Export pdf with puppeteer

Signed-off-by: Yukai Huang <yukaihuangtw@gmail.com>
This commit is contained in:
Yukai Huang 2020-03-09 10:09:38 +08:00
parent 42ae89cc72
commit c8462c72b3
7 changed files with 236 additions and 52 deletions

View File

@ -1,6 +1,7 @@
'use strict'
const os = require('os')
const uuid = require('uuid/v4')
module.exports = {
domain: '',
@ -186,5 +187,6 @@ module.exports = {
// 2nd appearance: "31-good-morning-my-friend---do-you-have-5-1"
// 3rd appearance: "31-good-morning-my-friend---do-you-have-5-2"
linkifyHeaderStyle: 'keep-case',
autoVersionCheck: true
autoVersionCheck: true,
codimdSignKey: uuid()
}

View File

@ -1,5 +1,7 @@
'use strict'
const jwt = require('jsonwebtoken')
const config = require('../config')
const logger = require('../logger')
@ -77,6 +79,24 @@ async function showNote (req, res) {
return responseCodiMD(res, note)
}
async function authenticateUser (req) {
const authHeader = req.header('Authorization')
const token = authHeader && authHeader.replace('Bearer ', '')
if (token) {
const { userId } = jwt.verify(token, config.codimdSignKey)
const user = await User.findOne({ id: userId })
if (user) {
return [true, user]
} else {
return [false]
}
} else {
return [req.isAuthenticated(), req.user]
}
}
function canViewNote (note, isLogin, userId) {
if (note.permission === 'private') {
return note.ownerId === userId
@ -87,6 +107,26 @@ function canViewNote (note, isLogin, userId) {
return true
}
async function findNote (req, res, next) {
const noteId = req.params.noteId
const note = await getNoteById(noteId)
if (!note) {
return errorNotFound(req, res)
}
const [isAuthenticated, user] = await authenticateUser(req)
if (!canViewNote(note, isAuthenticated, user ? user.id : null)) {
return errorForbidden(req, res)
}
res.locals.note = note
next()
}
async function showPublishNote (req, res) {
const shortid = req.params.shortid
@ -141,18 +181,9 @@ async function showPublishNote (req, res) {
}
async function noteActions (req, res) {
const { note } = res.locals
const noteId = req.params.noteId
const note = await getNoteById(noteId)
if (!note) {
return errorNotFound(req, res)
}
if (!canViewNote(note, req.isAuthenticated(), req.user ? req.user.id : null)) {
return errorForbidden(req, res)
}
const action = req.params.action
switch (action) {
case 'publish':
@ -191,3 +222,5 @@ async function noteActions (req, res) {
exports.showNote = showNote
exports.showPublishNote = showPublishNote
exports.noteActions = noteActions
exports.actionPDF = actionPDF
exports.findNote = findNote

View File

@ -1,8 +1,8 @@
'use strict'
const fs = require('fs')
const path = require('path')
const markdownpdf = require('markdown-pdf')
const jwt = require('jsonwebtoken')
const puppeteer = require('puppeteer')
const shortId = require('shortid')
const querystring = require('querystring')
const moment = require('moment')
@ -10,7 +10,7 @@ const { Pandoc } = require('@hackmd/pandoc.js')
const config = require('../config')
const logger = require('../logger')
const { Note, Revision } = require('../models')
const { Note, Revision, User } = require('../models')
const { errorInternalError, errorNotFound } = require('../response')
function actionPublish (req, res, note) {
@ -64,43 +64,67 @@ function actionInfo (req, res, note) {
res.send(data)
}
function actionPDF (req, res, note) {
const url = config.serverURL || 'http://' + req.get('host')
const body = note.content
const extracted = Note.extractMeta(body)
let content = extracted.markdown
const title = Note.decodeTitle(note.title)
const highlightCssPath = path.join(config.appRootPath, '/node_modules/highlight.js/styles/github-gist.css')
if (!fs.existsSync(config.tmpPath)) {
fs.mkdirSync(config.tmpPath)
}
const pdfPath = config.tmpPath + '/' + Date.now() + '.pdf'
content = content.replace(/\]\(\//g, '](' + url + '/')
const markdownpdfOptions = {
highlightCssPath: highlightCssPath
}
markdownpdf(markdownpdfOptions).from.string(content).to(pdfPath, function () {
if (!fs.existsSync(pdfPath)) {
logger.error('PDF seems to not be generated as expected. File doesn\'t exist: ' + pdfPath)
return errorInternalError(req, res)
}
const stream = fs.createReadStream(pdfPath)
let filename = title
// Be careful of special characters
filename = encodeURIComponent(filename)
// Ideally this should strip them
res.setHeader('Content-disposition', 'attachment; filename="' + filename + '.pdf"')
res.setHeader('Cache-Control', 'private')
res.setHeader('Content-Type', 'application/pdf; charset=UTF-8')
res.setHeader('X-Robots-Tag', 'noindex, nofollow') // prevent crawling
stream.on('end', () => {
stream.close()
fs.unlinkSync(pdfPath)
})
stream.pipe(res)
async function printPDF (noteUrl, headers = {}) {
const browser = await puppeteer.launch({
headless: true,
args: ['--disable-dev-shm-usage']
})
const page = await browser.newPage()
await page.setExtraHTTPHeaders(headers)
await page.goto(noteUrl, { waitUntil: 'networkidle0' })
const pdf = await page.pdf({ format: 'A4' })
await browser.close()
return pdf
}
function actionPDF (req, res, note) {
const noteId = req.params.noteId
if (req.method === 'POST') {
const token = jwt.sign({ userId: req.user.id.toString() }, config.codimdSignKey, { expiresIn: 5 * 60 })
const noteURL = `${config.serverURL}/${noteId}/pdf`
return printPDF(noteURL, {
Authorization: `Bearer ${token}`
}).then(pdf => {
res.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdf.length })
res.send(pdf)
})
} else {
const body = note.content
const extracted = Note.extractMeta(body)
const markdown = extracted.markdown
const meta = Note.parseMeta(extracted.meta)
const createTime = note.createdAt
const updateTime = note.lastchangeAt
const title = Note.generateWebTitle(meta.title || Note.decodeTitle(note.title))
const data = {
title: title,
description: meta.description || (markdown ? Note.generateDescription(markdown) : null),
viewcount: note.viewcount,
createtime: createTime,
updatetime: updateTime,
body: body,
owner: note.owner ? note.owner.id : null,
ownerprofile: note.owner ? User.getProfile(note.owner) : null,
lastchangeuser: note.lastchangeuser ? note.lastchangeuser.id : null,
lastchangeuserprofile: note.lastchangeuser ? User.getProfile(note.lastchangeuser) : null,
robots: meta.robots || false, // default allow robots
GA: meta.GA,
disqus: meta.disqus,
cspNonce: res.locals.nonce
}
res.set({
'Cache-Control': 'private' // only cache by client
})
return res.render('pretty.ejs', data)
}
}
const outputFormats = {

View File

@ -74,7 +74,8 @@ appRouter.get('/p/:shortid/:action', response.publishSlideActions)
// get note by id
appRouter.get('/:noteId', wrap(noteController.showNote))
// note actions
appRouter.get('/:noteId/:action', noteController.noteActions)
appRouter.get('/:noteId/:action', noteController.findNote, noteController.noteActions)
appRouter.post('/:noteId/:action', noteController.findNote, noteController.noteActions)
// note actions with action id
appRouter.get('/:noteId/:action/:actionId', noteController.noteActions)

111
package-lock.json generated
View File

@ -586,6 +586,11 @@
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
"integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw=="
},
"@types/mime-types": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.0.tgz",
"integrity": "sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM="
},
"@types/node": {
"version": "10.17.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.14.tgz",
@ -8699,6 +8704,23 @@
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.2.0.tgz",
"integrity": "sha1-XAxWhRBxYOcv50ib3eoLRMK8Z70="
},
"jsonwebtoken": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz",
"integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==",
"requires": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^5.6.0"
}
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
@ -9046,11 +9068,36 @@
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM="
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8="
},
"lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY="
},
"lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M="
},
"lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w="
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs="
},
"lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE="
},
"lodash.map": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz",
@ -9067,6 +9114,11 @@
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
"lodash.pick": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
@ -12694,6 +12746,11 @@
"ipaddr.js": "1.9.0"
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@ -12761,6 +12818,60 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"puppeteer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-2.1.1.tgz",
"integrity": "sha512-LWzaDVQkk1EPiuYeTOj+CZRIjda4k2s5w4MK4xoH2+kgWV/SDlkYHmxatDdtYrciHUKSXTsGgPgPP8ILVdBsxg==",
"requires": {
"@types/mime-types": "^2.1.0",
"debug": "^4.1.0",
"extract-zip": "^1.6.6",
"https-proxy-agent": "^4.0.0",
"mime": "^2.0.3",
"mime-types": "^2.1.25",
"progress": "^2.0.1",
"proxy-from-env": "^1.0.0",
"rimraf": "^2.6.1",
"ws": "^6.1.0"
},
"dependencies": {
"agent-base": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz",
"integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g=="
},
"debug": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
"requires": {
"ms": "^2.1.1"
}
},
"https-proxy-agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz",
"integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==",
"requires": {
"agent-base": "5",
"debug": "4"
}
},
"mime": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
"integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA=="
},
"ws": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
"integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
"requires": {
"async-limiter": "~1.0.0"
}
}
}
},
"q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",

View File

@ -78,6 +78,7 @@
"js-cookie": "~2.2.0",
"js-yaml": "~3.13.1",
"jsdom-nogyp": "~0.8.3",
"jsonwebtoken": "^8.5.1",
"keymaster": "~1.6.2",
"list.js": "~1.5.0",
"lodash": "~4.17.15",
@ -124,6 +125,7 @@
"pg-hstore": "~2.3.2",
"plantuml-encoder": "^1.2.5",
"prismjs": "~1.17.1",
"puppeteer": "^2.1.1",
"randomcolor": "~0.5.4",
"raphael": "~2.2.8",
"readline-sync": "~1.4.7",

View File

@ -951,7 +951,18 @@ ui.toolbar.download.rawhtml.click(function (e) {
exportToRawHTML(ui.area.markdown)
})
// pdf
ui.toolbar.download.pdf.attr('download', '').attr('href', noteurl + '/pdf')
ui.toolbar.download.pdf.click(function (e) {
// TODO: using ajax
const form = document.createElement('form')
form.method = 'POST'
form.target = '_blank'
form.action = `${noteurl}/pdf`
document.body.appendChild(form)
form.submit()
})
ui.modal.pandocExport.find('#pandoc-export-download').click(function (e) {
e.preventDefault()