utils/render.js

/*global window*/

/**
* Module rendering views as images
* @module api/utils/render
*/

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 pathModule = require('path');
var exec = require('child_process').exec;
var alternateChrome = true;
var chromePath = "";
var countlyFs = require('./countlyFs');
var log = require('./log.js')('core:render');
var countlyConfig = require('./../config', 'dont-enclose');
var fs = require('fs');


/**
* Check that a view stays on the dashboard, and return the url to navigate to
*
* The view can come from a request (/o/render passes params.qstring.view through), and the
* url is built by concatenation. With the default countlyConfig.path of "" the prefix is
* exactly "http://localhost", so a view that does not begin with "/" rewrites the host
* instead of the path:
*
*   "@169.254.169.254/latest/meta-data/"  ->  host 169.254.169.254
*   ":8500/v1/kv/?recurse"                ->  host localhost:8500
*   ".internal.example/x"                 ->  host localhost.internal.example
*
* Parsing the concatenated url and comparing origins settles all of those at once, rather
* than trying to enumerate them. It also agrees with what Chromium will do with the same
* string, since both use the WHATWG url parser. Note that a private range denylist would
* be the wrong control here: the intended target is loopback.
*
* The returned url is the concatenation itself, unchanged, so a configured
* countlyConfig.path keeps working exactly as before.
* @param {string} host - dashboard origin plus the configured path
* @param {string} view - view to render, expected to be a path on that dashboard
* @returns {string|null} url to navigate to, or null when it leaves the dashboard origin
**/
function sameOriginView(host, view) {
    if (typeof view !== "string") {
        return null;
    }
    var target = host + view;
    //host may carry no path at all, so normalise it before taking the origin
    var expected = originOf(host + "/");
    if (!expected || originOf(target) !== expected) {
        return null;
    }
    return target;
}

//The dashboard serves nearly all of its own assets, but not quite all of them. Two
//off-origin fetches are part of a normal render: countlyConfig.cdn moves the core
//styles and scripts to a CDN, and the map widgets fetch tiles from OpenStreetMap.
//Refusing those leaves screenshots unstyled or the map blank, and a CDN deployment
//cannot bootstrap the dashboard at all, so both are allowed by origin.
//
//frontend/express/public/javascripts/countly/vue/components/vis.js requests
//https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png and passes no subdomains option,
//so leaflet expands {s} to a, b and c. Listed as exact origins rather than matched by
//suffix: the allowlist stays a set of strings to compare, with no pattern to get wrong.
var TILE_ORIGINS = [
    "https://a.tile.openstreetmap.org",
    "https://b.tile.openstreetmap.org",
    "https://c.tile.openstreetmap.org",
    "https://tile.openstreetmap.org"
];

/**
* Origin of a url, or null when it has none to speak of
*
* A relative cdn ("" or "/assets/") has no origin and returns null here, which is
* correct: it is already served from the dashboard origin and needs no entry.
* @param {string} url - absolute url, or anything at all
* @returns {string|null} the serialised origin, or null when there is not a real one
**/
function originOf(url) {
    var origin;
    try {
        origin = new URL(url).origin;
    }
    catch (error) {
        return null;
    }
    //opaque origins serialise to "null" and would compare equal to each other
    if (!origin || origin === "null") {
        return null;
    }
    return origin;
}

/**
* Origin of the configured CDN, when one is configured as an absolute url
*
* cdn belongs to the dashboard's config rather than the api's. In the standard layout
* both live in the same tree and this finds it with no operator action; where the api
* is deployed without the dashboard the file is absent, and the origin is named in
* countlyConfig.render.allowedOrigins instead.
* @returns {string|null} the CDN origin, or null when there is none to allow
**/
function cdnOrigin() {
    try {
        return originOf(require('../../frontend/express/config.js').cdn || "");
    }
    catch (error) {
        return null;
    }
}

/**
* Origins the renderer may fetch subresources from
*
* The dashboard's own origin, the configured CDN, the map tile provider the shipped
* dashboard uses, and anything the operator adds in countlyConfig.render.allowedOrigins
* (entries may be a bare origin or any url on it). Everything else is aborted.
* @param {string} host - dashboard origin plus the configured path
* @returns {string[]} origins to allow, empty when the host itself cannot be parsed
**/
function renderAssetOrigins(host) {
    var dashboard = originOf(host + "/");
    if (!dashboard) {
        return [];
    }
    var configured = (countlyConfig.render && countlyConfig.render.allowedOrigins) || [];
    if (!Array.isArray(configured)) {
        configured = [];
    }
    var origins = [dashboard, cdnOrigin()]
        .concat(TILE_ORIGINS)
        .concat(configured.map(originOf));
    return origins.filter(function(origin, at) {
        return origin && origins.indexOf(origin) === at;
    });
}

//exported so the same origin check can be unit tested without launching a browser
exports.sameOriginView = sameOriginView;
exports.renderAssetOrigins = renderAssetOrigins;

