├── Procfile ├── docs ├── logo.png ├── heroku.png ├── logo.sketch ├── local-examples.md └── heroku.xml ├── .travis.yml ├── CHANGELOG.md ├── test ├── resources │ ├── special-chars.html │ ├── large-linked.html │ └── postmark-receipt.html ├── util │ └── index.js └── test-all.js ├── .env.sample ├── src ├── util │ ├── require-envs.js │ ├── logger.js │ ├── express.js │ └── validation.js ├── middleware │ ├── require-https.js │ ├── error-responder.js │ └── error-logger.js ├── config.js ├── index.js ├── router.js ├── app.js ├── http │ └── render-http.js └── core │ └── render-core.js ├── .vscode └── launch.json ├── .eslintrc ├── .gitignore ├── LICENSE ├── app.json ├── package.json └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: NODE_ENV=production node src/index.js -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/url-to-pdf-api/master/docs/logo.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | env: 5 | - ALLOW_HTTP=true 6 | -------------------------------------------------------------------------------- /docs/heroku.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/url-to-pdf-api/master/docs/heroku.png -------------------------------------------------------------------------------- /docs/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adaptive/url-to-pdf-api/master/docs/logo.sketch -------------------------------------------------------------------------------- /docs/local-examples.md: -------------------------------------------------------------------------------- 1 | # Local examples 2 | 3 | curl -o html.pdf -XPOST -d@test/resources/large-linked.html -H"content-type: text/html" https://url-to-pdf-api.herokuapp.com/api/render -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | * change the `:html` output to return `document.documentElement.innerHTML` instead of previously used `document.body.innerHTML` 4 | 5 | ## 1.0.0 6 | 7 | * initial version 8 | -------------------------------------------------------------------------------- /test/resources/special-chars.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

7 | special characters: ä ö ü 8 |

9 | 10 | 11 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Guide: 2 | # 3 | # 1. Copy this file to .env 4 | # 5 | # cp .env.sample .env 6 | # 7 | # 2. Fill the blanks 8 | 9 | NODE_ENV=development 10 | PORT=9000 11 | ALLOW_HTTP=true 12 | 13 | # Warning: PDF rendering does not work in Chrome when it is in headed mode. 14 | DEBUG_MODE=false 15 | -------------------------------------------------------------------------------- /test/util/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | function getResource(name) { 5 | const filePath = path.join(__dirname, '../resources', name); 6 | return fs.readFileSync(filePath, { encoding: 'utf-8' }); 7 | } 8 | 9 | module.exports = { 10 | getResource, 11 | }; 12 | -------------------------------------------------------------------------------- /src/util/require-envs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | 3 | const _ = require('lodash'); 4 | 5 | function requireEnvs(arr) { 6 | _.each(arr, (varName) => { 7 | if (!process.env[varName]) { 8 | throw new Error(`Environment variable not set: ${varName}`); 9 | } 10 | }); 11 | } 12 | 13 | module.exports = requireEnvs; 14 | -------------------------------------------------------------------------------- /src/middleware/require-https.js: -------------------------------------------------------------------------------- 1 | const createRequireHttps = () => function RequireHttps(req, res, next) { 2 | if (req.secure) { 3 | // Allow requests only over https 4 | return next(); 5 | } 6 | 7 | const err = new Error('Only HTTPS allowed.'); 8 | err.status = 403; 9 | next(err); 10 | }; 11 | 12 | module.exports = createRequireHttps; 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/src/index.js", 12 | "env": { 13 | "NODE_ENV": "development", 14 | "PORT": "9000", 15 | "ALLOW_HTTP": "true", 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "amd": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "extends": "airbnb-base", 9 | "rules": { 10 | "no-implicit-coercion": "error", 11 | "no-process-env": "error", 12 | "no-path-concat": "error", 13 | "import/no-extraneous-dependencies": ["error", {"devDependencies": true}], 14 | "no-use-before-define": ["error", { "functions": false }], 15 | "no-underscore-dangle": "off", 16 | "no-console": "off", 17 | "comma-dangle": ["error", { 18 | "arrays": "always-multiline", 19 | "objects": "always-multiline", 20 | "imports": "always-multiline", 21 | "exports": "always-multiline", 22 | "functions": "ignore" 23 | }], 24 | "function-paren-newline": "off" 25 | } 26 | } -------------------------------------------------------------------------------- /src/util/logger.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const winston = require('winston'); 3 | const _ = require('lodash'); 4 | const config = require('../config'); 5 | 6 | const COLORIZE = config.NODE_ENV === 'development'; 7 | 8 | function createLogger(filePath) { 9 | const fileName = path.basename(filePath); 10 | 11 | const logger = new winston.Logger({ 12 | transports: [new winston.transports.Console({ 13 | colorize: COLORIZE, 14 | label: fileName, 15 | timestamp: true, 16 | })], 17 | }); 18 | 19 | _setLevelForTransports(logger, config.LOG_LEVEL || 'info'); 20 | return logger; 21 | } 22 | 23 | function _setLevelForTransports(logger, level) { 24 | _.each(logger.transports, (transport) => { 25 | // eslint-disable-next-line 26 | transport.level = level; 27 | }); 28 | } 29 | 30 | module.exports = createLogger; 31 | -------------------------------------------------------------------------------- /test/resources/large-linked.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Page 7 | 12 | 13 | 14 |

Page

