initial release
[squeep-api-dingus] / lib / dingus.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * A very minimal API server framework.
6 * Just a self-contained router and some request glue.
7 */
8
9 require('./patches');
10 const { promises: fsPromises } = require('fs');
11 const path = require('path');
12 const querystring = require('querystring');
13 const common = require('./common');
14 const ContentNegotiation = require('./content-negotiation');
15 const Enum = require('./enum');
16 const { DingusError, ResponseError } = require('./errors');
17 const { extensionToMime } = require('./mime-helper');
18 const Router = require('./router');
19 const Template = require('./template');
20
21 // For logging.
22 const _fileScope = common.fileScope(__filename);
23
24 const defaultOptions = {
25 ignoreTrailingSlash: false,
26 proxyPrefix: '',
27 strictAccept: true,
28 selfBaseUrl: '',
29 trustProxy: true,
30 querystring,
31 };
32
33 class Dingus {
34 /**
35 * @param {Object} logger object which implements logging methods
36 * @param {Object} options
37 * @param {Boolean} options.ignoreTrailingSlash
38 * @param {string} options.proxyPrefix leading part of url path to strip
39 * @param {Boolean} options.strictAccept whether to error on unsupported Accept type
40 * @param {string} options.selfBaseUrl for constructing links
41 * @param {Boolean} options.trustProxy trust some header data to be provided by proxy
42 * @param {Object} options.querystring alternate qs parser to use
43 */
44 constructor(logger = common.nullLogger, options = {}) {
45 common.setOptions(this, defaultOptions, options);
46
47 this.router = new Router(options);
48
49 if (!this.proxyPrefix) {
50 this._stripPrefix = (p) => p;
51 }
52
53 this.responseTypes = [
54 Enum.ContentType.TextHTML,
55 Enum.ContentType.TextPlain,
56 Enum.ContentType.ApplicationJson,
57 ];
58
59 this.logger = logger;
60 common.ensureLoggerLevels(this.logger);
61 }
62
63
64 /**
65 * Resolve relative and empty paths in url
66 * @param {string} p path
67 */
68 _normalizePath(p) {
69 const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy...
70 return this._stripPrefix(pathNorm);
71 }
72
73
74 /**
75 * Remove a leading portion of url path
76 * N.B. This method gets obliterated if there is no prefix defined at construction
77 * @param {string} p path
78 */
79 _stripPrefix(p) {
80 if (p.startsWith(this.proxyPrefix)) {
81 return p.slice(this.proxyPrefix.length);
82 }
83 return p;
84 }
85
86
87 /**
88 * Returns the path part, and querystring object, from a request url.
89 * @param {string} url
90 */
91 _splitUrl(url) {
92 const [ p, qs ] = common.splitFirst(url, '?');
93 return {
94 pathPart: this._normalizePath(p),
95 queryParams: this.querystring.parse(qs),
96 };
97 }
98
99
100 /**
101 * Insert a new path handler
102 * @param {string} method
103 * @param {string} urlPath
104 * @param {fn} handler
105 */
106 on(method, urlPath, handler) {
107 this.router.on(method, urlPath, handler);
108 }
109
110
111 /**
112 * Common header tagging for all requests.
113 * Add our own identifier, and persist any external transit identifiers.
114 * @param {http.ClientRequest} req
115 * @param {http.ServerResponse} res
116 * @param {object} ctx
117 */
118 static tagContext(req, res, ctx) {
119 const requestId = common.requestId();
120 ctx.requestId = requestId;
121 res.setHeader(Enum.Header.RequestId, requestId);
122 [Enum.Header.XRequestId, Enum.Header.XCorrelationId].forEach((h) => {
123 const v = req.getHeader(h);
124 if (v) {
125 ctx[h.replace(/-/g, '')] = v;
126 res.setHeader(h, v);
127 }
128 });
129 return requestId;
130 }
131
132
133 /**
134 *
135 * @param {http.ClientRequest} req
136 */
137 _getAddress(req) {
138 // TODO: RFC7239 Forwarded support
139 const address = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedFor)) ||
140 (this.trustProxy && req && req.getHeader(Enum.Header.XRealIP)) ||
141 (req && req.connection && req.connection.remoteAddress) ||
142 '';
143 return address.split(/\s*,\s*/u)[0];
144 }
145
146
147 /**
148 *
149 * @param {http.ClientRequest} req
150 */
151 _getProtocol(req) {
152 // TODO: RFC7239 Forwarded support
153 const protocol = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedProto)) ||
154 ((req && req.connection && req.connection.encrypted) ? 'https' : 'http');
155 return protocol.split(/\s*,\s*/u)[0];
156 }
157
158
159 /**
160 *
161 * @param {http.ClientRequest} req
162 * @param {http.ServerResponse} res
163 * @param {object} ctx
164 */
165 clientAddressContext(req, res, ctx) {
166 ctx.clientAddress = this._getAddress(req);
167 ctx.clientProtocol = this._getProtocol(req);
168 }
169
170
171 /**
172 * Called before every request handler.
173 * @param {http.ClientRequest} req
174 * @param {http.ServerResponse} res
175 * @param {object} ctx
176 */
177 async preHandler(req, res, ctx) {
178 Dingus.tagContext(req, res, ctx);
179 this.clientAddressContext(req, res, ctx);
180 }
181
182
183 /**
184 * Helper for collecting chunks as array of buffers.
185 * @param {Buffer[]} chunks
186 * @param {string|Buffer} chunk
187 * @param {string} encoding
188 */
189 static pushBufChunk(chunks, chunk, encoding = 'utf8') {
190 if (chunk) {
191 if (typeof chunk === 'string') {
192 chunk = Buffer.from(chunk, encoding);
193 }
194 chunks.push(chunk);
195 }
196 }
197
198
199 /**
200 * Sets ctx.responseBody and calls handler upon res.end().
201 * @param {http.ClientRequest} req
202 * @param {http.ServerResponse} res
203 * @param {object} ctx
204 * @param {*} handler fn(req, res, ctx)
205 */
206 static setEndBodyHandler(req, res, ctx, handler) {
207 const origWrite = res.write.bind(res);
208 const origEnd = res.end.bind(res);
209 const chunks = [];
210 res.write = function (chunk, encoding, ...rest) {
211 Dingus.pushBufChunk(chunks, chunk, encoding);
212 return origWrite(chunk, encoding, ...rest);
213 };
214 res.end = function (data, encoding, ...rest) {
215 Dingus.pushBufChunk(chunks, data, encoding);
216 ctx.responseBody = Buffer.concat(chunks);
217 handler(req, res, ctx);
218 return origEnd(data, encoding, ...rest);
219 };
220 }
221
222
223 /**
224 * Intercept writes for head requests, do not send to client,
225 * but send length, and make body available in context.
226 * @param {http.ClientRequest} req
227 * @param {http.ServerResponse} res
228 * @param {object} ctx
229 */
230 static setHeadHandler(req, res, ctx) {
231 if (req.method === 'HEAD') {
232 const origEnd = res.end.bind(res);
233 const chunks = [];
234 res.write = function (chunk, encoding) {
235 Dingus.pushBufChunk(chunks, chunk, encoding);
236 // No call to original res.write.
237 };
238 res.end = function (data, encoding, ...rest) {
239 Dingus.pushBufChunk(chunks, data, encoding);
240 ctx.responseBody = Buffer.concat(chunks);
241 res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(ctx.responseBody));
242 return origEnd(undefined, encoding, ...rest);
243 };
244 }
245 }
246
247
248 /**
249 * Dispatch the handler for a request
250 * @param {http.ClientRequest} req
251 * @param {http.ServerResponse} res
252 * @param {object} ctx
253 */
254 async dispatch(req, res, ctx = {}) {
255 const _scope = _fileScope('dispatch');
256
257 const { pathPart, queryParams } = this._splitUrl(req.url);
258 ctx.queryParams = queryParams;
259
260 let handler;
261 try {
262 handler = this.router.lookup(req.method, pathPart, ctx);
263 } catch (e) {
264 if (e instanceof DingusError) {
265 switch (e.message) {
266 case 'NoPath':
267 handler = this.handlerNotFound.bind(this);
268 break;
269 case 'NoMethod':
270 handler = this.handlerMethodNotAllowed.bind(this);
271 break;
272 default:
273 this.logger.error(_scope, 'unknown dingus error', { error: e });
274 handler = this.handlerInternalServerError.bind(this);
275 }
276 } else if (e instanceof URIError) {
277 handler = this.handlerBadRequest.bind(this);
278 } else {
279 this.logger.error(_scope, 'lookup failure', { error: e });
280 handler = this.handlerInternalServerError.bind(this);
281 }
282 }
283
284 try {
285 await this.preHandler(req, res, ctx);
286 return await handler(req, res, ctx);
287 } catch (e) {
288 ctx.error = e;
289 this.sendErrorResponse(e, req, res, ctx);
290 }
291 }
292
293
294 /**
295 * Return normalized type, without any parameters.
296 * @param {http.ClientRequest} req
297 * @returns {string}
298 */
299 static getRequestContentType(req) {
300 const contentType = req.getHeader(Enum.Header.ContentType);
301 return (contentType || '').split(';')[0].trim().toLowerCase();
302 }
303
304
305 /**
306 * Parse rawBody from ctx as contentType into parsedBody.
307 * @param {string} contentType
308 * @param {object} ctx
309 */
310 parseBody(contentType, ctx) {
311 const _scope = _fileScope('parseBody');
312
313 switch (contentType) {
314 case Enum.ContentType.ApplicationForm:
315 ctx.parsedBody = this.querystring.parse(ctx.rawBody);
316 break;
317
318 case Enum.ContentType.ApplicationJson:
319 try {
320 ctx.parsedBody = JSON.parse(ctx.rawBody);
321 } catch (e) {
322 this.logger.debug(_scope, 'JSON parse failed', { requestId: ctx.requestId, error: e });
323 throw new ResponseError(Enum.ErrorResponse.BadRequest, e.message);
324 }
325 break;
326
327 default:
328 this.logger.debug(_scope, 'unhandled content-type', { requestId: ctx.requestId, contentType });
329 throw new ResponseError(Enum.ErrorResponse.UnsupportedMediaType);
330 }
331 }
332
333
334 /**
335 * Return all body data from a request.
336 * @param {http.ClientRequest} req
337 */
338 async bodyData(req) {
339 const _scope = _fileScope('bodyData');
340 return new Promise((resolve, reject) => {
341 const body = [];
342 req.on('data', (chunk) => body.push(chunk));
343 req.on('end', () => resolve(Buffer.concat(body).toString()));
344 req.on('error', (e) => {
345 this.logger.error(_scope, 'failed', { error: e });
346 reject(e);
347 });
348 });
349 }
350
351
352 /**
353 * Read and parse request body data.
354 * @param {http.ClientRequest} req
355 * @param {http.ServerResponse} res
356 * @param {object} ctx
357 */
358 async ingestBody(req, res, ctx) {
359 ctx.rawBody = await this.bodyData(req);
360 const contentType = Dingus.getRequestContentType(req);
361 this.parseBody(contentType, ctx);
362 }
363
364
365 /**
366 * Return the best matching response type.
367 * @param {string[]} responseTypes
368 * @param {http.ClientRequest} req
369 */
370 static getResponseContentType(responseTypes, req) {
371 const acceptHeader = req.getHeader(Enum.Header.Accept);
372 return ContentNegotiation.accept(responseTypes, acceptHeader);
373 }
374
375
376 /**
377 * Returns a list of the most-preferred content encodings for the response.
378 * @param {string[]} responseEncodings
379 * @param {http.ClientRequest} req
380 */
381 static getResponseEncoding(responseEncodings, req) {
382 const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding);
383 return ContentNegotiation.preferred(responseEncodings, acceptEncodingHeader);
384 }
385
386
387 /**
388 * Set the best content type for the response.
389 * @param {string[]} responseTypes default first
390 * @param {http.ClientRequest} req
391 * @param {http.ServerResponse} res
392 * @param {object} ctx
393 */
394 setResponseType(responseTypes, req, res, ctx) {
395 const _scope = _fileScope('setResponseType');
396 ctx.responseType = Dingus.getResponseContentType(responseTypes, req);
397 if (!ctx.responseType) {
398 if (this.strictAccept) {
399 this.logger.debug(_scope, 'unhandled strict accept', { requestId: req.requestId });
400 throw new ResponseError(Enum.ErrorResponse.NotAcceptable);
401 } else {
402 ctx.responseType = responseTypes[0];
403 }
404 }
405 res.setHeader(Enum.Header.ContentType, ctx.responseType);
406 }
407
408
409 /**
410 * Inserts an encoding
411 * @param {http.ServerResponse} res
412 * @param {string} encoding
413 */
414 static addEncodingHeader(res, encoding) {
415 const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
416 if (existingEncodings) {
417 encoding = `${encoding}, ${existingEncodings}`;
418 }
419 res.setHeader(Enum.Header.ContentEncoding, encoding);
420 }
421
422
423 /**
424 * Attempt to fetch both data and metadata for a file.
425 * @param {string} filePath
426 */
427 async _readFileInfo(filePath) {
428 const _scope = _fileScope('_readFileInfo');
429 let result;
430 try {
431 // eslint-disable-next-line security/detect-non-literal-fs-filename
432 const stat = fsPromises.stat(filePath);
433 // eslint-disable-next-line security/detect-non-literal-fs-filename
434 const data = fsPromises.readFile(filePath);
435 result = await Promise.all([stat, data]);
436 } catch (e) {
437 if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e.code)) {
438 return [null, null];
439 }
440 this.logger.error(_scope, 'fs error', { error: e, filePath });
441 throw e;
442 }
443 return result;
444 }
445
446
447 /**
448 * Serve a file from a directory, with rudimentary cache awareness.
449 * This will also serve pre-encoded variations if available and requested.
450 * @param {http.ClientRequest} req
451 * @param {http.ServerResponse} res
452 * @param {object} ctx
453 * @param {string} directory
454 * @param {string} fileName
455 */
456 async serveFile(req, res, ctx, directory, fileName) {
457 const _scope = _fileScope('serveFile');
458 this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
459
460 // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present.
461 fileName = path.normalize(fileName);
462
463 // We will not deal with any subdirs, nor any dot-files.
464 // (Note that we could not deal with subdirs even if we wanted, due to simple router matching scheme.)
465 if (fileName.indexOf(path.sep) >= 0
466 || fileName.charAt(0) === '.') {
467 this.logger.debug(_scope, 'rejected filename', { fileName });
468 return this.handlerNotFound(req, res, ctx);
469 }
470
471 const filePath = path.join(directory, fileName);
472
473 // File must exist, before any alternate static encodings will be considered.
474 let [stat, data] = await this._readFileInfo(filePath);
475 if (!stat) {
476 return this.handlerNotFound(req, res, ctx);
477 }
478
479 // If encodings were requested, check for static versions to serve.
480 // Update stat and data if matching version is found.
481 ctx.availableEncodings = Dingus.getResponseEncoding(Object.values(Enum.EncodingType), req);
482 if (ctx.availableEncodings.length === 0) {
483 // Identity encoding was specifically denied, and nothing else available.
484 this.logger.debug(_scope, 'no suitable encodings', { ctx });
485 return this.handlerMethodNotAllowed(req, res, ctx);
486 }
487 for (const encoding of ctx.availableEncodings) {
488 if (encoding === Enum.EncodingType.Identity) {
489 break;
490 }
491 const suffix = Enum.EncodingTypeSuffix[encoding];
492 if (suffix) {
493 const encodedFilePath = `${filePath}${suffix}`;
494 const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
495 if (encodedStat) {
496 ([ stat, data ] = [ encodedStat, encodedData ]);
497 ctx.selectedEncoding = encoding;
498 Dingus.addEncodingHeader(res, encoding);
499 res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
500 this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath });
501 }
502 break;
503 }
504 }
505
506 const lastModifiedDate = new Date(stat.mtimeMs);
507 res.setHeader(Enum.Header.LastModified, lastModifiedDate.toGMTString());
508
509 const eTag = common.generateETag(filePath, stat, data);
510 res.setHeader(Enum.Header.ETag, eTag);
511
512 if (common.isClientCached(req, stat.mtimeMs, eTag)) {
513 this.logger.debug(_scope, 'client cached file', { filePath });
514 res.statusCode = 304; // Not Modified
515 res.end();
516 return;
517 }
518
519 // Set the type based on extension of un-encoded filename.
520 const ext = path.extname(filePath).slice(1); // Drop the dot
521 const contentType = extensionToMime(ext);
522 res.setHeader(Enum.Header.ContentType, contentType);
523
524 // We presume static files are relatively cacheable.
525 res.setHeader(Enum.Header.CacheControl, 'public');
526
527 this.logger.debug(_scope, 'serving file', { filePath, contentType });
528 res.end(data);
529 }
530
531
532 /**
533 * Return a content-type appropriate rendering of an errorResponse object.
534 * @param {string} type content-type of response
535 * @param {object} err either an Error object, or an error response
536 * @param {number} err.statusCode
537 * @param {string} err.errorMessage
538 * @param {string|string[]} err.details
539 */
540 // eslint-disable-next-line class-methods-use-this
541 renderError(contentType, err) {
542 switch (contentType) {
543 case Enum.ContentType.ApplicationJson:
544 return JSON.stringify(err);
545
546 case Enum.ContentType.TextHTML:
547 return Template.errorHTML(err);
548
549 case Enum.ContentType.TextPlain:
550 default:
551 return [err.errorMessage, err.details].join('\r\n');
552 }
553 }
554
555
556 /**
557 * Send an error response. Terminal.
558 * Logs any non-error-response errors as such.
559 * @param {object} err either an Error object, or an error response
560 * @param {http.ClientRequest} req
561 * @param {http.ServerResponse} res
562 * @param {object} ctx
563 */
564 sendErrorResponse(err, req, res, ctx) {
565 const _scope = _fileScope('sendErrorResponse');
566 let body;
567
568 // Default to a content type if one is not yet present
569 if (!res.hasHeader(Enum.Header.ContentType)) {
570 res.setHeader(Enum.Header.ContentType, Enum.ContentType.TextPlain);
571 }
572
573 if (err && err.statusCode) {
574 res.statusCode = err.statusCode;
575 body = this.renderError(res.getHeader(Enum.Header.ContentType), err);
576 this.logger.debug(_scope, 'handler error', { err, ...common.handlerLogData(req, res, ctx) });
577 } else {
578 res.statusCode = 500;
579 body = this.renderError(res.getHeader(Enum.Header.ContentType), Enum.ErrorResponse.InternalServerError);
580 this.logger.error(_scope, 'handler exception', { err, ...common.handlerLogData(req, res, ctx) });
581 }
582 res.end(body);
583 }
584
585
586 /**
587 * @param {http.ClientRequest} req
588 * @param {http.ServerResponse} res
589 * @param {object} ctx
590 */
591 async handlerMethodNotAllowed(req, res, ctx) {
592 this.setResponseType(this.responseTypes, req, res, ctx);
593 throw new ResponseError(Enum.ErrorResponse.MethodNotAllowed);
594 }
595
596
597 /**
598 * @param {http.ClientRequest} req
599 * @param {http.ServerResponse} res
600 * @param {object} ctx
601 */
602 async handlerNotFound(req, res, ctx) {
603 this.setResponseType(this.responseTypes, req, res, ctx);
604 throw new ResponseError(Enum.ErrorResponse.NotFound);
605 }
606
607
608 /**
609 * @param {http.ClientRequest} req
610 * @param {http.ServerResponse} res
611 * @param {object} ctx
612 */
613 async handlerBadRequest(req, res, ctx) {
614 this.setResponseType(this.responseTypes, req, res, ctx);
615 throw new ResponseError(Enum.ErrorResponse.BadRequest);
616 }
617
618 /**
619 * @param {http.ClientRequest} req
620 * @param {http.ServerResponse} res
621 * @param {object} ctx
622 */
623 async handlerInternalServerError(req, res, ctx) {
624 this.setResponseType(this.responseTypes, req, res, ctx);
625 throw new ResponseError(Enum.ErrorResponse.InternalServerError);
626 }
627
628 }
629
630 module.exports = Dingus;