/**
 * Function to render views as images
 * @param  {object} options - options required for rendering
 * @param  {string} options.host - the hostname
 * @param  {string} options.token - the login token value
 * @param  {string} options.view - the view to open
 * @param  {string} options.id - the id of the block to capture screenshot of
 * @param  {string} options.savePath - path where to save the screenshot
 * @param  {function} options.cbFn - function called after opening the view
 * @param  {function} options.beforeScrnCbFn - function called just before capturing the screenshot
 * @param  {object} options.dimensions - the dimensions of the screenshot
 * @param  {number} options.dimensions.width - the width of the screenshot
 * @param  {number} options.dimensions.height - the height of the screenshot
 * @param  {number} options.dimensions.padding - the padding value to subtract from the height of the screenshot
 * @param  {number} options.dimensions.scale - the scale(ppi) value of the screenshot
 * @param  {function} cb - callback function called with the error value or the image data
 * @return {void} void
 */
exports.renderView = function(options, cb) {
    if (puppeteer === undefined) {
        cb = typeof cb === 'function' ? cb : () => undefined;
        return cb(new Error(
            'Puppeteer not installed. Please install Puppeteer to use this plugin.'
        ));
    }

    (async() => {
        try {

            if (!chromePath && alternateChrome) {
                chromePath = await fetchChromeExecutablePath();
            }

            var settings = {
                headless: true,
                env: {
                    //https://github.com/hardkoded/puppeteer-sharp/issues/2633
                    XDG_CONFIG_HOME: pathModule.resolve(__dirname, "../../.cache/chrome/tmp/.chromium"),
                    XDG_CACHE_HOME: pathModule.resolve(__dirname, "../../.cache/chrome/tmp/.chromium")
                },
                // --no-sandbox / --disable-setuid-sandbox: needed when running as root in containers.
                // --ignore-certificate-errors: needed because the renderer fetches the local
                //   dashboard at https://localhost which often has a self-signed certificate.
                // CSP is already bypassed via page.setBypassCSP(true) below; --disable-web-security
                //   was previously also passed here, but it weakens same-origin protection across
                //   the rendering session and is not necessary for this flow. Removed.
                args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
                ignoreHTTPSErrors: true,
                userDataDir: pathModule.resolve(__dirname, "../../dump/chrome/" + Date.now())
            };

            if (chromePath) {
                settings.executablePath = chromePath;
            }

            var browser = await puppeteer.launch(settings);

            try {
                log.d('Started rendering images');
                var 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());
                });

                var host = (process.env.COUNTLY_CONFIG_PROTOCOL || "http") + "://" + (process.env.COUNTLY_CONFIG_HOSTNAME || "localhost") + countlyConfig.path;

                if (options.host) {
                    host = options.host + countlyConfig.path;
                }

                var token = options.token;
                var view = options.view;
                var id = options.id;
                var path = options.savePath || pathModule.resolve(__dirname, "../../frontend/express/public/images/screenshots/" + "screenshot_" + Date.now() + ".png");
                var cbFn = options.cbFn || function() {};
                var beforeScrnCbFn = options.beforeScrnCbFn || function() {};
                var source = options.source;
                var updatedTimeout = options.timeout || 30000;
                var waitForRegex = options.waitForRegex;
                var waitForRegexAfterCbfn = options.waitForRegexAfterCbfn;

                options.dimensions = {
                    width: options.dimensions && options.dimensions.width ? options.dimensions.width : 1800,
                    height: options.dimensions && options.dimensions.height ? options.dimensions.height : 0,
                    padding: options.dimensions && options.dimensions.padding ? options.dimensions.padding : 0,
                    scale: options.dimensions && options.dimensions.scale ? options.dimensions.scale : 2
                };

                //Second, independent control: the renderer may only fetch from the
                //dashboard origin and the few asset origins a dashboard legitimately uses.
                //The check on the view bounds where we navigate, this bounds every
                //subresource the rendered page then asks for, so a page that does reach the
                //browser cannot use it to fetch what the server can reach.
                var allowedOrigins = renderAssetOrigins(host);
                if (!allowedOrigins.length) {
                    log.e("Cannot parse the configured dashboard host", host);
                }
                await page.setRequestInterception(true);
                page.on('request', function(request) {
                    var requestUrl = request.url();
                    if (/^(data|blob|about):/.test(requestUrl)) {
                        return request.continue();
                    }
                    if (allowedOrigins.indexOf(originOf(requestUrl)) !== -1) {
                        return request.continue();
                    }
                    log.d("Refused a request outside the allowed render origins", requestUrl);
                    return request.abort();
                });

                page.setDefaultNavigationTimeout(updatedTimeout);
                const resp = await page.goto(host + '/login/token/' + token + '?ssr=true');
                const status = resp?.status();
                if (status !== 200) {
                    throw new Error(`Failed to open login page. Status: ${status}`);
                }

                await page.waitForSelector('countly', {timeout: updatedTimeout});

                await timeout(1500);

                var viewUrl = sameOriginView(host, view);
                if (!viewUrl) {
                    //the value can be attacker supplied, so keep it out of the log
                    log.e("Refusing to render a view outside the dashboard origin");
                    throw new Error("Invalid view");
                }

                await page.goto(viewUrl);

                if (waitForRegex) {
                    await page.waitForResponse(
                        function(response) {
                            var url = response.url();
                            log.d("waitForRegex - Response Status: " + response.status() + ", URL: " + url);
                            if (waitForRegex.test(url) && response.status() === 200) {
                                return true;
                            }
                            else {
                                return false;
                            }

                        },
                        { timeout: updatedTimeout }
                    );
                }

                await timeout(500);

                await page.evaluate(cbFn, options);

                if (waitForRegexAfterCbfn) {
                    if (waitForRegex) {
                        await page.waitForResponse(
                            function(response) {
                                var url = response.url();
                                log.d("waitForRegexAfterCbfn - Response Status: " + response.status() + ", URL: " + url);
                                if (waitForRegex.test(url) && response.status() === 200) {
                                    return true;
                                }
                                else {
                                    return false;
                                }

                            },
                            { timeout: updatedTimeout }
                        );
                    }
                }

                await timeout(1500);

                await page.setViewport({
                    width: parseInt(options.dimensions.width),
                    height: parseInt(options.dimensions.height),
                    deviceScaleFactor: options.dimensions.scale
                });

                await timeout(1500);

                var bodyHandle = await page.$('body');
                var dimensions = await bodyHandle.boundingBox();

                await page.setViewport({
                    width: parseInt(options.dimensions.width || dimensions.width),
                    height: parseInt(dimensions.height - options.dimensions.padding),
                    deviceScaleFactor: options.dimensions.scale
                });

                await timeout(1500);

                await page.evaluate(beforeScrnCbFn, options);

                await timeout(1500);

                var image = "";
                var screenshotOptions = {
                    type: 'png',
                    encoding: 'binary'
                };

                if (id) {
                    var rect = await page.evaluate(function(selector) {
                    /*global document */
                        var element = document.querySelector(selector);
                        dimensions = element.getBoundingClientRect();
                        return {
                            left: dimensions.x,
                            top: dimensions.y,
                            width: dimensions.width,
                            height: dimensions.height,
                            id: element.id
                        };
                    }, id);

                    await page.setViewport({
                        width: options.dimensions.width,
                        height: parseInt(rect.height),
                        deviceScaleFactor: options.dimensions.scale
                    });


                    var clip = {
                        x: rect.left,
                        y: rect.top,
                        width: rect.width,
                        height: rect.height
                    };

                    screenshotOptions.clip = clip;
                }

                image = await page.screenshot(screenshotOptions);

                await saveScreenshot(image, path, source);

                await page.evaluate(function() {
                    var $ = window.$;
                    $("#user-logout").trigger("click");
                });

                await timeout(1500);

                await bodyHandle.dispose();
                await browser.close();

                // Remove user data directory after use
                fs.rmSync(settings.userDataDir, { recursive: true, force: true });

                var imageData = {
                    image: image,
                    path: path
                };
                log.d('Finished rendering images');
                return cb(null, imageData);
            }
            catch (e) {
                log.e("Headless chrome browser error", e);
                await browser.close();
                // Remove user data directory after use
                fs.rmSync(settings.userDataDir, { recursive: true, force: true });
                return cb(e);
            }
        }
        catch (err) {
            if (cb) {
                log.e("Headless chrome error", err);
                return cb(err);
            }
        }
    })();
};
/**
 * Function to fetch Chrome executable
 * @returns {Promise} Promise
 */
