1 /* eslint-disable security/detect-object-injection */
5 * A very minimal API server framework.
6 * Just a self-contained router and some request glue.
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');
22 const _fileScope
= common
.fileScope(__filename
);
24 const defaultOptions
= {
25 ignoreTrailingSlash: false,
30 staticPath: undefined, // No reasonable default
37 * @param {Object} logger object which implements logging methods
38 * @param {Object} options
39 * @param {Boolean} options.ignoreTrailingSlash
40 * @param {string} options.proxyPrefix leading part of url path to strip
41 * @param {Boolean} options.strictAccept whether to error on unsupported Accept type
42 * @param {string} options.selfBaseUrl for constructing links
43 * @param {Boolean} options.staticMetadata serve static headers with static files
44 * @param {Boolean} options.trustProxy trust some header data to be provided by proxy
45 * @param {Object} options.querystring alternate qs parser to use
47 constructor(logger
= common
.nullLogger
, options
= {}) {
48 common
.setOptions(this, defaultOptions
, options
);
50 this.router
= new Router(options
);
52 if (!this.proxyPrefix
) {
53 this._stripPrefix
= (p
) => p
;
56 this.responseTypes
= [
57 Enum
.ContentType
.TextHTML
,
58 Enum
.ContentType
.TextPlain
,
59 Enum
.ContentType
.ApplicationJson
,
63 common
.ensureLoggerLevels(this.logger
);
68 * Resolve relative and empty paths in url
69 * @param {string} p path
72 const pathNorm
= path
.normalize(p
); // This isn't perfectly correct, but it's easy...
73 return this._stripPrefix(pathNorm
);
78 * Remove a leading portion of url path
79 * N.B. This method gets obliterated if there is no prefix defined at construction
80 * @param {string} p path
83 if (p
.startsWith(this.proxyPrefix
)) {
84 return p
.slice(this.proxyPrefix
.length
);
91 * Returns the path part, and querystring object, from a request url.
95 const [ p
, qs
] = common
.splitFirst(url
, '?');
97 pathPart: this._normalizePath(p
),
98 queryParams: this.querystring
.parse(qs
),
104 * Insert a new path handler
105 * @param {string} method
106 * @param {string} urlPath
107 * @param {fn} handler
109 on(method
, urlPath
, handler
, ...handlerArgs
) {
110 this.router
.on(method
, urlPath
, handler
, handlerArgs
);
115 * Common header tagging for all requests.
116 * Add our own identifier, and persist any external transit identifiers.
117 * @param {http.ClientRequest} req
118 * @param {http.ServerResponse} res
119 * @param {object} ctx
121 static tagContext(req
, res
, ctx
) {
122 const requestId
= common
.requestId();
123 ctx
.requestId
= requestId
;
124 res
.setHeader(Enum
.Header
.RequestId
, requestId
);
125 [Enum
.Header
.XRequestId
, Enum
.Header
.XCorrelationId
].forEach((h
) => {
126 const v
= req
.getHeader(h
);
128 ctx
[h
.replace(/-/g
, '')] = v
;
138 * @param {http.ClientRequest} req
141 // TODO: RFC7239 Forwarded support
142 const address
= (this.trustProxy
&& req
&& req
.getHeader(Enum
.Header
.XForwardedFor
)) ||
143 (this.trustProxy
&& req
&& req
.getHeader(Enum
.Header
.XRealIP
)) ||
144 (req
&& req
.connection
&& req
.connection
.remoteAddress
) ||
146 return address
.split(/\s*,\s*/u)[0];
152 * @param {http.ClientRequest} req
155 // TODO: RFC7239 Forwarded support
156 const protocol
= (this.trustProxy
&& req
&& req
.getHeader(Enum
.Header
.XForwardedProto
)) ||
157 ((req
&& req
.connection
&& req
.connection
.encrypted
) ? 'https' : 'http');
158 return protocol
.split(/\s*,\s*/u)[0];
164 * @param {http.ClientRequest} req
165 * @param {http.ServerResponse} res
166 * @param {object} ctx
168 clientAddressContext(req
, res
, ctx
) {
169 ctx
.clientAddress
= this._getAddress(req
);
170 ctx
.clientProtocol
= this._getProtocol(req
);
175 * Called before every request handler.
176 * @param {http.ClientRequest} req
177 * @param {http.ServerResponse} res
178 * @param {object} ctx
180 async
preHandler(req
, res
, ctx
) {
181 Dingus
.tagContext(req
, res
, ctx
);
182 this.clientAddressContext(req
, res
, ctx
);
187 * Helper for collecting chunks as array of buffers.
188 * @param {Buffer[]} chunks
189 * @param {string|Buffer} chunk
190 * @param {string} encoding
192 static pushBufChunk(chunks
, chunk
, encoding
= 'utf8') {
194 if (typeof chunk
=== 'string') {
195 chunk
= Buffer
.from(chunk
, encoding
);
203 * Sets ctx.responseBody and calls handler upon res.end().
204 * @param {http.ClientRequest} req
205 * @param {http.ServerResponse} res
206 * @param {object} ctx
207 * @param {*} handler fn(req, res, ctx)
209 static setEndBodyHandler(req
, res
, ctx
, handler
) {
210 const origWrite
= res
.write
.bind(res
);
211 const origEnd
= res
.end
.bind(res
);
213 res
.write = function (chunk
, encoding
, ...rest
) {
214 Dingus
.pushBufChunk(chunks
, chunk
, encoding
);
215 return origWrite(chunk
, encoding
, ...rest
);
217 res
.end = function (data
, encoding
, ...rest
) {
218 Dingus
.pushBufChunk(chunks
, data
, encoding
);
219 ctx
.responseBody
= Buffer
.concat(chunks
);
220 handler(req
, res
, ctx
);
221 return origEnd(data
, encoding
, ...rest
);
227 * Intercept writes for head requests, do not send to client,
228 * but send length, and make body available in context.
229 * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging.
230 * @param {http.ClientRequest} req
231 * @param {http.ServerResponse} res
232 * @param {object} ctx
233 * @param {Boolean} persistResponseBody
235 static setHeadHandler(req
, res
, ctx
, persistResponseBody
= false) {
236 if (req
.method
=== 'HEAD') {
237 const origEnd
= res
.end
.bind(res
);
239 res
.write = function (chunk
, encoding
) {
240 Dingus
.pushBufChunk(chunks
, chunk
, encoding
);
241 // No call to original res.write.
243 res
.end = function (data
, encoding
, ...rest
) {
244 Dingus
.pushBufChunk(chunks
, data
, encoding
);
245 const responseBody
= Buffer
.concat(chunks
);
246 res
.setHeader(Enum
.Header
.ContentLength
, Buffer
.byteLength(responseBody
));
247 if (persistResponseBody
) {
248 ctx
.responseBody
= responseBody
;
250 return origEnd(undefined, encoding
, ...rest
);
257 * Dispatch the handler for a request
258 * @param {http.ClientRequest} req
259 * @param {http.ServerResponse} res
260 * @param {object} ctx
262 async
dispatch(req
, res
, ctx
= {}) {
263 const _scope
= _fileScope('dispatch');
265 const { pathPart
, queryParams
} = this._splitUrl(req
.url
);
266 ctx
.queryParams
= queryParams
;
268 let handler
, handlerArgs
= [];
270 ({ handler
, handlerArgs
} = this.router
.lookup(req
.method
, pathPart
, ctx
));
272 if (e
instanceof DingusError
) {
275 handler
= this.handlerNotFound
.bind(this);
278 handler
= this.handlerMethodNotAllowed
.bind(this);
281 this.logger
.error(_scope
, 'unknown dingus error', { error: e
});
282 handler
= this.handlerInternalServerError
.bind(this);
284 } else if (e
instanceof URIError
) {
285 handler
= this.handlerBadRequest
.bind(this);
287 this.logger
.error(_scope
, 'lookup failure', { error: e
});
288 handler
= this.handlerInternalServerError
.bind(this);
293 await
this.preHandler(req
, res
, ctx
);
294 return await
handler(req
, res
, ctx
, ...handlerArgs
);
297 this.sendErrorResponse(e
, req
, res
, ctx
);
303 * Return normalized type, without any parameters.
304 * @param {http.ClientRequest} req
307 static getRequestContentType(req
) {
308 const contentType
= req
.getHeader(Enum
.Header
.ContentType
);
309 return (contentType
|| '').split(';')[0].trim().toLowerCase();
314 * Parse rawBody as contentType into ctx.parsedBody.
315 * @param {string} contentType
316 * @param {object} ctx
317 * @param {string|buffer}
319 parseBody(contentType
, ctx
, rawBody
) {
320 const _scope
= _fileScope('parseBody');
323 // 1.2.4 and earlier expected rawBody on context
324 rawBody
= ctx
.rawBody
;
327 switch (contentType
) {
328 case Enum
.ContentType
.ApplicationForm:
329 ctx
.parsedBody
= this.querystring
.parse(rawBody
);
332 case Enum
.ContentType
.ApplicationJson:
334 ctx
.parsedBody
= JSON
.parse(rawBody
);
336 this.logger
.debug(_scope
, 'JSON parse failed', { requestId: ctx
.requestId
, error: e
});
337 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
, e
.message
);
342 this.logger
.debug(_scope
, 'unhandled content-type', { requestId: ctx
.requestId
, contentType
});
343 throw new ResponseError(Enum
.ErrorResponse
.UnsupportedMediaType
);
349 * Return all body data from a request.
350 * @param {http.ClientRequest} req
351 * @param {Number=} maximumBodySize
352 * @param {Boolean=} toString
354 async
bodyData(req
, maximumBodySize
, toString
= true) {
355 const _scope
= _fileScope('bodyData');
356 return new Promise((resolve
, reject
) => {
359 req
.on('data', (chunk
) => {
361 length
+= Buffer
.byteLength(chunk
);
362 if (maximumBodySize
&& length
> maximumBodySize
) {
363 this.logger
.debug(_scope
, 'body data exceeded limit', { length
, maximumBodySize
});
364 reject(new ResponseError(Enum
.ErrorResponse
.RequestEntityTooLarge
));
367 req
.on('end', () => {
368 const bodyBuffer
= Buffer
.concat(body
);
369 resolve(toString
? bodyBuffer
.toString() : bodyBuffer
);
371 req
.on('error', (e
) => {
372 this.logger
.error(_scope
, 'failed', { error: e
});
380 * Read and parse request body data.
381 * @param {http.ClientRequest} req
382 * @param {http.ServerResponse} res
383 * @param {object} ctx
385 * @param {Boolean} .parseEmptyBody
386 * @param {Boolean} .persistRawBody
388 async
ingestBody(req
, res
, ctx
, { parseEmptyBody
= true, persistRawBody
= false, maximumBodySize
} = {}) {
389 const rawBody
= await
this.bodyData(req
, maximumBodySize
);
390 if (persistRawBody
) {
391 ctx
.rawBody
= rawBody
;
393 if (rawBody
|| parseEmptyBody
) {
394 const contentType
= Dingus
.getRequestContentType(req
);
395 this.parseBody(contentType
, ctx
, rawBody
);
401 * Return the best matching response type.
402 * @param {string[]} responseTypes
403 * @param {http.ClientRequest} req
405 static getResponseContentType(responseTypes
, req
) {
406 const acceptHeader
= req
.getHeader(Enum
.Header
.Accept
);
407 return ContentNegotiation
.accept(responseTypes
, acceptHeader
);
412 * Returns a list of the most-preferred content encodings for the response.
413 * @param {string[]} responseEncodings
414 * @param {http.ClientRequest} req
416 static getResponseEncoding(responseEncodings
, req
) {
417 const acceptEncodingHeader
= req
.getHeader(Enum
.Header
.AcceptEncoding
);
418 return ContentNegotiation
.preferred(responseEncodings
, acceptEncodingHeader
);
423 * Set the best content type for the response.
424 * @param {string[]} responseTypes default first
425 * @param {http.ClientRequest} req
426 * @param {http.ServerResponse} res
427 * @param {object} ctx
429 setResponseType(responseTypes
, req
, res
, ctx
) {
430 const _scope
= _fileScope('setResponseType');
431 ctx
.responseType
= Dingus
.getResponseContentType(responseTypes
, req
);
432 if (!ctx
.responseType
) {
433 if (this.strictAccept
) {
434 this.logger
.debug(_scope
, 'unhandled strict accept', { requestId: req
.requestId
});
435 throw new ResponseError(Enum
.ErrorResponse
.NotAcceptable
);
437 ctx
.responseType
= responseTypes
[0];
440 res
.setHeader(Enum
.Header
.ContentType
, ctx
.responseType
);
445 * Inserts an encoding
446 * @param {http.ServerResponse} res
447 * @param {string} encoding
449 static addEncodingHeader(res
, encoding
) {
450 const existingEncodings
= res
.getHeader(Enum
.Header
.ContentEncoding
);
451 if (existingEncodings
) {
452 encoding
= `${encoding}, ${existingEncodings}`;
454 res
.setHeader(Enum
.Header
.ContentEncoding
, encoding
);
459 * Attempt to fetch both data and metadata for a file.
460 * @param {string} filePath
462 async
_readFileInfo(filePath
) {
463 const _scope
= _fileScope('_readFileInfo');
466 // eslint-disable-next-line security/detect-non-literal-fs-filename
467 const stat
= fsPromises
.stat(filePath
);
468 // eslint-disable-next-line security/detect-non-literal-fs-filename
469 const data
= fsPromises
.readFile(filePath
);
470 result
= await Promise
.all([stat
, data
]);
472 if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e
.code
)) {
475 this.logger
.error(_scope
, 'fs error', { error: e
, filePath
});
483 * Potentially add additional headers from static file meta-file.
484 * @param {http.ServerResponse} res
485 * @param {string} directory
486 * @param {string} fileName - already normalized and filtered
488 async
_serveFileMetaHeaders(res
, directory
, fileName
) {
489 const _scope
= _fileScope('_serveFileMetaHeaders');
490 this.logger
.debug(_scope
, 'called', { directory
, fileName
});
492 const metaPrefix
= '.';
493 const metaSuffix
= '.meta';
494 const metaFileName
= `${metaPrefix}${fileName}${metaSuffix}`;
495 const metaFilePath
= path
.join(directory
, metaFileName
);
497 const [stat
, data
] = await
this._readFileInfo(metaFilePath
);
502 const lineBreakRE
= /\r\n|\n|\r/;
503 const lines
= data
.toString().split(lineBreakRE
);
504 common
.unfoldHeaderLines(lines
);
506 const headerParseRE
= /^(?<name
>[^:]+): +(?<value
>.*)$/;
507 lines
.forEach((line
) => {
509 const result
= headerParseRE
.exec(line
);
510 const { groups: header
} = result
;
511 res
.setHeader(header
.name
, header
.value
);
518 * Serve a file from a directory, with rudimentary cache awareness.
519 * This will also serve pre-encoded variations if available and requested.
520 * @param {http.ClientRequest} req
521 * @param {http.ServerResponse} res
522 * @param {object} ctx
523 * @param {string} directory
524 * @param {string} fileName
526 async
serveFile(req
, res
, ctx
, directory
, fileName
) {
527 const _scope
= _fileScope('serveFile');
528 this.logger
.debug(_scope
, 'called', { req
, ctx
});
530 // Require a directory field.
532 this.logger
.debug(_scope
, 'rejected unset directory', { fileName
});
533 return this.handlerNotFound(req
, res
, ctx
);
536 // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present.
537 fileName
= path
.normalize(fileName
);
539 // We will not deal with any subdirs, nor any dot-files.
540 // (Note that we could not deal with subdirs even if we wanted, due to simple router matching scheme.)
541 if (fileName
.indexOf(path
.sep
) >= 0
542 || fileName
.charAt(0) === '.') {
543 this.logger
.debug(_scope
, 'rejected filename', { fileName
});
544 return this.handlerNotFound(req
, res
, ctx
);
547 const filePath
= path
.join(directory
, fileName
);
549 // File must exist, before any alternate static encodings will be considered.
550 let [stat
, data
] = await
this._readFileInfo(filePath
);
552 return this.handlerNotFound(req
, res
, ctx
);
555 // If encodings were requested, check for static versions to serve.
556 // Update stat and data if matching version is found.
557 ctx
.availableEncodings
= Dingus
.getResponseEncoding(Object
.values(Enum
.EncodingType
), req
);
558 if (ctx
.availableEncodings
.length
=== 0) {
559 // Identity encoding was specifically denied, and nothing else available.
560 this.logger
.debug(_scope
, 'no suitable encodings', { ctx
});
561 return this.handlerMethodNotAllowed(req
, res
, ctx
);
563 for (const encoding
of ctx
.availableEncodings
) {
564 if (encoding
=== Enum
.EncodingType
.Identity
) {
567 const suffix
= Enum
.EncodingTypeSuffix
[encoding
];
569 this.logger
.error(_scope
, 'supported encoding missing mapped suffix', { ctx
, encoding
});
572 const encodedFilePath
= `${filePath}${suffix}`;
573 const [ encodedStat
, encodedData
] = await
this._readFileInfo(encodedFilePath
);
575 ([ stat
, data
] = [ encodedStat
, encodedData
]);
576 ctx
.selectedEncoding
= encoding
;
577 Dingus
.addEncodingHeader(res
, encoding
);
578 res
.setHeader(Enum
.Header
.Vary
, Enum
.Header
.AcceptEncoding
);
579 this.logger
.debug(_scope
, 'serving encoded version', { ctx
, encodedFilePath
});
584 const lastModifiedDate
= new Date(stat
.mtimeMs
);
585 res
.setHeader(Enum
.Header
.LastModified
, lastModifiedDate
.toGMTString());
587 const eTag
= common
.generateETag(filePath
, stat
, data
);
588 res
.setHeader(Enum
.Header
.ETag
, eTag
);
590 if (common
.isClientCached(req
, stat
.mtimeMs
, eTag
)) {
591 this.logger
.debug(_scope
, 'client cached file', { filePath
});
592 res
.statusCode
= 304; // Not Modified
597 // Set the type based on extension of un-encoded filename.
598 const ext
= path
.extname(filePath
).slice(1); // Drop the dot
599 const contentType
= extensionToMime(ext
);
600 res
.setHeader(Enum
.Header
.ContentType
, contentType
);
602 // We presume static files are relatively cacheable.
603 res
.setHeader(Enum
.Header
.CacheControl
, 'public');
605 if (this.staticMetadata
) {
606 await
this._serveFileMetaHeaders(res
, directory
, fileName
);
609 this.logger
.debug(_scope
, 'serving file', { filePath
, contentType
});
615 * Return a content-type appropriate rendering of an errorResponse object.
616 * @param {string} type content-type of response
617 * @param {object} err either an Error object, or an error response
618 * @param {number} err.statusCode
619 * @param {string} err.errorMessage
620 * @param {string|string[]} err.details
622 // eslint-disable-next-line class-methods-use-this
623 renderError(contentType
, err
) {
624 switch (contentType
) {
625 case Enum
.ContentType
.ApplicationJson:
626 return JSON
.stringify(err
);
628 case Enum
.ContentType
.TextHTML:
629 return Template
.errorHTML(err
);
631 case Enum
.ContentType
.TextPlain:
633 return [err
.errorMessage
, err
.details
].join('\r\n');
639 * Send an error response. Terminal.
640 * Logs any non-error-response errors as such.
641 * @param {object} err either an Error object, or an error response
642 * @param {http.ClientRequest} req
643 * @param {http.ServerResponse} res
644 * @param {object} ctx
646 sendErrorResponse(err
, req
, res
, ctx
) {
647 const _scope
= _fileScope('sendErrorResponse');
650 // Default to a content type if one is not yet present
651 if (!res
.hasHeader(Enum
.Header
.ContentType
)) {
652 res
.setHeader(Enum
.Header
.ContentType
, Enum
.ContentType
.TextPlain
);
655 if (err
&& err
.statusCode
) {
656 res
.statusCode
= err
.statusCode
;
657 body
= this.renderError(res
.getHeader(Enum
.Header
.ContentType
), err
);
658 this.logger
.debug(_scope
, 'handler error', { err
, req
, res
, ctx
});
660 res
.statusCode
= 500;
661 body
= this.renderError(res
.getHeader(Enum
.Header
.ContentType
), Enum
.ErrorResponse
.InternalServerError
);
662 this.logger
.error(_scope
, 'handler exception', { err
, req
, res
, ctx
});
669 * @param {http.ClientRequest} req
670 * @param {http.ServerResponse} res
671 * @param {object} ctx
672 * @param {String} file - override ctx.params.file
674 async
handlerGetStaticFile(req
, res
, ctx
, file
) {
675 Dingus
.setHeadHandler(req
, res
, ctx
);
677 // Set a default response type to handle any errors; will be re-set to serve actual static content type.
678 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
680 await
this.serveFile(req
, res
, ctx
, this.staticPath
, file
|| ctx
.params
.file
);
685 * @param {http.ClientRequest} req
686 * @param {http.ServerResponse} res
687 * @param {Object} ctx
688 * @param {String} newPath
689 * @param {Number} statusCode
691 async
handlerRedirect(req
, res
, ctx
, newPath
, statusCode
= 307) {
692 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
693 res
.setHeader(Enum
.Header
.Location
, newPath
);
694 res
.statusCode
= statusCode
;
700 * @param {http.ClientRequest} req
701 * @param {http.ServerResponse} res
702 * @param {object} ctx
704 async
handlerMethodNotAllowed(req
, res
, ctx
) {
705 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
706 throw new ResponseError(Enum
.ErrorResponse
.MethodNotAllowed
);
711 * @param {http.ClientRequest} req
712 * @param {http.ServerResponse} res
713 * @param {object} ctx
715 async
handlerNotFound(req
, res
, ctx
) {
716 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
717 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
722 * @param {http.ClientRequest} req
723 * @param {http.ServerResponse} res
724 * @param {object} ctx
726 async
handlerBadRequest(req
, res
, ctx
) {
727 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
728 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
733 * @param {http.ClientRequest} req
734 * @param {http.ServerResponse} res
735 * @param {object} ctx
737 async
handlerInternalServerError(req
, res
, ctx
) {
738 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
739 throw new ResponseError(Enum
.ErrorResponse
.InternalServerError
);
744 module
.exports
= Dingus
;