-
Notifications
You must be signed in to change notification settings - Fork 269
Expand file tree
/
Copy pathexport.js
More file actions
324 lines (267 loc) · 9.69 KB
/
export.js
File metadata and controls
324 lines (267 loc) · 9.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
/*******************************************************************************
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,
addXlinkNamespace
} 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) {
// This exception is a workaround for #547
// The plainly downloaded SVG is not properly formatted, as it lacks
// the xmlns:xlink, so images with "xlink:href" cannot be displayed
// and the entire SVG is deemed as incorrect (this may be a Highcharts
// problem as well as they should take care of this).
// A proper SVG has xlmns:xlink defined if they are used
// and Highcharts does not seem to have that for now.
// Once they do, we can get rid of this.
if (type === 'svg') {
info.result = addXlinkNamespace(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);
};