function fetchChromeExecutablePath() {
    return new Promise(function(resolve) {
        exec('ls /etc/ | grep -i "redhat-release" | wc -l', function(error1, stdout1, stderr1) {
            if (error1 || parseInt(stdout1) !== 1) {
                if (stderr1) {
                    log.e(stderr1);
                }

                alternateChrome = false;
                return resolve();
            }

            exec('cat /etc/redhat-release | grep -i "release 6" | wc -l', function(error2, stdout2, stderr2) {
                if (error2 || parseInt(stdout2) !== 1) {
                    if (stderr2) {
                        log.e(stderr2);
                    }

                    alternateChrome = false;
                    return resolve();
                }

                var path = "/usr/bin/google-chrome-stable";
                return resolve(path);
            });
        });
    });
}
/**
 * Function to save screenshots
 * @param  {Buffer} image - image data to store
 * @param  {String} path - path where image should be stored
 * @param  {String} source - who provided image
 * @returns {Promise} Promise
 */
function saveScreenshot(image, path, source) {
    return new Promise(function(resolve) {
        var buffer = image;
        var saveDataOptions = {writeMode: "overwrite"};
        if (source && source.length) {
            saveDataOptions.id = source;
        }
        countlyFs.saveData("screenshots", path, buffer, saveDataOptions, function(err3) {
            if (err3) {
                log.e(err3, err3.stack);
            }
            return resolve();
        });
    });
}

/**
 * Function to set a timeout
 * @param  {number} ms - Total milliseconds
 * @returns {Promise} Promise
 */
function timeout(ms) {
    return new Promise(function(resolve) {
        setTimeout(resolve, ms);
    });
}