/*global*/
/**
* Module for pdf export
* @module api/utils/pdf
*/
var puppeteer;
try {
puppeteer = require('puppeteer');
}
catch (err) {
if (process.env.COUNTLY_CONTAINER !== 'frontend') {
console.warn(
`Puppeteer not installed. Please install puppeteer if
you would like to use api/utils/render.js. \nGracefully skipping
any functionality associated with Puppeteer...`, err.stack
);
}
}
var log = require('./log.js')('core:pdf');
var webConfig = {};
try {
//web.host and web.port live in the frontend config, which is the origin the
//report templates point the renderer at. Optional: pdf.js is core and may be
//loaded where that file is absent, and callers pass their origins anyway.
webConfig = require('./../../frontend/express/config.js').web || {};
}
catch (error) {
log.d('no frontend config available for render origins', error.message);
}
/**
* The origins a rendered document is allowed to fetch from.
*
* Rendering runs on the server, so anything the document requests is requested
* from the server's network position: loopback, the private network, a cloud
* metadata service. The report templates only ever point at Countly itself, at
* `host` or at the localhost form the caller builds for exactly that reason, so
* an allow-list of those costs nothing and closes the rest.
*
* Origins are compared as scheme, host and port together. Host alone would be
* too loose, since Countly is frequently on loopback itself and every other
* service on that interface would come along with it.
* @param {array} extra - origins the caller knows it needs, e.g. its public host
* @returns {Set} the set of allowed origins
**/
function renderOrigins(extra) {
var origins = new Set();
var protocol = process.env.COUNTLY_CONFIG_PROTOCOL || "http";
if (webConfig.host) {
origins.add(protocol + "://" + webConfig.host + (webConfig.port ? ":" + webConfig.port : ""));
}
(extra || []).forEach(function(value) {
if (!value) {
return;
}
try {
origins.add(new URL(value).origin);
}
catch (error) {
log.d('ignoring an unparseable render origin', value);
}
});
return origins;
}
exports.renderOrigins = renderOrigins;
/**
* Whether a rendered document may fetch the given url.
*
* Split out from the interception handler so the decision can be exercised
* without launching Chromium: the handler does nothing but call this and
* continue or abort.
*
* data:, blob: and about: carry no network request anywhere, so they pass
* through - the document itself is opened as a data: url. Everything else is
* compared by full origin, and anything that will not parse is refused.
* @param {string} url - the url the document asked for
* @param {Set} origins - the allowed origins, from renderOrigins()
* @returns {boolean} true if the request may proceed
**/
function isAllowedRenderUrl(url, origins) {
if (/^(data|blob|about):/.test(url)) {
return true;
}
let origin;
try {
origin = new URL(url).origin;
}
catch (error) {
return false;
}
//a url with no meaningful origin parses to the string "null"; it is not a
//Countly origin, so it is refused like anything else off the list
return !!origin && origins.has(origin);
}
exports.isAllowedRenderUrl = isAllowedRenderUrl;
/**
* Function to generate pdf from html
* @param {string} html - html text to be converted to html
* @param {function} callback - callback function after pdf is generated
* @param {object} options - pdf options, default null
* @param {object} puppeteerArgs - pupeteer arguments, default null
* @param {boolean} remoteContent - if it is set base64 string of html content buffer is set as pdf content, default true
* @param {array} allowedOrigins - origins the document may fetch from, in
* addition to the configured Countly origin. Anything else is refused.
*/
exports.renderPDF = async function(html, callback, options = null, puppeteerArgs = null, remoteContent = true, allowedOrigins = null) {
if (typeof html !== 'string') {
throw new Error(
'Invalid Argument: HTML expected as type of string and received a value of a different type. Check your request body and request headers.'
);
}
let browser;
try {
log.d('Starting pdf generation', 'puppeteerArgs: ', puppeteerArgs);
if (puppeteerArgs) {
browser = await puppeteer.launch(puppeteerArgs);
}
else {
browser = await puppeteer.launch();
}
const updatedTimeout = 240000;
const page = await browser.newPage();
await page.setBypassCSP(true);
page.on('console', (msg) => {
log.d("Headless chrome page log", msg.text());
});
page.on('pageerror', (error) => {
log.e("Headless chrome page error message", error.message);
});
page.on('response', (response) => {
log.d("Headless chrome page response", response.status(), response.url());
});
page.on('requestfailed', (request) => {
log.d("Headless chrome page failed request", request.failure().errorText, request.url());
});
//Refuse anything the document asks for beyond Countly's own origins. The
//document is opened as a data: url below, and inline data: and blob:
//resources carry no network request anywhere, so both pass through.
const origins = renderOrigins(allowedOrigins);
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = request.url();
if (isAllowedRenderUrl(url, origins)) {
return request.continue();
}
log.w('pdf render refused a request outside the countly origin', url);
return request.abort();
});
page.setDefaultNavigationTimeout(updatedTimeout);
if (!options) {
options = { format: 'Letter' };
}
if (remoteContent === true) {
await page.goto(`data:text/html;base64,${Buffer.from(html).toString('base64')}`, {
waitUntil: 'networkidle0'
});
}
else {
//page.setContent will be faster than page.goto if html is a static
await page.setContent(html);
}
const contentHeight = await page.evaluate(() => {
/*global document*/
return document.body.scrollHeight;
});
options.width = '210mm'; // A4 width, for example
options.height = `${contentHeight}px`; // full content height
options.printBackground = true;
options.preferCSSPageSize = true;
await page.pdf(options).then(callback, function(error) {
log.d('pdf generation error', error);
});
log.d('pdf generated');
}
catch (error) {
//a failed render used to be logged at debug and then replaced by a
//TypeError from the close below, so the actual reason (a missing chromium,
//a bad launch argument) never reached the log at all
log.e('pdf generation failed', error);
}
finally {
if (browser) {
await browser.close();
}
}
};