Skip to content
This repository has been archived by the owner on Apr 7, 2020. It is now read-only.

Commit

Permalink
Refactor, image support, improve error handling
Browse files Browse the repository at this point in the history
  • Loading branch information
msokk committed Jan 19, 2016
1 parent eb98caa commit 657d2dc
Show file tree
Hide file tree
Showing 16 changed files with 733 additions and 141 deletions.
4 changes: 4 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"env": {
"node": true,
"mocha": true
},
"parser": "babel-eslint",
"extends": "airbnb/base"
}
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
FROM node:slim

ENV RENDERER_ACCESS_KEY=changeme CONCURRENCY=1
MAINTAINER Mihkel Sokk <[email protected]>

ENV RENDERER_ACCESS_KEY=changeme CONCURRENCY=1 WINDOW_WIDTH=1024 WINDOW_HEIGHT=768

# Add subpixel hinting
COPY .fonts.conf /root/.fonts.conf
Expand All @@ -16,4 +18,5 @@ RUN npm install --production && \
apt-get clean

EXPOSE 3000
CMD xvfb-run --server-args="-screen 0 800x600x24" npm start

CMD xvfb-run --server-args="-screen 0 $WINDOW_WIDTHx$WINDOW_HEIGHTx24" npm start
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
[![Docker Hub](https://img.shields.io/badge/docker-ready-blue.svg)](https://registry.hub.docker.com/u/msokk/electron-render-service/)
[![](https://badge.imagelayers.io/msokk/electron-render-service:latest.svg)](https://imagelayers.io/?images=msokk/electron-render-service:latest 'Get your own badge on imagelayers.io')

Simple PDF render service, accepts webpage URL and returns it as a PDF.
Simple PDF/PNG/JPEG render service, accepts webpage URL and returns the resource.


## Docker usage
Expand All @@ -28,7 +28,7 @@ git clone https://github.com/msokk/electron-render-service.git
npm install

# Run in virtual framebuffer
RENDERER_ACCESS_KEY=secret xvfb-run --server-args="-screen 0 800x600x24" npm start
RENDERER_ACCESS_KEY=secret xvfb-run --server-args="-screen 0 1024x768x24" npm start

wget -o out.pdf http://localhost:3000/pdf?url=https://github.com/msokk/electron-render-service&access_key=secret
```
Expand All @@ -47,6 +47,20 @@ wget -o out.pdf http://localhost:3000/pdf?url=https://github.com/msokk/electron-
* `printBackground` - Whether to print CSS backgrounds. (default: `true`)
* `landscape` - `true` for landscape, `false` for portrait. (default: `false`)

#### `GET /png|jpeg` - Render PNG/JPEG

*Query params:*

* `access_key` - Authentication key.
* `url` - Full URL to fetch.
* `quality` - JPEG quality. (default: `80`)
* `browserWidth` - Browser window width (default: `rect.width || env.WINDOW_WIDTH`, max: `3000`)
* `browserHeight` - Browser window height (default: `rect.height || env.WINDOW_HEIGHT`, max: `3000`)
* Clipping rectangle (optional, but all 4 integers need to be set)
* `x`
* `y`
* `width`
* `height`

#### `GET /stats` - Display render pool stats

Expand All @@ -62,5 +76,8 @@ wget -o out.pdf http://localhost:3000/pdf?url=https://github.com/msokk/electron-

##### *Optional*
* `CONCURRENCY` - Number of browser windows to run in parallel (default: `1`)
* `TIMEOUT` - Number of seconds before request timeouts (default: `30`)
* `WINDOW_WIDTH` - Default window width (default: `1024`)
* `WINDOW_HEIGHT` - Default window height (default: `768`)
* `INTERFACE` - Network interface for Express to listen on (default: `0.0.0.0`)
* `PORT` - (default: `3000`)
7 changes: 6 additions & 1 deletion lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ if (Object.keys(validKeys).length === 0) throw new Error('No access key defined!
function authMiddleware(req, res, next) {
const sentKey = req.query.access_key;
const key = Object.keys(validKeys).filter(k => validKeys[k] === sentKey);
if (!sentKey || key.length === 0) return res.sendStatus(403);
if (!sentKey || key.length === 0) {
return res.status(403).send({
error: { code: 'UNAUTHORIZED', message: 'Invalid or missing access key.' }
});
}

/* eslint-disable no-param-reassign */
req.keyLabel = key[0];
next();
}
158 changes: 158 additions & 0 deletions lib/renderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use strict';

var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }();

Object.defineProperty(exports, "__esModule", {
value: true
});
exports.renderWorker = renderWorker;
exports.createWindow = createWindow;

var _package = require('../package.json');

var _package2 = _interopRequireDefault(_package);

var _electron = require('electron');

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

const TIMEOUT = process.env.TIMEOUT || 30;
const WINDOW_WIDTH = process.env.WINDOW_WIDTH || 1024;
const WINDOW_HEIGHT = process.env.WINDOW_HEIGHT || 768;
const LIMIT = 3000; // Constrain screenshots to 3000x3000px

const DEFAULT_HEADERS = 'Cache-Control: no-cache, no-store, must-revalidate';

/**
* Render PDF
*/
function renderPDF(_ref, done) {
let options = _ref.options;

this.webContents.printToPDF(options, done);
}

/**
* Render image
*/
function renderImage(_ref2, done) {
let type = _ref2.type;
let options = _ref2.options;

const handleCapture = image => {
done(null, type === 'png' ? image.toPng() : image.toJpeg(parseInt(options.quality, 10) || 80));
};

// Sanitize rect
const validKeys = ['x', 'y', 'width', 'height'];
const rect = {};
Object.keys(options).map(k => [k, options[k]]).filter(_ref3 => {
var _ref4 = _slicedToArray(_ref3, 2);

let k = _ref4[0];
let v = _ref4[1];
return validKeys.includes(k) && !isNaN(parseInt(v, 10));
}).forEach(_ref5 => {
var _ref6 = _slicedToArray(_ref5, 2);

let k = _ref6[0];
let v = _ref6[1];
return rect[k] = parseInt(v, 10);
});

// Use explicit browser size or rect size, capped by LIMIT, default to ENV variable
const browserSize = {
width: Math.min(parseInt(options.browserWidth, 10) || rect.width, LIMIT) || WINDOW_WIDTH,
height: Math.min(parseInt(options.browserHeight, 10) || rect.height, LIMIT) || WINDOW_HEIGHT
};

if (Object.keys(rect).length === 4) {
// Avoid stretching by adding rect coordinates to size
this.setSize(browserSize.width + rect.x, browserSize.height + rect.y);
this.capturePage(rect, handleCapture);
} else {
this.setSize(browserSize.width, browserSize.height);
this.capturePage(handleCapture);
}
}

/**
* Handle loading failure errors
*/
function handleLoadingError(done, e, code, desc) {
switch (code) {
case -105:
done({ statusCode: 500, code: 'NAME_NOT_RESOLVED',
message: `The host name could not be resolved.` });
break;
case -300:
done({ statusCode: 500, code: 'INVALID_URL', message: 'The URL is invalid.' });
break;
case -501:
done({ statusCode: 500, code: 'INSECURE_RESPONSE',
message: 'The server\'s response was insecure (e.g. there was a cert error).' });
break;
case -3:
done({ statusCode: 500, code: 'ABORTED', message: 'User aborted loading.' });
break;
default:
done({ statusCode: 500, code: 'GENERIC_ERROR', message: `${ code } - ${ desc }` });
}
}

/**
* Render job with error handling
*/
function renderWorker(window, task, done) {
const webContents = window.webContents;

// Prevent loading of malicious chrome:// URLS

if (task.url.startsWith('chrome://')) {
return done({ statusCode: 500, code: 'INVALID_URL', message: 'The URL is invalid.' });
}

// Loading failures
webContents.once('did-fail-load', function () {
for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}

return handleLoadingError(done, ...args);
});

// Renderer process has crashed
webContents.once('crashed', () => {
done({ statusCode: 500, code: 'RENDERER_CRASH', message: `Render process crashed.` });
});

webContents.once('did-finish-load', () => {
(task.type === 'pdf' ? renderPDF : renderImage).call(window, task, done);
});

webContents.once('timeout', () => {
done({ statusCode: 524, code: 'RENDERER_TIMEOUT', message: `Renderer timed out.` });
});

// Timeout render job
webContents.timeoutTimer = setTimeout(() => webContents.emit('timeout'), TIMEOUT * 1000);

webContents.loadURL(task.url, { extraHeaders: DEFAULT_HEADERS });
}

/**
* Create BrowserWindow
*/
function createWindow() {
const window = new _electron.BrowserWindow({
width: WINDOW_WIDTH, height: WINDOW_HEIGHT,
frame: false, show: false
});

// Set user agent
const webContents = window.webContents;

webContents.setUserAgent(`${ webContents.getUserAgent() } ${ _package2.default.name }/${ _package2.default.version }`);

return window;
}
90 changes: 61 additions & 29 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,89 @@ var _morgan = require('morgan');

var _morgan2 = _interopRequireDefault(_morgan);

var _responseTime = require('response-time');

var _responseTime2 = _interopRequireDefault(_responseTime);

var _electron = require('electron');

var _render_pool = require('./render_pool');
var _window_pool = require('./window_pool');

var _render_pool2 = _interopRequireDefault(_render_pool);
var _window_pool2 = _interopRequireDefault(_window_pool);

var _auth = require('./auth');

var _auth2 = _interopRequireDefault(_auth);

var _util = require('./util');

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

const INTERFACE = process.env.INTERFACE || '0.0.0.0';
const PORT = process.env.PORT || 3000;
const app = (0, _express2.default)();

function printPDFUsage() {
let url = arguments.length <= 0 || arguments[0] === undefined ? '' : arguments[0];

return `Usage: GET ${ url }/pdf?url=http://google.com&access_key=<token>`;
}
app.use((0, _responseTime2.default)());

// Log with token
_morgan2.default.token('key-label', req => req.keyLabel);
app.use((0, _morgan2.default)(`[:date[iso]] :key-label@:remote-addr - :method :status
:url :res[content-length] ":user-agent" :response-time ms`.replace('\n', '')));
:url :res[content-length] ":user-agent" :response-time ms`.replace('\n', '')));

app.disable('x-powered-by');
app.enable('trust proxy');

/**
* GET /pdf - Render PDF
*
* Query: https://github.com/atom/electron/blob/master/docs/api/web-contents.md#webcontentsprinttopdfoptions-callback
* Query params: https://github.com/atom/electron/blob/master/docs/api/web-contents.md#webcontentsprinttopdfoptions-callback
*/
app.get('/pdf', _auth2.default, (req, res) => {
var _req$query = req.query;
var _req$query$url = _req$query.url;
const url = _req$query$url === undefined ? 'data:text/plain;charset=utf-8,' + printPDFUsage() : _req$query$url;
const url = _req$query$url === undefined ? 'data:text/plain;charset=utf-8,' + (0, _util.printUsage)('pdf') : _req$query$url;
var _req$query$marginsTyp = _req$query.marginsType;
const marginsType = _req$query$marginsTyp === undefined ? 0 : _req$query$marginsTyp;
var _req$query$pageSize = _req$query.pageSize;
const pageSize = _req$query$pageSize === undefined ? 'A4' : _req$query$pageSize;
var _req$query$printBackg = _req$query.printBackground;
const printBackground = _req$query$printBackg === undefined ? true : _req$query$printBackg;
const printBackground = _req$query$printBackg === undefined ? 'true' : _req$query$printBackg;
var _req$query$landscape = _req$query.landscape;
const landscape = _req$query$landscape === undefined ? false : _req$query$landscape;
const landscape = _req$query$landscape === undefined ? 'false' : _req$query$landscape;

req.app.pool.enqueue({ url, type: 'pdf',
options: {
pageSize,
marginsType: parseInt(marginsType, 10),
landscape: landscape === 'true',
printBackground: printBackground === 'true'
}
}, (err, buffer) => {
if ((0, _util.handleErrors)(err, req, res)) return;

(0, _util.setContentDisposition)(res, 'pdf');
res.type('pdf').send(buffer);
});
});

_render_pool2.default.enqueue({ url, res, type: 'pdf',
options: { marginsType, pageSize, landscape, printBackground } });
/**
* GET /png|jpeg - Render png or jpeg
*
* Query params:
* x = 0, y = 0, width, height
* quality = 80 - JPEG quality
*/
app.get(/^\/(png|jpeg)/, _auth2.default, (req, res) => {
const type = req.params[0];
var _req$query$url2 = req.query.url;
const url = _req$query$url2 === undefined ? 'data:text/plain;charset=utf-8,' + (0, _util.printUsage)(type) : _req$query$url2;

req.app.pool.enqueue({ url, type, options: req.query }, (err, buffer) => {
if ((0, _util.handleErrors)(err, req, res)) return;

(0, _util.setContentDisposition)(res, type);
res.type(type).send(buffer);
});
});

/**
Expand All @@ -63,29 +99,25 @@ app.get('/pdf', _auth2.default, (req, res) => {
app.get('/stats', _auth2.default, (req, res) => {
if (req.keyLabel !== 'global') return res.sendStatus(403);

res.send(_render_pool2.default.stats());
res.send(req.app.pool.stats());
});

/**
* GET / - Print usage
*/
app.get('/', (req, res) => res.status(404).send(printPDFUsage()));
app.get('/', (req, res) => {
res.send((0, _util.printUsage)());
});

// Electron finished booting
_electron.app.on('ready', () => {
_render_pool2.default.init();

const listener = app.listen(PORT, INTERFACE, () => {
var _listener$address = listener.address();

const port = _listener$address.port;
const address = _listener$address.address;

const url = `http://${ address }:${ port }`;
process.stdout.write(`Renderer listening on ${ url }\n\n`);
process.stdout.write(printPDFUsage(url) + '\n');
});
app.pool = new _window_pool2.default();
const listener = app.listen(PORT, INTERFACE, () => (0, _util.printBootMessage)(listener));
});

// Stop Electron on SIG*
process.on('exit', code => _electron.app.exit(code));
process.on('exit', code => _electron.app.exit(code));

process.on('uncaughtException', err => {
throw err;
});
Loading

0 comments on commit 657d2dc

Please sign in to comment.