highcharts-export-server
Version:
**Note:** If you use the public Export Server at [https://export.highcharts.com](https://export.highcharts.com) you should read our [Terms of use and Fair Usage Policy](https://www.highcharts.com/docs/export-module/privacy-disclaimer-export). Note that a
312 lines (255 loc) • 9.29 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 { v4 as uuid } from 'uuid';
import { getAllowCodeExecution, startExport } from '../../chart.js';
import { getOptions, mergeConfigOptions } from '../../config.js';
import { log } from '../../logger.js';
import {
fixType,
isCorrectJSON,
isObjectEmpty,
isPrivateRangeUrlFound,
optionsStringify,
measureTime
} from '../../utils.js';
import HttpError from '../../errors/HttpError.js';
// Reversed MIME types
const reversedMime = {
png: 'image/png',
jpeg: 'image/jpeg',
gif: 'image/gif',
pdf: 'application/pdf',
svg: 'image/svg+xml'
};
// The requests counter
let requestsCounter = 0;
// The array of callbacks to call before a request
const beforeRequest = [];
// The array of callbacks to call after a request
const afterRequest = [];
/**
* Invokes an array of callback functions with specified parameters, allowing
* customization of request handling.
*
* @param {Function[]} callbacks - An array of callback functions
* to be executed.
* @param {Express.Request} request - The Express request object.
* @param {Express.Response} response - The Express response object.
* @param {Object} data - An object containing parameters like id, uniqueId,
* type, and body.
*
* @returns {boolean} - Returns a boolean indicating the overall result
* of the callback invocations.
*/
const doCallbacks = (callbacks, request, response, data) => {
let result = true;
const { id, uniqueId, type, body } = data;
callbacks.some((callback) => {
if (callback) {
let callResponse = callback(request, response, id, uniqueId, type, body);
if (callResponse !== undefined && callResponse !== true) {
result = callResponse;
}
return true;
}
});
return result;
};
/**
* Handles the export requests from the client.
*
* @param {Express.Request} request - The Express request object.
* @param {Express.Response} response - The Express response object.
* @param {Function} next - The next middleware function.
*
* @returns {Promise<void>} - A promise that resolves once the export process
* is complete.
*/
const exportHandler = async (request, response, next) => {
try {
// Start counting time
const stopCounter = measureTime();
// Create a unique ID for a request
const uniqueId = uuid().replace(/-/g, '');
// Get the current server's general options
const defaultOptions = getOptions();
const body = request.body;
const id = ++requestsCounter;
let type = fixType(body.type);
// Throw 'Bad Request' if there's no body
if (!body || isObjectEmpty(body)) {
throw new HttpError(
'The request body is required. Please ensure that your Content-Type header is correct (accepted types are application/json and multipart/form-data).',
400
);
}
// All of the below can be used
let instr = isCorrectJSON(body.infile || body.options || body.data);
// Throw 'Bad Request' if there's no JSON or SVG to export
if (!instr && !body.svg) {
log(
2,
`The request with ID ${uniqueId} from ${
request.headers['x-forwarded-for'] || request.connection.remoteAddress
} was incorrect:
Content-Type: ${request.headers['content-type']}.
Chart constructor: ${body.constr}.
Dimensions: ${body.width}x${body.height} @ ${body.scale} scale.
Type: ${type}.
Is SVG set? ${typeof body.svg !== 'undefined'}.
B64? ${typeof body.b64 !== 'undefined'}.
No download? ${typeof body.noDownload !== 'undefined'}.
Payload received: ${JSON.stringify(body.infile || body.options || body.data || body.svg)}
`
);
throw new HttpError(
"No correct chart data found. Ensure that you are using either application/json or multipart/form-data headers. If sending JSON, make sure the chart data is in the 'infile', 'options', or 'data' attribute. If sending SVG, ensure it is in the 'svg' attribute.",
400
);
}
let callResponse = false;
// Call the before request functions
callResponse = doCallbacks(beforeRequest, request, response, {
id,
uniqueId,
type,
body
});
// Block the request if one of a callbacks failed
if (callResponse !== true) {
return response.send(callResponse);
}
let connectionAborted = false;
// In case the connection is closed, force to abort further actions
request.socket.on('close', (hadErrors) => {
if (hadErrors) {
connectionAborted = true;
}
});
log(4, `[export] Got an incoming HTTP request with ID ${uniqueId}.`);
body.constr = (typeof body.constr === 'string' && body.constr) || 'chart';
// Gather and organize options from the payload
const requestOptions = {
export: {
instr,
type,
constr: body.constr[0].toLowerCase() + body.constr.substr(1),
height: body.height,
width: body.width,
scale: body.scale || defaultOptions.export.scale,
globalOptions: isCorrectJSON(body.globalOptions, true),
themeOptions: isCorrectJSON(body.themeOptions, true)
},
customLogic: {
allowCodeExecution: getAllowCodeExecution(),
allowFileResources: false,
resources: isCorrectJSON(body.resources, true),
callback: body.callback,
customCode: body.customCode
}
};
if (instr) {
// Stringify JSON with options
requestOptions.export.instr = optionsStringify(
instr,
requestOptions.customLogic.allowCodeExecution
);
}
// Merge the request options into default ones
const options = mergeConfigOptions(defaultOptions, requestOptions);
// Save the JSON if exists
options.export.options = instr;
// Lastly, add the server specific arguments into options as payload
options.payload = {
svg: body.svg || false,
b64: body.b64 || false,
noDownload: body.noDownload || false,
requestId: uniqueId
};
// Test xlink:href elements from payload's SVG
if (body.svg && isPrivateRangeUrlFound(options.payload.svg)) {
throw new HttpError(
'SVG potentially contain at least one forbidden URL in xlink:href element. Please review the SVG content and ensure that all referenced URLs comply with security policies.',
400
);
}
// Start the export process
await startExport(options, (error, info) => {
// Remove the close event from the socket
request.socket.removeAllListeners('close');
// After the whole exporting process
if (defaultOptions.server.benchmarking) {
log(
5,
`[benchmark] Request with ID ${uniqueId} - After the whole exporting process: ${stopCounter()}ms.`
);
}
// If the connection was closed, do nothing
if (connectionAborted) {
return log(
3,
`[export] The client closed the connection before the chart finished processing.`
);
}
// If error, log it and send it to the error middleware
if (error) {
throw error;
}
// If data is missing, log the message and send it to the error middleware
if (!info || !info.result) {
throw new HttpError(
`Unexpected return from chart generation. Please check your request data. For the request with ID ${uniqueId}, the result is ${info.result}.`,
400
);
}
// Get the type from options
type = info.options.export.type;
// The after request callbacks
doCallbacks(afterRequest, request, response, { id, body: info.result });
if (info.result) {
// If only base64 is required, return it
if (body.b64) {
// SVG Exception for the Highcharts 11.3.0 version
if (type === 'pdf' || type == 'svg') {
return response.send(
Buffer.from(info.result, 'utf8').toString('base64')
);
}
return response.send(info.result);
}
// Set correct content type
response.header('Content-Type', reversedMime[type] || 'image/png');
// Decide whether to download or not chart file
if (!body.noDownload) {
response.attachment(
`${request.params.filename || request.body.filename || 'chart'}.${
type || 'png'
}`
);
}
// If SVG, return plain content
return type === 'svg'
? response.send(info.result)
: response.send(Buffer.from(info.result, 'base64'));
}
});
} catch (error) {
next(error);
}
};
export default (app) => {
/**
* Adds the POST / a route for handling POST requests at the root endpoint.
*/
app.post('/', exportHandler);
/**
* Adds the POST /:filename a route for handling POST requests with
* a specified filename parameter.
*/
app.post('/:filename', exportHandler);
};