15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | 3 | // Env vars should be casted to correct types 4 | const config = { 5 | PORT: Number(process.env.PORT) || 9000, 6 | NODE_ENV: process.env.NODE_ENV, 7 | LOG_LEVEL: process.env.LOG_LEVEL, 8 | ALLOW_HTTP: process.env.ALLOW_HTTP === 'true', 9 | DEBUG_MODE: process.env.DEBUG_MODE === 'true', 10 | DISABLE_HTML_INPUT: process.env.DISABLE_HTML_INPUT === 'true', 11 | CORS_ORIGIN: process.env.CORS_ORIGIN || '*', 12 | BROWSER_WS_ENDPOINT: process.env.BROWSER_WS_ENDPOINT, 13 | BROWSER_EXECUTABLE_PATH: process.env.BROWSER_EXECUTABLE_PATH, 14 | API_TOKENS: [], 15 | ALLOW_URLS: [], 16 | }; 17 | 18 | if (process.env.API_TOKENS) { 19 | config.API_TOKENS = process.env.API_TOKENS.split(','); 20 | } 21 | 22 | if (process.env.ALLOW_URLS) { 23 | config.ALLOW_URLS = process.env.ALLOW_URLS.split(','); 24 | } 25 | 26 | module.exports = config; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .DS_Store 3 | .idea 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # node-waf configuration 29 | .lock-wscript 30 | 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directories 35 | node_modules 36 | jspm_packages 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional eslint cache 42 | .eslintcache 43 | 44 | # Optional REPL history 45 | .node_repl_history 46 | 47 | # Output of 'npm pack' 48 | *.tgz 49 | 50 | # Yarn Integrity file 51 | .yarn-integrity 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Kimmo Brunfeldt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-to-pdf-api", 3 | "description": "Web page PDF rendering done right. Packaged to an easy API.", 4 | "keywords": [ 5 | "pdf", 6 | "html", 7 | "html to pdf", 8 | "html 2 pdf", 9 | "render" 10 | ], 11 | "website": "https://github.com/alvarcarto/url-to-pdf-api", 12 | "repository": "https://github.com/alvarcarto/url-to-pdf-api", 13 | "env": { 14 | "ALLOW_HTTP": { 15 | "description": "When set to \"true\", unsecure requests are allowed.", 16 | "value": "false" 17 | }, 18 | "API_TOKENS": { 19 | "description": "Comma-separated list of accepted keys in x-api-key header.", 20 | "required": false 21 | } 22 | }, 23 | "success_url": "/api/render?url=https://github.com/alvarcarto/url-to-pdf-api/blob/master/README.md", 24 | "buildpacks": [ 25 | { 26 | "url": "https://github.com/jontewks/puppeteer-heroku-buildpack" 27 | }, 28 | { 29 | "url": "http://github.com/heroku/heroku-buildpack-nodejs.git" 30 | }, 31 | { 32 | "url": "https://github.com/debitoor/heroku-buildpack-converter-fonts" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const createApp = require('./app'); 2 | const enableDestroy = require('server-destroy'); 3 | const BPromise = require('bluebird'); 4 | const logger = require('./util/logger')(__filename); 5 | const config = require('./config'); 6 | 7 | BPromise.config({ 8 | warnings: config.NODE_ENV !== 'production', 9 | longStackTraces: true, 10 | }); 11 | 12 | const app = createApp(); 13 | const server = app.listen(config.PORT, () => { 14 | logger.info( 15 | 'Express server listening on http://localhost:%d/ in %s mode', 16 | config.PORT, 17 | app.get('env') 18 | ); 19 | }); 20 | enableDestroy(server); 21 | 22 | function closeServer(signal) { 23 | logger.info(`${signal} received`); 24 | logger.info('Closing http.Server ..'); 25 | server.destroy(); 26 | } 27 | 28 | // Handle signals gracefully. Heroku will send SIGTERM before idle. 29 | process.on('SIGTERM', closeServer.bind(this, 'SIGTERM')); 30 | process.on('SIGINT', closeServer.bind(this, 'SIGINT(Ctrl-C)')); 31 | 32 | server.on('close', () => { 33 | logger.info('Server closed'); 34 | process.emit('cleanup'); 35 | 36 | logger.info('Giving 100ms time to cleanup..'); 37 | // Give a small time frame to clean up 38 | setTimeout(process.exit, 100); 39 | }); 40 | -------------------------------------------------------------------------------- /src/middleware/error-responder.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const _ = require('lodash'); 3 | 4 | // This responder is assuming that all <500 errors are safe to be responded 5 | // with their .message attribute. 6 | // DO NOT write sensitive data into error messages. 7 | function createErrorResponder(_opts) { 8 | const opts = _.merge({ 9 | isErrorSafeToRespond: status => status < 500, 10 | }, _opts); 11 | 12 | // 4 params needed for Express to know it's a error handler middleware 13 | // eslint-disable-next-line 14 | return function errorResponder(err, req, res, next) { 15 | let message; 16 | const status = err.status ? err.status : 500; 17 | 18 | const httpMessage = http.STATUS_CODES[status]; 19 | if (opts.isErrorSafeToRespond(status)) { 20 | // eslint-disable-next-line 21 | message = err.message; 22 | } else { 23 | message = httpMessage; 24 | } 25 | 26 | const isPrettyValidationErr = _.has(err, 'errors'); 27 | const body = isPrettyValidationErr 28 | ? JSON.stringify(err) 29 | : { status, statusText: httpMessage, messages: [message] }; 30 | 31 | res.status(status); 32 | res.send(body); 33 | }; 34 | } 35 | 36 | module.exports = createErrorResponder; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-to-pdf-api", 3 | "version": "1.0.0", 4 | "description": "Web page PDF rendering done right. Packaged to an easy API.", 5 | "main": "src/index.js", 6 | "engines": { 7 | "node": "10.x.x" 8 | }, 9 | "scripts": { 10 | "start": "env-cmd nodemon --watch ./src -e js src/index.js", 11 | "test": "mocha --timeout 10000 && npm run lint", 12 | "lint": "eslint ." 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/alvarcarto/url-to-pdf-api.git" 17 | }, 18 | "author": "Kimmo Brunfeldt", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/alvarcarto/url-to-pdf-api/issues" 22 | }, 23 | "homepage": "https://github.com/alvarcarto/url-to-pdf-api#readme", 24 | "dependencies": { 25 | "bluebird": "^3.5.0", 26 | "body-parser": "^1.18.2", 27 | "compression": "^1.7.1", 28 | "cors": "^2.8.4", 29 | "express": "^4.15.5", 30 | "express-validation": "^1.0.2", 31 | "joi": "^11.1.1", 32 | "lodash": "^4.17.15", 33 | "morgan": "^1.9.1", 34 | "normalize-url": "^5.0.0", 35 | "pdf-parse": "^1.1.1", 36 | "puppeteer": "^2.0.0", 37 | "server-destroy": "^1.0.1", 38 | "winston": "^2.3.1" 39 | }, 40 | "devDependencies": { 41 | "chai": "^4.1.2", 42 | "env-cmd": "^9.0.1", 43 | "eslint": "^4.8.0", 44 | "eslint-config-airbnb-base": "^12.0.2", 45 | "eslint-plugin-import": "^2.7.0", 46 | "mocha": "^4.0.1", 47 | "nodemon": "^1.12.1", 48 | "supertest": "^3.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/middleware/error-logger.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const logger = require('../util/logger')(__filename); 3 | 4 | const SLICE_THRESHOLD = 1000; 5 | 6 | function createErrorLogger(_opts) { 7 | const opts = _.merge({ 8 | logRequest: status => status >= 400 && status !== 404 && status !== 503, 9 | logStackTrace: status => status >= 500 && status !== 503, 10 | }, _opts); 11 | 12 | return function errorHandler(err, req, res, next) { 13 | const status = err.status ? err.status : 500; 14 | const logLevel = getLogLevel(status); 15 | const log = logger[logLevel]; 16 | 17 | if (opts.logRequest(status)) { 18 | logRequestDetails(logLevel, req, status); 19 | } 20 | 21 | if (opts.logStackTrace(status)) { 22 | log(err, err.stack); 23 | } else { 24 | log(err.toString()); 25 | } 26 | 27 | next(err); 28 | }; 29 | } 30 | 31 | function getLogLevel(status) { 32 | return status >= 500 ? 'error' : 'warn'; 33 | } 34 | 35 | function logRequestDetails(logLevel, req) { 36 | logger[logLevel]('Request headers:', deepSupressLongStrings(req.headers)); 37 | logger[logLevel]('Request parameters:', deepSupressLongStrings(req.params)); 38 | logger[logLevel]('Request body:', req.body); 39 | } 40 | 41 | function deepSupressLongStrings(obj) { 42 | const newObj = {}; 43 | _.each(obj, (val, key) => { 44 | if (_.isString(val) && val.length > SLICE_THRESHOLD) { 45 | newObj[key] = `${val.slice(0, SLICE_THRESHOLD)} ... [CONTENT SLICED]`; 46 | } else if (_.isPlainObject(val)) { 47 | deepSupressLongStrings(val); 48 | } else { 49 | newObj[key] = val; 50 | } 51 | }); 52 | 53 | return newObj; 54 | } 55 | 56 | module.exports = createErrorLogger; 57 | -------------------------------------------------------------------------------- /docs/heroku.xml: -------------------------------------------------------------------------------- 1 | 7VhJk+I2FP41HIeSLRb7CHSTSVVPhZo+JHMUtsBKC8uRZZb8+ujJ8obcDEx7Dqkac8D69LS9971FHuHV4fybJFnyRcSUj3wUn0f4aeT7Hp7O9R8glxIJJmEJ7CWLrVADvLJ/qQWRRQsW07wjqITgimVdMBJpSiPVwYiU4tQV2wneXTUje+oArxHhLvoni1ViTzFFDf6Zsn1Srewh27Ml0dteiiK16418vDNP2X0g1VxWPk9ILE4tCD+P8EoKocq3w3lFOei2Uls5bv1Ob71vSVN1z4BpOeBIeEGrHZt9qUulC3MaCvJohJenhCn6mpEIek/a+hpL1IHrlqdfcyXFG10JLqQZjScrhMKw7qm0iTWyY5xXkqlIKUAiVZYNHrbt1mQz82g8JnlitgRrZlSyA1VUwrZYutdwaGU2RGk8NYI+AmF7XioVPb+rM6+2hGY4FXpyedEidoA/t1qz5PZCa8xTQxWMLZa0aOJPLEgsPff13I2J9Iu10jsmdi1EY01e27SKjAp5rBXUsg89M/UXmHI8ta1vVU+qN9LqguY3a/RcEakW4FfNCgZbM9inkaFpXElEnOQ5i0rQisASf1OlLta8pFBCQ0KqROxFSviLENm7JHqCn0Mi7wHGlEoDTd22ulasKGRkpWwY00fd00oK9ZNDUk4UO3an/4ih545rPp8zSfNcg4vN7wP7aa2oHhW3/BTi2RR+D6v+Qy6HA9RxuTp+tlzO83tcbjaExyHHEqtE6nk0tiIpsbO1baHDegav7GASTVvzoAemM82Csz0EJgW0r9EXsqV8I3KmmIDerVBKHLQAh45lnV3a9ijzC16axRZ5ViZEIAGpGjt2BmYs7X6eEqUgky5AB/46ilNvzHQu3THNIDmO9Ir+OiaK6D/ANeXWXLupumQU3jEoLzI6+OT5wTiDoLtsCNjLMGSe7zLswUwwALkmXjeeYzxzyDXt4dZ0CG7hngQ84wryFzvq170yhyyhrbxGwE6uXA9U8GuEswp5IUUaJcBmS+t67kbGHfVHRtOqlPJXoC7ClCm2pAmH/xQ0V7mp3ABlKcuTO6f+SoGHpWp9tHla3xynQfd014q5clDNFnUrIlYsdIlJrN9yulM97nxgcQyL9Abgroe0KY5uUNzI2a37Q/B94nWD6Txwg+ls+pMI71acn6nWfAHr0625M6TiJ9gr0trRnBrMYp5rMV30Icdi3iAVZ3BVcU5ci6G+inMAi9V10a+C8w4OrM3zQMH5gUrSDt0Ilqq2e6OxN9dZDc9D7CHcTW6hP8aTSaDvMMHMR7Nw3p2+LH7tjO174/UiOBw3swThfNYlaHWPrOYtK2hnXsO9+uj3BZBZT8b8v5df6LvlV0yPLKL5pwkobwqXwPUXEm2FeOutwND1Jbn/NuwQv3x+5Lre4wt1BrvKmUPkMHR1IXDvA0FPPAyGiIfI4duveDhcPOxewL2eG/j8g2Hz7lLFvYHXpcpXUShToRoXRi+CxObrHydpBPhwd/Pa2rf8sf2h7V6DOIXRIIUK7uYB3PdpDLuOiR93TN1svpOWiaT5GI2f/wM= -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const validate = require('express-validation'); 3 | const express = require('express'); 4 | const render = require('./http/render-http'); 5 | const config = require('./config'); 6 | const logger = require('./util/logger')(__filename); 7 | const { renderQuerySchema, renderBodySchema, sharedQuerySchema } = require('./util/validation'); 8 | 9 | function createRouter() { 10 | const router = express.Router(); 11 | 12 | if (!_.isEmpty(config.API_TOKENS)) { 13 | logger.info('x-api-key authentication required'); 14 | 15 | router.use('/*', (req, res, next) => { 16 | const userToken = req.headers['x-api-key']; 17 | if (!_.includes(config.API_TOKENS, userToken)) { 18 | const err = new Error('Invalid API token in x-api-key header.'); 19 | err.status = 401; 20 | return next(err); 21 | } 22 | 23 | return next(); 24 | }); 25 | } else { 26 | logger.warn('Warning: no authentication required to use the API'); 27 | } 28 | 29 | const getRenderSchema = { 30 | query: renderQuerySchema, 31 | options: { 32 | allowUnknownBody: false, 33 | allowUnknownQuery: false, 34 | }, 35 | }; 36 | router.get('/api/render', validate(getRenderSchema), render.getRender); 37 | 38 | const postRenderSchema = { 39 | body: renderBodySchema, 40 | query: sharedQuerySchema, 41 | options: { 42 | allowUnknownBody: false, 43 | allowUnknownQuery: false, 44 | 45 | // Without this option, text body causes an error 46 | // https://github.com/AndrewKeig/express-validation/issues/36 47 | contextRequest: true, 48 | }, 49 | }; 50 | router.post('/api/render', validate(postRenderSchema), render.postRender); 51 | 52 | return router; 53 | } 54 | 55 | module.exports = createRouter; 56 | -------------------------------------------------------------------------------- /src/util/express.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const BPromise = require('bluebird'); 3 | 4 | // Route which assumes that the Promise `func` returns, will be resolved 5 | // with data which will be sent as json response. 6 | function createJsonRoute(func) { 7 | return createRoute(func, (data, req, res) => { 8 | res.json(data); 9 | }); 10 | } 11 | 12 | // Generic route creator 13 | // Factory function to create a new route to reduce boilerplate in controllers 14 | // and make it easier to interact with promises. 15 | // `func` must return a promise 16 | // `responseHandler` receives the data from asynchronous `func` as the first 17 | // parameter 18 | // Factory function to create a new 'raw' route handler. 19 | // When using this function directly instead of `createJsonRoute`, you must 20 | // send a response to express' `res` object. 21 | function createRoute(func, responseHandler) { 22 | return function route(req, res, next) { 23 | try { 24 | const callback = _.isFunction(responseHandler) 25 | ? func.bind(this, req, res) 26 | : func.bind(this, req, res, next); 27 | 28 | let valuePromise = callback(); 29 | if (!_.isFunction(_.get(valuePromise, 'then'))) { 30 | // It was a not a Promise, so wrap it as a Promise 31 | valuePromise = BPromise.resolve(valuePromise); 32 | } 33 | 34 | if (_.isFunction(responseHandler)) { 35 | valuePromise 36 | .then(data => responseHandler(data, req, res, next)) 37 | .catch(next); 38 | } else { 39 | valuePromise.catch(next); 40 | } 41 | } catch (err) { 42 | next(err); 43 | } 44 | }; 45 | } 46 | 47 | function throwStatus(status, message) { 48 | const err = new Error(message); 49 | err.status = status; 50 | throw err; 51 | } 52 | 53 | module.exports = { 54 | createRoute, 55 | createJsonRoute, 56 | throwStatus, 57 | }; 58 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const morgan = require('morgan'); 3 | const bodyParser = require('body-parser'); 4 | const compression = require('compression'); 5 | const cors = require('cors'); 6 | const logger = require('./util/logger')(__filename); 7 | const errorResponder = require('./middleware/error-responder'); 8 | const errorLogger = require('./middleware/error-logger'); 9 | const requireHttps = require('./middleware/require-https'); 10 | const createRouter = require('./router'); 11 | const config = require('./config'); 12 | 13 | function createApp() { 14 | const app = express(); 15 | // App is served behind Heroku's router. 16 | // This is needed to be able to use req.ip or req.secure 17 | app.enable('trust proxy', 1); 18 | app.disable('x-powered-by'); 19 | 20 | if (config.NODE_ENV !== 'production') { 21 | app.use(morgan('dev')); 22 | } 23 | 24 | if (!config.ALLOW_HTTP) { 25 | logger.info('All requests require HTTPS.'); 26 | app.use(requireHttps()); 27 | } else { 28 | logger.info('ALLOW_HTTP=true, unsafe requests are allowed. Don\'t use this in production.'); 29 | } 30 | 31 | if (config.ALLOW_URLS) { 32 | logger.info(`ALLOW_URLS set! Allowed urls patterns are: ${config.ALLOW_URLS.join(' ')}`); 33 | } 34 | 35 | if (config.DISABLE_HTML_INPUT) { 36 | logger.info('DISABLE_HTML_INPUT=true! Input HTML is disabled!'); 37 | } 38 | 39 | const corsOpts = { 40 | origin: config.CORS_ORIGIN, 41 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'], 42 | }; 43 | logger.info('Using CORS options:', corsOpts); 44 | app.use(cors(corsOpts)); 45 | 46 | // Limit to 10mb if HTML has e.g. inline images 47 | app.use(bodyParser.text({ limit: '10mb', type: 'text/html' })); 48 | app.use(bodyParser.json({ limit: '10mb' })); 49 | 50 | app.use(compression({ 51 | // Compress everything over 10 bytes 52 | threshold: 10, 53 | })); 54 | 55 | // Initialize routes 56 | const router = createRouter(); 57 | app.use('/', router); 58 | 59 | app.use(errorLogger()); 60 | app.use(errorResponder()); 61 | 62 | return app; 63 | } 64 | 65 | module.exports = createApp; 66 | -------------------------------------------------------------------------------- /src/util/validation.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const urlSchema = Joi.string().uri({ 4 | scheme: [ 5 | 'http', 6 | 'https', 7 | ], 8 | }); 9 | 10 | const cookieSchema = Joi.object({ 11 | name: Joi.string().required(), 12 | value: Joi.string().required(), 13 | url: Joi.string(), 14 | domain: Joi.string(), 15 | path: Joi.string(), 16 | expires: Joi.number().min(1), 17 | httpOnly: Joi.boolean(), 18 | secure: Joi.boolean(), 19 | sameSite: Joi.string().regex(/^(Strict|Lax)$/), 20 | }); 21 | 22 | const sharedQuerySchema = Joi.object({ 23 | attachmentName: Joi.string(), 24 | scrollPage: Joi.boolean(), 25 | emulateScreenMedia: Joi.boolean(), 26 | enableGPU: Joi.boolean(), 27 | ignoreHttpsErrors: Joi.boolean(), 28 | waitFor: Joi.alternatives([ 29 | Joi.number().min(1).max(60000), 30 | Joi.string().min(1).max(2000), 31 | ]), 32 | cookies: Joi.array().items(cookieSchema), 33 | output: Joi.string().valid(['pdf', 'screenshot', 'html']), 34 | 'viewport.width': Joi.number().min(1).max(30000), 35 | 'viewport.height': Joi.number().min(1).max(30000), 36 | 'viewport.deviceScaleFactor': Joi.number().min(0).max(100), 37 | 'viewport.isMobile': Joi.boolean(), 38 | 'viewport.hasTouch': Joi.boolean(), 39 | 'viewport.isLandscape': Joi.boolean(), 40 | 'goto.timeout': Joi.number().min(0).max(60000), 41 | 'goto.waitUntil': Joi.string().min(1).max(2000), 42 | 'pdf.scale': Joi.number().min(0).max(1000), 43 | 'pdf.displayHeaderFooter': Joi.boolean(), 44 | 'pdf.landscape': Joi.boolean(), 45 | 'pdf.pageRanges': Joi.string().min(1).max(2000), 46 | 'pdf.format': Joi.string().min(1).max(2000), 47 | 'pdf.width': Joi.string().min(1).max(2000), 48 | 'pdf.height': Joi.string().min(1).max(2000), 49 | 'pdf.fullPage': Joi.boolean(), 50 | 'pdf.footerTemplate': Joi.string(), 51 | 'pdf.headerTemplate': Joi.string(), 52 | 'pdf.margin.top': Joi.string().min(1).max(2000), 53 | 'pdf.margin.right': Joi.string().min(1).max(2000), 54 | 'pdf.margin.bottom': Joi.string().min(1).max(2000), 55 | 'pdf.margin.left': Joi.string().min(1).max(2000), 56 | 'pdf.printBackground': Joi.boolean(), 57 | 'screenshot.fullPage': Joi.boolean(), 58 | 'screenshot.quality': Joi.number().integer().min(0).max(100), 59 | 'screenshot.type': Joi.string().valid(['png', 'jpeg']), 60 | 'screenshot.clip.x': Joi.number(), 61 | 'screenshot.clip.y': Joi.number(), 62 | 'screenshot.clip.width': Joi.number(), 63 | 'screenshot.clip.height': Joi.number(), 64 | 'screenshot.selector': Joi.string().regex(/(#|\.).*/), 65 | 'screenshot.omitBackground': Joi.boolean(), 66 | }); 67 | 68 | const renderQuerySchema = Joi.object({ 69 | url: urlSchema.required(), 70 | }).concat(sharedQuerySchema); 71 | 72 | const renderBodyObject = Joi.object({ 73 | url: urlSchema, 74 | html: Joi.string(), 75 | attachmentName: Joi.string(), 76 | scrollPage: Joi.boolean(), 77 | ignoreHttpsErrors: Joi.boolean(), 78 | emulateScreenMedia: Joi.boolean(), 79 | cookies: Joi.array().items(cookieSchema), 80 | output: Joi.string().valid(['pdf', 'screenshot', 'html']), 81 | viewport: Joi.object({ 82 | width: Joi.number().min(1).max(30000), 83 | height: Joi.number().min(1).max(30000), 84 | deviceScaleFactor: Joi.number().min(0).max(100), 85 | isMobile: Joi.boolean(), 86 | hasTouch: Joi.boolean(), 87 | isLandscape: Joi.boolean(), 88 | }), 89 | waitFor: Joi.alternatives([ 90 | Joi.number().min(1).max(60000), 91 | Joi.string().min(1).max(2000), 92 | ]), 93 | goto: Joi.object({ 94 | timeout: Joi.number().min(0).max(60000), 95 | waitUntil: Joi.string().min(1).max(2000), 96 | }), 97 | pdf: Joi.object({ 98 | scale: Joi.number().min(0).max(1000), 99 | displayHeaderFooter: Joi.boolean(), 100 | landscape: Joi.boolean(), 101 | pageRanges: Joi.string().min(1).max(2000), 102 | format: Joi.string().min(1).max(2000), 103 | width: Joi.string().min(1).max(2000), 104 | height: Joi.string().min(1).max(2000), 105 | fullPage: Joi.boolean(), 106 | footerTemplate: Joi.string(), 107 | headerTemplate: Joi.string(), 108 | margin: Joi.object({ 109 | top: Joi.string().min(1).max(2000), 110 | right: Joi.string().min(1).max(2000), 111 | bottom: Joi.string().min(1).max(2000), 112 | left: Joi.string().min(1).max(2000), 113 | }), 114 | printBackground: Joi.boolean(), 115 | }), 116 | screenshot: Joi.object({ 117 | fullPage: Joi.boolean(), 118 | quality: Joi.number().integer().min(0).max(100), 119 | type: Joi.string().valid(['png', 'jpeg']), 120 | clip: { 121 | x: Joi.number(), 122 | y: Joi.number(), 123 | width: Joi.number(), 124 | height: Joi.number(), 125 | }, 126 | selector: Joi.string().regex(/(#|\.).*/), 127 | omitBackground: Joi.boolean(), 128 | }), 129 | failEarly: Joi.string(), 130 | }); 131 | 132 | const renderBodySchema = Joi.alternatives([ 133 | Joi.string(), 134 | renderBodyObject, 135 | ]); 136 | 137 | module.exports = { 138 | renderQuerySchema, 139 | renderBodySchema, 140 | sharedQuerySchema, 141 | }; 142 | 143 | -------------------------------------------------------------------------------- /src/http/render-http.js: -------------------------------------------------------------------------------- 1 | const { URL } = require('url'); 2 | const _ = require('lodash'); 3 | const normalizeUrl = require('normalize-url'); 4 | const ex = require('../util/express'); 5 | const renderCore = require('../core/render-core'); 6 | const logger = require('../util/logger')(__filename); 7 | const config = require('../config'); 8 | 9 | function getMimeType(opts) { 10 | if (opts.output === 'pdf') { 11 | return 'application/pdf'; 12 | } else if (opts.output === 'html') { 13 | return 'text/html'; 14 | } 15 | 16 | const type = _.get(opts, 'screenshot.type'); 17 | switch (type) { 18 | case 'png': return 'image/png'; 19 | case 'jpeg': return 'image/jpeg'; 20 | default: throw new Error(`Unknown screenshot type: ${type}`); 21 | } 22 | } 23 | 24 | const getRender = ex.createRoute((req, res) => { 25 | const opts = getOptsFromQuery(req.query); 26 | 27 | assertOptionsAllowed(opts); 28 | return renderCore.render(opts) 29 | .then((data) => { 30 | if (opts.attachmentName) { 31 | res.attachment(opts.attachmentName); 32 | } 33 | res.set('content-type', getMimeType(opts)); 34 | res.send(data); 35 | }); 36 | }); 37 | 38 | const postRender = ex.createRoute((req, res) => { 39 | const isBodyJson = req.headers['content-type'].includes('application/json'); 40 | if (isBodyJson) { 41 | const hasContent = _.isString(_.get(req.body, 'url')) || _.isString(_.get(req.body, 'html')); 42 | if (!hasContent) { 43 | ex.throwStatus(400, 'Body must contain url or html'); 44 | } 45 | } else if (_.isString(req.query.url)) { 46 | ex.throwStatus(400, 'url query parameter is not allowed when body is HTML'); 47 | } 48 | 49 | let opts; 50 | if (isBodyJson) { 51 | opts = _.merge({ 52 | output: 'pdf', 53 | screenshot: { 54 | type: 'png', 55 | }, 56 | }, req.body); 57 | } else { 58 | opts = getOptsFromQuery(req.query); 59 | opts.html = req.body; 60 | } 61 | 62 | assertOptionsAllowed(opts); 63 | return renderCore.render(opts) 64 | .then((data) => { 65 | if (opts.attachmentName) { 66 | res.attachment(opts.attachmentName); 67 | } 68 | res.set('content-type', getMimeType(opts)); 69 | res.send(data); 70 | }); 71 | }); 72 | 73 | function isHostMatch(host1, host2) { 74 | return { 75 | match: host1.toLowerCase() === host2.toLowerCase(), 76 | type: 'host', 77 | part1: host1.toLowerCase(), 78 | part2: host2.toLowerCase(), 79 | }; 80 | } 81 | 82 | function isRegexMatch(urlPattern, inputUrl) { 83 | const re = new RegExp(`${urlPattern}`); 84 | 85 | return { 86 | match: re.test(inputUrl), 87 | type: 'regex', 88 | part1: inputUrl, 89 | part2: urlPattern, 90 | }; 91 | } 92 | 93 | function isNormalizedMatch(url1, url2) { 94 | return { 95 | match: normalizeUrl(url1) === normalizeUrl(url2), 96 | type: 'normalized url', 97 | part1: url1, 98 | part2: url2, 99 | }; 100 | } 101 | 102 | function isUrlAllowed(inputUrl) { 103 | const urlParts = new URL(inputUrl); 104 | 105 | const matchInfos = _.map(config.ALLOW_URLS, (urlPattern) => { 106 | if (_.startsWith(urlPattern, 'host:')) { 107 | return isHostMatch(urlPattern.split(':')[1], urlParts.host); 108 | } else if (_.startsWith(urlPattern, 'regex:')) { 109 | return isRegexMatch(urlPattern.split(':')[1], inputUrl); 110 | } 111 | 112 | return isNormalizedMatch(urlPattern, inputUrl); 113 | }); 114 | 115 | const isAllowed = _.some(matchInfos, info => info.match); 116 | if (!isAllowed) { 117 | logger.info('The url was not allowed because:'); 118 | _.forEach(matchInfos, (info) => { 119 | logger.info(`${info.part1} !== ${info.part2} (with ${info.type} matching)`); 120 | }); 121 | } 122 | 123 | return isAllowed; 124 | } 125 | 126 | function assertOptionsAllowed(opts) { 127 | const isDisallowedHtmlInput = !_.isString(opts.url) && config.DISABLE_HTML_INPUT; 128 | if (isDisallowedHtmlInput) { 129 | ex.throwStatus(403, 'Rendering HTML input is disabled.'); 130 | } 131 | 132 | if (_.isString(opts.url) && config.ALLOW_URLS.length > 0 && !isUrlAllowed(opts.url)) { 133 | ex.throwStatus(403, 'Url not allowed.'); 134 | } 135 | } 136 | 137 | function getOptsFromQuery(query) { 138 | const opts = { 139 | url: query.url, 140 | attachmentName: query.attachmentName, 141 | scrollPage: query.scrollPage, 142 | emulateScreenMedia: query.emulateScreenMedia, 143 | enableGPU: query.enableGPU, 144 | ignoreHttpsErrors: query.ignoreHttpsErrors, 145 | waitFor: query.waitFor, 146 | output: query.output || 'pdf', 147 | viewport: { 148 | width: query['viewport.width'], 149 | height: query['viewport.height'], 150 | deviceScaleFactor: query['viewport.deviceScaleFactor'], 151 | isMobile: query['viewport.isMobile'], 152 | hasTouch: query['viewport.hasTouch'], 153 | isLandscape: query['viewport.isLandscape'], 154 | }, 155 | goto: { 156 | timeout: query['goto.timeout'], 157 | waitUntil: query['goto.waitUntil'], 158 | }, 159 | pdf: { 160 | fullPage: query['pdf.fullPage'], 161 | scale: query['pdf.scale'], 162 | displayHeaderFooter: query['pdf.displayHeaderFooter'], 163 | footerTemplate: query['pdf.footerTemplate'], 164 | headerTemplate: query['pdf.headerTemplate'], 165 | landscape: query['pdf.landscape'], 166 | pageRanges: query['pdf.pageRanges'], 167 | format: query['pdf.format'], 168 | width: query['pdf.width'], 169 | height: query['pdf.height'], 170 | margin: { 171 | top: query['pdf.margin.top'], 172 | right: query['pdf.margin.right'], 173 | bottom: query['pdf.margin.bottom'], 174 | left: query['pdf.margin.left'], 175 | }, 176 | printBackground: query['pdf.printBackground'], 177 | }, 178 | screenshot: { 179 | fullPage: query['screenshot.fullPage'], 180 | quality: query['screenshot.quality'], 181 | type: query['screenshot.type'] || 'png', 182 | clip: { 183 | x: query['screenshot.clip.x'], 184 | y: query['screenshot.clip.y'], 185 | width: query['screenshot.clip.width'], 186 | height: query['screenshot.clip.height'], 187 | }, 188 | selector: query['screenshot.selector'], 189 | omitBackground: query['screenshot.omitBackground'], 190 | }, 191 | }; 192 | return opts; 193 | } 194 | 195 | module.exports = { 196 | getRender, 197 | postRender, 198 | }; 199 | -------------------------------------------------------------------------------- /test/test-all.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | const chai = require('chai'); 4 | const fs = require('fs'); 5 | const request = require('supertest'); 6 | const BPromise = require('bluebird'); 7 | const { getResource } = require('./util'); 8 | const pdf = require('pdf-parse'); 9 | const createApp = require('../src/app'); 10 | 11 | const DEBUG = false; 12 | 13 | BPromise.config({ 14 | longStackTraces: true, 15 | }); 16 | 17 | const app = createApp(); 18 | 19 | function normalisePdfText(text) { 20 | // Replace all non-alphanumeric characters with a hyphen to resolve some difference in 21 | // character encoding when comparing strings extracted from the PDF and strings 22 | // defined in the test environment 23 | return text.replace(/[\W_]+/g, '-'); 24 | } 25 | 26 | function getPdfTextContent(buffer, opts = {}) { 27 | return pdf(buffer) 28 | .then((data) => { 29 | if (opts.raw) { 30 | return data.text; 31 | } 32 | 33 | return normalisePdfText(data.text); 34 | }); 35 | } 36 | 37 | describe('GET /api/render', () => { 38 | it('request must have "url" query parameter', () => 39 | request(app).get('/api/render').expect(400) 40 | ); 41 | 42 | it('invalid cert should cause an error', () => 43 | request(app) 44 | .get('/api/render') 45 | .query({ 46 | url: 'https://self-signed.badssl.com/', 47 | }) 48 | .expect(500) 49 | ); 50 | 51 | it('invalid cert should not cause an error when ignoreHttpsErrors=true', () => 52 | request(app) 53 | .get('/api/render') 54 | .query({ 55 | url: 'https://self-signed.badssl.com/', 56 | ignoreHttpsErrors: true, 57 | }) 58 | .expect(200) 59 | ); 60 | }); 61 | 62 | describe('POST /api/render', () => { 63 | it('body must have "url" attribute', () => 64 | request(app) 65 | .post('/api/render') 66 | .send({ 67 | pdf: { scale: 2 }, 68 | }) 69 | .set('content-type', 'application/json') 70 | .expect(400) 71 | ); 72 | 73 | it('render github.com should succeed', () => 74 | request(app) 75 | .post('/api/render') 76 | .send({ url: 'https://github.com' }) 77 | .set('content-type', 'application/json') 78 | .set('Connection', 'keep-alive') 79 | .expect(200) 80 | .expect('content-type', 'application/pdf') 81 | .then((response) => { 82 | const length = Number(response.headers['content-length']); 83 | chai.expect(length).to.be.above(1024 * 40); 84 | }) 85 | ); 86 | 87 | it('html in json body should succeed', () => 88 | request(app) 89 | .post('/api/render') 90 | .send({ html: getResource('postmark-receipt.html') }) 91 | .set('Connection', 'keep-alive') 92 | .set('content-type', 'application/json') 93 | .expect(200) 94 | .expect('content-type', 'application/pdf') 95 | .then((response) => { 96 | const length = Number(response.headers['content-length']); 97 | chai.expect(length).to.be.above(1024 * 40); 98 | }) 99 | ); 100 | 101 | it('html as text body should succeed', () => 102 | request(app) 103 | .post('/api/render') 104 | .send(getResource('postmark-receipt.html')) 105 | .set('Connection', 'keep-alive') 106 | .set('content-type', 'text/html') 107 | .expect(200) 108 | .expect('content-type', 'application/pdf') 109 | .then((response) => { 110 | const length = Number(response.headers['content-length']); 111 | chai.expect(length).to.be.above(1024 * 40); 112 | }) 113 | ); 114 | 115 | it('rendering large html should succeed', () => 116 | request(app) 117 | .post('/api/render') 118 | .send(getResource('large.html')) 119 | .set('content-type', 'text/html') 120 | .expect(200) 121 | .expect('content-type', 'application/pdf') 122 | .then((response) => { 123 | const length = Number(response.headers['content-length']); 124 | chai.expect(length).to.be.above(1024 * 1024 * 1); 125 | }) 126 | ); 127 | 128 | it('rendering html with large linked images should succeed', () => 129 | request(app) 130 | .post('/api/render') 131 | .send(getResource('large-linked.html')) 132 | .set('content-type', 'text/html') 133 | .expect(200) 134 | .expect('content-type', 'application/pdf') 135 | .then((response) => { 136 | if (DEBUG) { 137 | console.log(response.headers); 138 | console.log(response.body); 139 | fs.writeFileSync('out.pdf', response.body, { encoding: null }); 140 | } 141 | 142 | const length = Number(response.headers['content-length']); 143 | chai.expect(length).to.be.above(30 * 1024 * 1); 144 | }) 145 | ); 146 | 147 | it('cookies should exist on the page', () => 148 | request(app) 149 | .post('/api/render') 150 | .send({ 151 | url: 'http://www.html-kit.com/tools/cookietester/', 152 | cookies: 153 | [{ 154 | name: 'url-to-pdf-test', 155 | value: 'test successful', 156 | domain: 'www.html-kit.com', 157 | }, { 158 | name: 'url-to-pdf-test-2', 159 | value: 'test successful 2', 160 | domain: 'www.html-kit.com', 161 | }], 162 | }) 163 | .set('Connection', 'keep-alive') 164 | .set('content-type', 'application/json') 165 | .expect(200) 166 | .expect('content-type', 'application/pdf') 167 | .then((response) => { 168 | if (DEBUG) { 169 | console.log(response.headers); 170 | console.log(response.body); 171 | fs.writeFileSync('cookies-pdf.pdf', response.body, { encoding: null }); 172 | } 173 | 174 | return getPdfTextContent(response.body); 175 | }) 176 | .then((text) => { 177 | if (DEBUG) { 178 | fs.writeFileSync('./cookies-content.txt', text); 179 | } 180 | 181 | chai.expect(text).to.have.string('Number-of-cookies-received-2'); 182 | chai.expect(text).to.have.string('Cookie-named-url-to-pdf-test'); 183 | chai.expect(text).to.have.string('Cookie-named-url-to-pdf-test-2'); 184 | }) 185 | ); 186 | 187 | it('special characters should be rendered correctly', () => 188 | request(app) 189 | .post('/api/render') 190 | .send({ html: getResource('special-chars.html') }) 191 | .set('Connection', 'keep-alive') 192 | .set('content-type', 'application/json') 193 | .expect(200) 194 | .expect('content-type', 'application/pdf') 195 | .then((response) => { 196 | if (DEBUG) { 197 | console.log(response.headers); 198 | console.log(response.body); 199 | fs.writeFileSync('special-chars.pdf', response.body, { encoding: null }); 200 | } 201 | 202 | return getPdfTextContent(response.body, { raw: true }); 203 | }) 204 | .then((text) => { 205 | if (DEBUG) { 206 | fs.writeFileSync('./special-chars-content.txt', text); 207 | } 208 | 209 | chai.expect(text).to.have.string('special characters: ä ö ü'); 210 | }) 211 | ); 212 | }); 213 | -------------------------------------------------------------------------------- /src/core/render-core.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const _ = require('lodash'); 3 | const config = require('../config'); 4 | const logger = require('../util/logger')(__filename); 5 | 6 | 7 | async function createBrowser(opts) { 8 | const browserOpts = { 9 | ignoreHTTPSErrors: opts.ignoreHttpsErrors, 10 | sloMo: config.DEBUG_MODE ? 250 : undefined, 11 | }; 12 | if (config.BROWSER_WS_ENDPOINT) { 13 | browserOpts.browserWSEndpoint = config.BROWSER_WS_ENDPOINT; 14 | return puppeteer.connect(browserOpts); 15 | } 16 | if (config.BROWSER_EXECUTABLE_PATH) { 17 | browserOpts.executablePath = config.BROWSER_EXECUTABLE_PATH; 18 | } 19 | browserOpts.headless = !config.DEBUG_MODE; 20 | browserOpts.args = ['--no-sandbox', '--disable-setuid-sandbox']; 21 | if (!opts.enableGPU || navigator.userAgent.indexOf('Win') !== -1) { 22 | browserOpts.args.push('--disable-gpu'); 23 | } 24 | return puppeteer.launch(browserOpts); 25 | } 26 | 27 | async function getFullPageHeight(page) { 28 | const height = await page.evaluate(() => { 29 | const { body, documentElement } = document; 30 | return Math.max( 31 | body.scrollHeight, 32 | body.offsetHeight, 33 | documentElement.clientHeight, 34 | documentElement.scrollHeight, 35 | documentElement.offsetHeight 36 | ); 37 | }); 38 | return height; 39 | } 40 | 41 | async function render(_opts = {}) { 42 | const opts = _.merge({ 43 | cookies: [], 44 | scrollPage: false, 45 | emulateScreenMedia: true, 46 | ignoreHttpsErrors: false, 47 | html: null, 48 | viewport: { 49 | width: 1600, 50 | height: 1200, 51 | }, 52 | goto: { 53 | waitUntil: 'networkidle0', 54 | }, 55 | output: 'pdf', 56 | pdf: { 57 | format: 'A4', 58 | printBackground: true, 59 | }, 60 | screenshot: { 61 | type: 'png', 62 | fullPage: true, 63 | }, 64 | failEarly: false, 65 | }, _opts); 66 | 67 | if ((_.get(_opts, 'pdf.width') && _.get(_opts, 'pdf.height')) || _.get(opts, 'pdf.fullPage')) { 68 | // pdf.format always overrides width and height, so we must delete it 69 | // when user explicitly wants to set width and height 70 | opts.pdf.format = undefined; 71 | } 72 | 73 | logOpts(opts); 74 | 75 | const browser = await createBrowser(opts); 76 | const page = await browser.newPage(); 77 | 78 | page.on('console', (...args) => logger.info('PAGE LOG:', ...args)); 79 | 80 | page.on('error', (err) => { 81 | logger.error(`Error event emitted: ${err}`); 82 | logger.error(err.stack); 83 | browser.close(); 84 | }); 85 | 86 | 87 | this.failedResponses = []; 88 | page.on('requestfailed', (request) => { 89 | this.failedResponses.push(request); 90 | if (request.url === opts.url) { 91 | this.mainUrlResponse = request; 92 | } 93 | }); 94 | 95 | page.on('response', (response) => { 96 | if (response.status >= 400) { 97 | this.failedResponses.push(response); 98 | } 99 | 100 | if (response.url === opts.url) { 101 | this.mainUrlResponse = response; 102 | } 103 | }); 104 | 105 | let data; 106 | try { 107 | logger.info('Set browser viewport..'); 108 | await page.setViewport(opts.viewport); 109 | if (opts.emulateScreenMedia) { 110 | logger.info('Emulate @media screen..'); 111 | await page.emulateMedia('screen'); 112 | } 113 | 114 | if (opts.cookies && opts.cookies.length > 0) { 115 | logger.info('Setting cookies..'); 116 | 117 | const client = await page.target().createCDPSession(); 118 | 119 | await client.send('Network.enable'); 120 | await client.send('Network.setCookies', { cookies: opts.cookies }); 121 | } 122 | 123 | if (_.isString(opts.html)) { 124 | logger.info('Set HTML ..'); 125 | await page.setContent(opts.html, opts.goto); 126 | } else { 127 | logger.info(`Goto url ${opts.url} ..`); 128 | await page.goto(opts.url, opts.goto); 129 | } 130 | 131 | if (_.isNumber(opts.waitFor) || _.isString(opts.waitFor)) { 132 | logger.info(`Wait for ${opts.waitFor} ..`); 133 | await page.waitFor(opts.waitFor); 134 | } 135 | 136 | if (opts.scrollPage) { 137 | logger.info('Scroll page ..'); 138 | await scrollPage(page); 139 | } 140 | 141 | if (this.failedResponses.length) { 142 | logger.warn(`Number of failed requests: ${this.failedResponses.length}`); 143 | this.failedResponses.forEach((response) => { 144 | logger.warn(`${response.status} ${response.url}`); 145 | }); 146 | 147 | if (opts.failEarly === 'all') { 148 | const err = new Error(`${this.failedResponses.length} requests have failed. See server log for more details.`); 149 | err.status = 412; 150 | throw err; 151 | } 152 | } 153 | if (opts.failEarly === 'page' && this.mainUrlResponse.status !== 200) { 154 | const msg = `Request for ${opts.url} did not directly succeed and returned status ${this.mainUrlResponse.status}`; 155 | const err = new Error(msg); 156 | err.status = 412; 157 | throw err; 158 | } 159 | 160 | logger.info('Rendering ..'); 161 | if (config.DEBUG_MODE) { 162 | const msg = `\n\n---------------------------------\n 163 | Chrome does not support rendering in "headed" mode. 164 | See this issue: https://github.com/GoogleChrome/puppeteer/issues/576 165 | \n---------------------------------\n\n 166 | `; 167 | throw new Error(msg); 168 | } 169 | 170 | if (opts.output === 'pdf') { 171 | if (opts.pdf.fullPage) { 172 | const height = await getFullPageHeight(page); 173 | opts.pdf.height = height; 174 | } 175 | data = await page.pdf(opts.pdf); 176 | } else if (opts.output === 'html') { 177 | data = await page.evaluate(() => document.documentElement.innerHTML); 178 | } else { 179 | // This is done because puppeteer throws an error if fullPage and clip is used at the same 180 | // time even though clip is just empty object {} 181 | const screenshotOpts = _.cloneDeep(_.omit(opts.screenshot, ['clip'])); 182 | const clipContainsSomething = _.some(opts.screenshot.clip, val => !_.isUndefined(val)); 183 | if (clipContainsSomething) { 184 | screenshotOpts.clip = opts.screenshot.clip; 185 | } 186 | if (_.isNil(opts.screenshot.selector)) { 187 | data = await page.screenshot(screenshotOpts); 188 | } else { 189 | const selElement = await page.$(opts.screenshot.selector); 190 | const selectorScreenOpts = _.cloneDeep(_.omit(screenshotOpts, ['selector', 'fullPage'])); 191 | if (!_.isNull(selElement)) { 192 | data = await selElement.screenshot(selectorScreenOpts); 193 | } 194 | } 195 | } 196 | } catch (err) { 197 | logger.error(`Error when rendering page: ${err}`); 198 | logger.error(err.stack); 199 | throw err; 200 | } finally { 201 | logger.info('Closing browser..'); 202 | if (!config.DEBUG_MODE) { 203 | await browser.close(); 204 | } 205 | } 206 | 207 | return data; 208 | } 209 | 210 | async function scrollPage(page) { 211 | // Scroll to page end to trigger lazy loading elements 212 | await page.evaluate(() => { 213 | const scrollInterval = 100; 214 | const scrollStep = Math.floor(window.innerHeight / 2); 215 | const bottomThreshold = 400; 216 | 217 | function bottomPos() { 218 | return window.pageYOffset + window.innerHeight; 219 | } 220 | 221 | return new Promise((resolve, reject) => { 222 | function scrollDown() { 223 | window.scrollBy(0, scrollStep); 224 | 225 | if (document.body.scrollHeight - bottomPos() < bottomThreshold) { 226 | window.scrollTo(0, 0); 227 | setTimeout(resolve, 500); 228 | return; 229 | } 230 | 231 | setTimeout(scrollDown, scrollInterval); 232 | } 233 | 234 | setTimeout(reject, 30000); 235 | scrollDown(); 236 | }); 237 | }); 238 | } 239 | 240 | function logOpts(opts) { 241 | const supressedOpts = _.cloneDeep(opts); 242 | if (opts.html) { 243 | supressedOpts.html = '...'; 244 | } 245 | 246 | logger.info(`Rendering with opts: ${JSON.stringify(supressedOpts, null, 2)}`); 247 | } 248 | 249 | module.exports = { 250 | render, 251 | }; 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/alvarcarto/url-to-pdf-api) 2 | 3 | [![Build Status](https://travis-ci.org/alvarcarto/url-to-pdf-api.svg?branch=master)](https://travis-ci.org/alvarcarto/url-to-pdf-api) 4 | 5 | # URL to PDF Microservice 6 | 7 | > Web page PDF rendering done right. Microservice for rendering receipts, invoices, or any content. Packaged to an easy API. 8 | 9 | ![Logo](docs/logo.png) 10 | 11 | **⚠️ WARNING ⚠️** *Don't serve this API publicly to the internet unless you are aware of the 12 | risks. It allows API users to run any JavaScript code inside a Chrome session on the server. 13 | It's fairly easy to expose the contents of files on the server. You have been warned!. See https://github.com/alvarcarto/url-to-pdf-api/issues/12 for background.* 14 | 15 | **⭐️ Features:** 16 | 17 | * Converts any URL or HTML content to a PDF file or an image (PNG/JPEG) 18 | * Rendered with Headless Chrome, using [Puppeteer](https://github.com/GoogleChrome/puppeteer). The PDFs should match to the ones generated with a desktop Chrome. 19 | * Sensible defaults but everything is configurable. 20 | * Single-page app (SPA) support. Waits until all network requests are finished before rendering. 21 | * Easy deployment to Heroku. We love Lambda but...Deploy to Heroku button. 22 | * Renders lazy loaded elements. *(scrollPage option)* 23 | * Supports optional `x-api-key` authentication. *(`API_TOKENS` env var)* 24 | 25 | Usage is as simple as https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com. There's also a `POST /api/render` if you prefer to send options in the body. 26 | 27 | **🔍 Why?** 28 | 29 | This microservice is useful when you need to automatically produce PDF files 30 | for whatever reason. The files could be receipts, weekly reports, invoices, 31 | or any content. 32 | 33 | PDFs can be generated in many ways, but one of them is to convert HTML+CSS 34 | content to a PDF. This API does just that. 35 | 36 | **🚀 Shortcuts:** 37 | 38 | * [Examples](#examples) 39 | * [API](#api) 40 | * [I want to run this myself](#development) 41 | 42 | ## How it works 43 | 44 | ![](docs/heroku.png) 45 | 46 | Local setup is identical except Express API is running on your machine 47 | and requests are direct connections to it. 48 | 49 | ### Good to know 50 | 51 | * **By default, page's `@media print` CSS rules are ignored**. We set Chrome to emulate `@media screen` to make the default PDFs look more like actual sites. To get results closer to desktop Chrome, add `&emulateScreenMedia=false` query parameter. See more at [Puppeteer API docs](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions). 52 | 53 | * Chrome is launched with `--no-sandbox --disable-setuid-sandbox` flags to fix usage in Heroku. See [this issue](https://github.com/GoogleChrome/puppeteer/issues/290). 54 | 55 | * Heavy pages may cause Chrome to crash if the server doesn't have enough RAM. 56 | 57 | * Docker image for this can be found here: https://github.com/restorecommerce/pdf-rendering-srv 58 | 59 | 60 | ## Examples 61 | 62 | **⚠️ Restrictions ⚠️:** 63 | 64 | * For security reasons the urls have been restricted and HTML rendering is disabled. For full demo, run this app locally or deploy to Heroku. 65 | * The demo Heroku app runs on a free dyno which sleep after idle. A request to sleeping dyno may take even 30 seconds. 66 | 67 | 68 | 69 | **The most minimal example, render google.com** 70 | 71 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com 72 | 73 | **The most minimal example, render google.com as PNG image** 74 | 75 | https://url-to-pdf-api.herokuapp.com/api/render?output=screenshot&url=http://google.com 76 | 77 | 78 | **Use the default @media print instead of @media screen.** 79 | 80 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&emulateScreenMedia=false 81 | 82 | **Use scrollPage=true which tries to reveal all lazy loaded elements. Not perfect but better than without.** 83 | 84 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://www.andreaverlicchi.eu/lazyload/demos/lazily_load_lazyLoad.html&scrollPage=true 85 | 86 | **Render only the first page.** 87 | 88 | https://url-to-pdf-api.herokuapp.com/api/render?url=https://en.wikipedia.org/wiki/Portable_Document_Format&pdf.pageRanges=1 89 | 90 | **Render A5-sized PDF in landscape.** 91 | 92 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&pdf.format=A5&pdf.landscape=true 93 | 94 | **Add 2cm margins to the PDF.** 95 | 96 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&pdf.margin.top=2cm&pdf.margin.right=2cm&pdf.margin.bottom=2cm&pdf.margin.left=2cm 97 | 98 | **Wait for extra 1000ms before render.** 99 | 100 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&waitFor=1000 101 | 102 | 103 | **Download the PDF with a given attachment name** 104 | 105 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&attachmentName=google.pdf 106 | 107 | **Wait for an element matching the selector `input` appears.** 108 | 109 | https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&waitFor=input 110 | 111 | **Render HTML sent in JSON body** 112 | 113 | *NOTE: Demo app has disabled html rendering for security reasons.* 114 | 115 | ```bash 116 | curl -o html.pdf -XPOST -d'{"html": "test"}' -H"content-type: application/json" http://localhost:9000/api/render 117 | ``` 118 | 119 | **Render HTML sent as text body** 120 | 121 | *NOTE: Demo app has disabled html rendering for security reasons.* 122 | 123 | ```bash 124 | curl -o html.pdf -XPOST -d@test/resources/large.html -H"content-type: text/html" http://localhost:9000/api/render 125 | ``` 126 | 127 | ## API 128 | 129 | To understand the API options, it's useful to know how [Puppeteer](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md) 130 | is internally used by this API. The [render code](https://github.com/alvarcarto/url-to-pdf-api/blob/master/src/core/render-core.js) 131 | is quite simple, check it out. Render flow: 132 | 133 | 1. **`page.setViewport(options)`** where options matches `viewport.*`. 134 | 2. *Possibly* **`page.emulateMedia('screen')`** if `emulateScreenMedia=true` is set. 135 | 3. Render url **or** html. 136 | 137 | If `url` is defined, **`page.goto(url, options)`** is called and options match `goto.*`. 138 | Otherwise **`page.setContent(html, options)`** is called where html is taken from request body, and options match `goto.*`. 139 | 140 | 4. *Possibly* **`page.waitFor(numOrStr)`** if e.g. `waitFor=1000` is set. 141 | 5. *Possibly* **Scroll the whole page** to the end before rendering if e.g. `scrollPage=true` is set. 142 | 143 | Useful if you want to render a page which lazy loads elements. 144 | 145 | 6. Render the output 146 | 147 | * If output is `pdf` rendering is done with **`page.pdf(options)`** where options matches `pdf.*`. 148 | * Else if output is `screenshot` rendering is done with **`page.screenshot(options)`** where options matches `screenshot.*`. 149 | 150 | 151 | ### GET /api/render 152 | 153 | All options are passed as query parameters. 154 | Parameter names match [Puppeteer options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md). 155 | 156 | These options are exactly the same as its `POST` counterpart, but options are 157 | expressed with the dot notation. E.g. `?pdf.scale=2` instead of `{ pdf: { scale: 2 }}`. 158 | 159 | The only required parameter is `url`. 160 | 161 | Parameter | Type | Default | Description 162 | ----------|------|---------|------------ 163 | url | string | - | URL to render as PDF. (required) 164 | output | string | pdf | Specify the output format. Possible values: `pdf` , `screenshot` or `html`. 165 | emulateScreenMedia | boolean | `true` | Emulates `@media screen` when rendering the PDF. 166 | enableGPU | boolean | `false` | When set, enables chrome GPU. For windows user, this will always return false. See https://developers.google.com/web/updates/2017/04/headless-chrome 167 | ignoreHttpsErrors | boolean | `false` | Ignores possible HTTPS errors when navigating to a page. 168 | scrollPage | boolean | `false` | Scroll page down before rendering to trigger lazy loading elements. 169 | waitFor | number or string | - | Number in ms to wait before render or selector element to wait before render. 170 | attachmentName | string | - | When set, the `content-disposition` headers are set and browser will download the PDF instead of showing inline. The given string will be used as the name for the file. 171 | viewport.width | number | `1600` | Viewport width. 172 | viewport.height | number | `1200` | Viewport height. 173 | viewport.deviceScaleFactor | number | `1` | Device scale factor (could be thought of as dpr). 174 | viewport.isMobile | boolean | `false` | Whether the meta viewport tag is taken into account. 175 | viewport.hasTouch | boolean | `false` | Specifies if viewport supports touch events. 176 | viewport.isLandscape | boolean | `false` | Specifies if viewport is in landscape mode. 177 | cookies[0][name] | string | - | Cookie name (required) 178 | cookies[0][value] | string | - | Cookie value (required) 179 | cookies[0][url] | string | - | Cookie url 180 | cookies[0][domain] | string | - | Cookie domain 181 | cookies[0][path] | string | - | Cookie path 182 | cookies[0][expires] | number | - | Cookie expiry in unix time 183 | cookies[0][httpOnly] | boolean | - | Cookie httpOnly 184 | cookies[0][secure] | boolean | - | Cookie secure 185 | cookies[0][sameSite] | string | - | `Strict` or `Lax` 186 | goto.timeout | number | `30000` | Maximum navigation time in milliseconds, defaults to 30 seconds, pass 0 to disable timeout. 187 | goto.waitUntil | string | `networkidle0` | When to consider navigation succeeded. Options: `load`, `domcontentloaded`, `networkidle0`, `networkidle2`. `load` - consider navigation to be finished when the load event is fired. `domcontentloaded` - consider navigation to be finished when the `DOMContentLoaded` event is fired. `networkidle0` - consider navigation to be finished when there are no more than 0 network connections for at least `500` ms. `networkidle2` - consider navigation to be finished when there are no more than 2 network connections for at least `500` ms. 188 | pdf.scale | number | `1` | Scale of the webpage rendering. 189 | pdf.printBackground | boolean | `false`| Print background graphics. 190 | pdf.displayHeaderFooter | boolean | `false` | Display header and footer. 191 | pdf.headerTemplate | string | - | HTML template to use as the header of each page in the PDF. **Currently Puppeteer basically only supports a single line of text and you must use pdf.margins+CSS to make the header appear!** See https://github.com/alvarcarto/url-to-pdf-api/issues/77. 192 | pdf.footerTemplate | string | - | HTML template to use as the footer of each page in the PDF. **Currently Puppeteer basically only supports a single line of text and you must use pdf.margins+CSS to make the footer appear!** See https://github.com/alvarcarto/url-to-pdf-api/issues/77. 193 | pdf.landscape | boolean | `false` | Paper orientation. 194 | pdf.pageRanges | string | - | Paper ranges to print, e.g., '1-5, 8, 11-13'. Defaults to the empty string, which means print all pages. 195 | pdf.format | string | `A4` | Paper format. If set, takes priority over width or height options. 196 | pdf.width | string | - | Paper width, accepts values labeled with units. 197 | pdf.height | string | - | Paper height, accepts values labeled with units. 198 | pdf.fullPage | boolean | - | Create PDF in a single page 199 | pdf.margin.top | string | - | Top margin, accepts values labeled with units. 200 | pdf.margin.right | string | - | Right margin, accepts values labeled with units. 201 | pdf.margin.bottom | string | - | Bottom margin, accepts values labeled with units. 202 | pdf.margin.left | string | - | Left margin, accepts values labeled with units. 203 | screenshot.fullPage | boolean | `true` | When true, takes a screenshot of the full scrollable page. 204 | screenshot.type | string | `png` | Screenshot image type. Possible values: `png`, `jpeg` 205 | screenshot.quality | number | - | The quality of the JPEG image, between 0-100. Only applies when `screenshot.type` is `jpeg`. 206 | screenshot.omitBackground | boolean | `false` | Hides default white background and allows capturing screenshots with transparency. 207 | screenshot.clip.x | number | - | Specifies x-coordinate of top-left corner of clipping region of the page. 208 | screenshot.clip.y | number | - | Specifies y-coordinate of top-left corner of clipping region of the page. 209 | screenshot.clip.width | number | - | Specifies width of clipping region of the page. 210 | screenshot.clip.height | number | - | Specifies height of clipping region of the page. 211 | screenshot.selector | string | - | Specifies css selector to clip the screenshot to. 212 | 213 | 214 | **Example:** 215 | 216 | ```bash 217 | curl -o google.pdf https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com 218 | ``` 219 | 220 | 221 | ### POST /api/render - (JSON) 222 | 223 | All options are passed in a JSON body object. 224 | Parameter names match [Puppeteer options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md). 225 | 226 | These options are exactly the same as its `GET` counterpart. 227 | 228 | **Body** 229 | 230 | The only required parameter is `url`. 231 | 232 | ```js 233 | { 234 | // Url to render. Either url or html is required 235 | url: "https://google.com", 236 | 237 | // Either "pdf" or "screenshot" 238 | output: "pdf", 239 | 240 | // HTML content to render. Either url or html is required 241 | html: "Your content", 242 | 243 | // If we should emulate @media screen instead of print 244 | emulateScreenMedia: true, 245 | 246 | // If we should ignore HTTPS errors 247 | ignoreHttpsErrors: false, 248 | 249 | // If true, page is scrolled to the end before rendering 250 | // Note: this makes rendering a bit slower 251 | scrollPage: false, 252 | 253 | // Passed to Puppeteer page.waitFor() 254 | waitFor: null, 255 | 256 | // Passsed to Puppeteer page.setCookies() 257 | cookies: [{ ... }] 258 | 259 | // Passed to Puppeteer page.setViewport() 260 | viewport: { ... }, 261 | 262 | // Passed to Puppeteer page.goto() as the second argument after url 263 | goto: { ... }, 264 | 265 | // Passed to Puppeteer page.pdf() 266 | pdf: { ... }, 267 | 268 | // Passed to Puppeteer page.screenshot() 269 | screenshot: { ... }, 270 | } 271 | ``` 272 | 273 | **Example:** 274 | 275 | ```bash 276 | curl -o google.pdf -XPOST -d'{"url": "http://google.com"}' -H"content-type: application/json" http://localhost:9000/api/render 277 | ``` 278 | 279 | ```bash 280 | curl -o html.pdf -XPOST -d'{"html": "test"}' -H"content-type: application/json" http://localhost:9000/api/render 281 | ``` 282 | 283 | ### POST /api/render - (HTML) 284 | 285 | HTML to render is sent in body. All options are passed in query parameters. 286 | Supports exactly the same query parameters as `GET /api/render`, except `url` 287 | paremeter. 288 | 289 | *Remember that relative links do not work.* 290 | 291 | **Example:** 292 | 293 | ```bash 294 | curl -o receipt.html https://rawgit.com/wildbit/postmark-templates/master/templates_inlined/receipt.html 295 | curl -o html.pdf -XPOST -d@receipt.html -H"content-type: text/html" http://localhost:9000/api/render?pdf.scale=1 296 | ``` 297 | 298 | ## Development 299 | 300 | To get this thing running, you have two options: run it in Heroku, or locally. 301 | 302 | The code requires Node 8+ (async, await). 303 | 304 | #### 1. Heroku deployment 305 | 306 | Scroll this readme up to the Deploy to Heroku -button. Click it and follow 307 | instructions. 308 | 309 | **WARNING:** *Heroku dynos have a very low amount of RAM. Rendering heavy pages 310 | may cause Chrome instance to crash inside Heroku dyno. 512MB should be 311 | enough for most real-life use cases such as receipts. Some news sites may need 312 | even 2GB of RAM.* 313 | 314 | 315 | #### 2. Local development 316 | 317 | First, clone the repository and cd into it. 318 | 319 | * `cp .env.sample .env` 320 | * Fill in the blanks in `.env` 321 | 322 | * `npm install` 323 | * `npm start` Start express server locally 324 | * Server runs at http://localhost:9000 or what `$PORT` env defines 325 | 326 | 327 | ### Techstack 328 | 329 | * Node 8+ (async, await), written in ES7 330 | * [Express.js](https://expressjs.com/) app with a nice internal architecture, based on [these conventions](https://github.com/kimmobrunfeldt/express-example). 331 | * Hapi-style Joi validation with [express-validation](https://github.com/andrewkeig/express-validation) 332 | * Heroku + [Puppeteer buildpack](https://github.com/jontewks/puppeteer-heroku-buildpack) 333 | * [Puppeteer](https://github.com/GoogleChrome/puppeteer) to control Chrome 334 | -------------------------------------------------------------------------------- /test/resources/postmark-receipt.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Receipt for [Product Name] 7 | 8 | 9 | 10 | 28 | 29 | 30 | 31 | 165 | 166 |
32 | 33 | 34 | 39 | 40 | 41 | 42 | 146 | 147 | 148 | 162 | 163 | 164 |
167 | 168 | --------------------------------------------------------------------------------