highcharts-export-server
Version:
Convert Highcharts.JS charts into static image files.
567 lines (506 loc) • 17.6 kB
JavaScript
/*******************************************************************************
Highcharts Export Server
Copyright (c) 2016-2024, Highsoft
Licenced under the MIT licence.
Additionally a valid Highcharts license is required for use.
See LICENSE file in root for details.
*******************************************************************************/
import { readFileSync, writeFileSync } from 'fs';
import { getOptions, initExportSettings } from './config.js';
import { log, logWithStack } from './logger.js';
import { killPool, postWork, stats } from './pool.js';
import {
fixType,
handleResources,
isCorrectJSON,
optionsStringify,
roundNumber,
toBoolean,
wrapAround
} from './utils.js';
import { sanitize } from './sanitize.js';
import ExportError from './errors/ExportError.js';
let allowCodeExecution = false;
/**
* Starts an export process. The `settings` contains final options gathered
* from all possible sources (config, env, cli, json). The `endCallback` is
* called when the export is completed, with an error object as the first
* argument and the second containing the base64 respresentation of a chart.
*
* @param {Object} settings - The settings object containing export
* configuration.
* @param {function} endCallback - The callback function to be invoked upon
* finalizing work or upon error occurance of the exporting process.
*
* @returns {void} This function does not return a value directly; instead,
* it communicates results via the endCallback.
*/
export const startExport = async (settings, endCallback) => {
// Starting exporting process message
log(4, '[chart] Starting the exporting process.');
// Initialize options
const options = initExportSettings(settings, getOptions());
// Get the export options
const exportOptions = options.export;
// If SVG is an input (argument can be sent only by the request)
if (options.payload?.svg && options.payload.svg !== '') {
try {
log(4, '[chart] Attempting to export from a SVG input.');
const result = exportAsString(
sanitize(options.payload.svg), // #209
options,
endCallback
);
++stats.exportFromSvgAttempts;
return result;
} catch (error) {
return endCallback(
new ExportError('[chart] Error loading SVG input.').setError(error)
);
}
}
// Export using options from the file
if (exportOptions.infile && exportOptions.infile.length) {
// Try to read the file to get the string representation
try {
log(4, '[chart] Attempting to export from an input file.');
options.export.instr = readFileSync(exportOptions.infile, 'utf8');
return exportAsString(options.export.instr.trim(), options, endCallback);
} catch (error) {
return endCallback(
new ExportError('[chart] Error loading input file.').setError(error)
);
}
}
// Export with options from the raw representation
if (
(exportOptions.instr && exportOptions.instr !== '') ||
(exportOptions.options && exportOptions.options !== '')
) {
try {
log(4, '[chart] Attempting to export from a raw input.');
// Perform a direct inject when forced
if (toBoolean(options.customLogic?.allowCodeExecution)) {
return doStraightInject(options, endCallback);
}
// Either try to parse to JSON first or do the direct export
return typeof exportOptions.instr === 'string'
? exportAsString(exportOptions.instr.trim(), options, endCallback)
: doExport(
options,
exportOptions.instr || exportOptions.options,
endCallback
);
} catch (error) {
return endCallback(
new ExportError('[chart] Error loading raw input.').setError(error)
);
}
}
// No input specified, pass an error message to the callback
return endCallback(
new ExportError(
`[chart] No valid input specified. Check if at least one of the following parameters is correctly set: 'infile', 'instr', 'options', or 'svg'.`
)
);
};
/**
* Starts a batch export process for multiple charts based on the information
* in the batch option. The batch is a string in the following format:
* "infile1.json=outfile1.png;infile2.json=outfile2.png;..."
*
* @param {Object} options - The options object containing configuration for
* a batch export.
*
* @returns {Promise<void>} A Promise that resolves once the batch export
* process is completed.
*
* @throws {ExportError} Throws an ExportError if an error occurs during
* any of the batch export process.
*/
export const batchExport = async (options) => {
const batchFunctions = [];
// Split and pair the --batch arguments
for (let pair of options.export.batch.split(';')) {
pair = pair.split('=');
if (pair.length === 2) {
batchFunctions.push(
startExport(
{
...options,
export: {
...options.export,
infile: pair[0],
outfile: pair[1]
}
},
(error, info) => {
// Throw an error
if (error) {
throw error;
}
// Save the base64 from a buffer to a correct image file
writeFileSync(
info.options.export.outfile,
info.options.export.type !== 'svg'
? Buffer.from(info.result, 'base64')
: info.result
);
}
)
);
}
}
try {
// Await all exports are done
await Promise.all(batchFunctions);
// Kill pool and close browser after finishing batch export
await killPool();
} catch (error) {
throw new ExportError(
'[chart] Error encountered during batch export.'
).setError(error);
}
};
/**
* Starts a single export process based on the specified options.
*
* @param {Object} options - The options object containing configuration for
* a single export.
*
* @returns {Promise<void>} A Promise that resolves once the single export
* process is completed.
*
* @throws {ExportError} Throws an ExportError if an error occurs during
* the single export process.
*/
export const singleExport = async (options) => {
// Use instr or its alias, options
options.export.instr = options.export.instr || options.export.options;
// Perform an export
await startExport(options, async (error, info) => {
// Exit process when error
if (error) {
throw error;
}
const { outfile, type } = info.options.export;
// Save the base64 from a buffer to a correct image file
writeFileSync(
outfile || `chart.${type}`,
type !== 'svg' ? Buffer.from(info.result, 'base64') : info.result
);
// Kill pool and close browser after finishing single export
await killPool();
});
};
/**
* Determines the size and scale for chart export based on the provided options.
*
* @param {Object} options - The options object containing configuration for
* chart export.
*
* @returns {Object} An object containing the calculated height, width,
* and scale for the chart export.
*/
export const findChartSize = (options) => {
const { chart, exporting } =
options.export?.options || isCorrectJSON(options.export?.instr);
// See if globalOptions holds chart or exporting size
const globalOptions = isCorrectJSON(options.export?.globalOptions);
// Secure scale value
let scale =
options.export?.scale ||
exporting?.scale ||
globalOptions?.exporting?.scale ||
options.export?.defaultScale ||
1;
// the scale cannot be lower than 0.1 and cannot be higher than 5.0
scale = Math.max(0.1, Math.min(scale, 5.0));
// we want to round the numbers like 0.23234 -> 0.23
scale = roundNumber(scale, 2);
// Find chart size and scale
const size = {
height:
options.export?.height ||
exporting?.sourceHeight ||
chart?.height ||
globalOptions?.exporting?.sourceHeight ||
globalOptions?.chart?.height ||
options.export?.defaultHeight ||
400,
width:
options.export?.width ||
exporting?.sourceWidth ||
chart?.width ||
globalOptions?.exporting?.sourceWidth ||
globalOptions?.chart?.width ||
options.export?.defaultWidth ||
600,
scale
};
// Get rid of potential px and %
for (let [param, value] of Object.entries(size)) {
size[param] =
typeof value === 'string' ? +value.replace(/px|%/gi, '') : value;
}
return size;
};
/**
* Function for finalizing options before export.
*
* @param {Object} options - The options object containing configuration for
* the export process.
* @param {Object} chartJson - The JSON representation of the chart.
* @param {Function} endCallback - The callback function to be called upon
* completion or error.
* @param {string} svg - The SVG representation of the chart.
*
* @returns {Promise<void>} A Promise that resolves once the export process
* is completed.
*/
const doExport = async (options, chartJson, endCallback, svg) => {
let { export: exportOptions, customLogic: customLogicOptions } = options;
const allowCodeExecutionScoped =
typeof customLogicOptions.allowCodeExecution === 'boolean'
? customLogicOptions.allowCodeExecution
: allowCodeExecution;
if (!customLogicOptions) {
customLogicOptions = options.customLogic = {};
} else if (allowCodeExecutionScoped) {
if (typeof options.customLogic.resources === 'string') {
// Process resources
options.customLogic.resources = handleResources(
options.customLogic.resources,
toBoolean(options.customLogic.allowFileResources)
);
} else if (!options.customLogic.resources) {
try {
const resources = readFileSync('resources.json', 'utf8');
options.customLogic.resources = handleResources(
resources,
toBoolean(options.customLogic.allowFileResources)
);
} catch (error) {
logWithStack(
2,
error,
`[chart] Unable to load the default resources.json file.`
);
}
}
}
// If the allowCodeExecution flag isn't set, we should refuse the usage
// of callback, resources, and custom code. Additionally, the worker will
// refuse to run arbitrary JavaScript. Prioritized should be the scoped
// option, then we should take a look at the overall pool option.
if (!allowCodeExecutionScoped && customLogicOptions) {
if (
customLogicOptions.callback ||
customLogicOptions.resources ||
customLogicOptions.customCode
) {
// Send back a friendly message saying that the exporter does not support
// these settings.
return endCallback(
new ExportError(
`[chart] The 'callback', 'resources' and 'customCode' options have been disabled for this server.`
)
);
}
// Reset all additional custom code
customLogicOptions.callback = false;
customLogicOptions.resources = false;
customLogicOptions.customCode = false;
}
// Clean properties to keep it lean and mean
if (chartJson) {
chartJson.chart = chartJson.chart || {};
chartJson.exporting = chartJson.exporting || {};
chartJson.exporting.enabled = false;
}
exportOptions.constr = exportOptions.constr || 'chart';
exportOptions.type = fixType(exportOptions.type, exportOptions.outfile);
if (exportOptions.type === 'svg') {
exportOptions.width = false;
}
// Prepare global and theme options
['globalOptions', 'themeOptions'].forEach((optionsName) => {
try {
if (exportOptions && exportOptions[optionsName]) {
if (
typeof exportOptions[optionsName] === 'string' &&
exportOptions[optionsName].endsWith('.json')
) {
exportOptions[optionsName] = isCorrectJSON(
readFileSync(exportOptions[optionsName], 'utf8'),
true
);
} else {
exportOptions[optionsName] = isCorrectJSON(
exportOptions[optionsName],
true
);
}
}
} catch (error) {
exportOptions[optionsName] = {};
logWithStack(2, error, `[chart] The '${optionsName}' cannot be loaded.`);
}
});
// Prepare the customCode
if (customLogicOptions.allowCodeExecution) {
try {
customLogicOptions.customCode = wrapAround(
customLogicOptions.customCode,
customLogicOptions.allowFileResources
);
} catch (error) {
logWithStack(2, error, `[chart] The 'customCode' cannot be loaded.`);
}
}
// Get the callback
if (
customLogicOptions &&
customLogicOptions.callback &&
customLogicOptions.callback?.indexOf('{') < 0
) {
// The allowFileResources is always set to false for HTTP requests to avoid
// injecting arbitrary files from the fs
if (customLogicOptions.allowFileResources) {
try {
customLogicOptions.callback = readFileSync(
customLogicOptions.callback,
'utf8'
);
} catch (error) {
customLogicOptions.callback = false;
logWithStack(2, error, `[chart] The 'callback' cannot be loaded.`);
}
} else {
customLogicOptions.callback = false;
}
}
// Size search
options.export = {
...options.export,
...findChartSize(options)
};
// Post the work to the pool
try {
const result = await postWork(
exportOptions.strInj || chartJson || svg,
options
);
return endCallback(false, result);
} catch (error) {
return endCallback(error);
}
};
/**
* Performs a direct inject of options before export. The function attempts
* to stringify the provided options and removes unnecessary characters,
* ensuring a clean and formatted input. The resulting string is saved as
* a "stright inject" string in the export options. It then invokes the
* doExport function with the updated options.
*
* IMPORTANT: Dangerous and must be used deliberately by someone who sets up
* a server (see the --allowCodeExecution option).
*
* @param {Object} options - The export options containing the input
* to be injected.
* @param {function} endCallback - The callback function to be invoked
* at the end of the process.
*
* @returns {Promise} A Promise that resolves with the result of the export
* operation or rejects with an error if any issues occur during the process.
*/
const doStraightInject = (options, endCallback) => {
try {
let strInj;
let instr = options.export.instr || options.export.options;
if (typeof instr !== 'string') {
// Try to stringify options
strInj = instr = optionsStringify(
instr,
options.customLogic?.allowCodeExecution
);
}
strInj = instr.replaceAll(/\t|\n|\r/g, '').trim();
// Get rid of the ;
if (strInj[strInj.length - 1] === ';') {
strInj = strInj.substring(0, strInj.length - 1);
}
// Save as stright inject string
options.export.strInj = strInj;
return doExport(options, false, endCallback);
} catch (error) {
return endCallback(
new ExportError(
`[chart] Malformed input detected for ${options.export?.requestId || '?'}. Please make sure that your JSON/JavaScript options are sent using the "options" attribute, and that if you're using SVG, it is unescaped.`
).setError(error)
);
}
};
/**
* Exports a string based on the provided options and invokes an end callback.
*
* @param {string} stringToExport - The string content to be exported.
* @param {Object} options - Export options, including customLogic with
* allowCodeExecution flag.
* @param {Function} endCallback - Callback function to be invoked at the end
* of the export process.
*
* @returns {any} Result of the export process or an error if encountered.
*/
const exportAsString = (stringToExport, options, endCallback) => {
const { allowCodeExecution } = options.customLogic;
// Check if it is SVG
if (
stringToExport.indexOf('<svg') >= 0 ||
stringToExport.indexOf('<?xml') >= 0
) {
log(4, '[chart] Parsing input as SVG.');
return doExport(options, false, endCallback, stringToExport);
}
try {
// Try to parse to JSON and call the doExport function
const chartJSON = JSON.parse(stringToExport.replaceAll(/\t|\n|\r/g, ' '));
// If a correct JSON, do the export
return doExport(options, chartJSON, endCallback);
} catch (error) {
// Not a valid JSON
if (toBoolean(allowCodeExecution)) {
return doStraightInject(options, endCallback);
} else {
// Do not allow straight injection without the allowCodeExecution flag
return endCallback(
new ExportError(
'[chart] Only JSON configurations and SVG are allowed for this server. If this is your server, JavaScript custom code can be enabled by starting the server with the --allowCodeExecution flag.'
).setError(error)
);
}
}
};
/**
* Retrieves and returns the current status of code execution permission.
*
* @returns {any} The value of allowCodeExecution.
*/
export const getAllowCodeExecution = () => allowCodeExecution;
/**
* Sets the code execution permission based on the provided boolean value.
*
* @param {any} value - The value to be converted and assigned
* to allowCodeExecution.
*/
export const setAllowCodeExecution = (value) => {
allowCodeExecution = toBoolean(value);
};
export default {
batchExport,
singleExport,
getAllowCodeExecution,
setAllowCodeExecution,
startExport,
findChartSize
};