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,
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
44 constructor(logger
= common
.nullLogger
, options
= {}) {
45 common
.setOptions(this, defaultOptions
, options
);
47 this.router
= new Router(options
);
49 if (!this.proxyPrefix
) {
50 this._stripPrefix
= (p
) => p
;
53 this.responseTypes
= [
54 Enum
.ContentType
.TextHTML
,
55 Enum
.ContentType
.TextPlain
,
56 Enum
.ContentType
.ApplicationJson
,
60 common
.ensureLoggerLevels(this.logger
);
65 * Resolve relative and empty paths in url
66 * @param {string} p path
69 const pathNorm
= path
.normalize(p
); // This isn't perfectly correct, but it's easy...
70 return this._stripPrefix(pathNorm
);
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
80 if (p
.startsWith(this.proxyPrefix
)) {
81 return p
.slice(this.proxyPrefix
.length
);
88 * Returns the path part, and querystring object, from a request url.
92 const [ p
, qs
] = common
.splitFirst(url
, '?');
94 pathPart: this._normalizePath(p
),
95 queryParams: this.querystring
.parse(qs
),
101 * Insert a new path handler
102 * @param {string} method
103 * @param {string} urlPath
104 * @param {fn} handler
106 on(method
, urlPath
, handler
) {
107 this.router
.on(method
, urlPath
, handler
);
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
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
);
125 ctx
[h
.replace(/-/g
, '')] = v
;
135 * @param {http.ClientRequest} 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
) ||
143 return address
.split(/\s*,\s*/u)[0];
149 * @param {http.ClientRequest} 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];
161 * @param {http.ClientRequest} req
162 * @param {http.ServerResponse} res
163 * @param {object} ctx
165 clientAddressContext(req
, res
, ctx
) {
166 ctx
.clientAddress
= this._getAddress(req
);
167 ctx
.clientProtocol
= this._getProtocol(req
);
172 * Called before every request handler.
173 * @param {http.ClientRequest} req
174 * @param {http.ServerResponse} res
175 * @param {object} ctx
177 async
preHandler(req
, res
, ctx
) {
178 Dingus
.tagContext(req
, res
, ctx
);
179 this.clientAddressContext(req
, res
, ctx
);
184 * Helper for collecting chunks as array of buffers.
185 * @param {Buffer[]} chunks
186 * @param {string|Buffer} chunk
187 * @param {string} encoding
189 static pushBufChunk(chunks
, chunk
, encoding
= 'utf8') {
191 if (typeof chunk
=== 'string') {
192 chunk
= Buffer
.from(chunk
, encoding
);
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)
206 static setEndBodyHandler(req
, res
, ctx
, handler
) {
207 const origWrite
= res
.write
.bind(res
);
208 const origEnd
= res
.end
.bind(res
);
210 res
.write = function (chunk
, encoding
, ...rest
) {
211 Dingus
.pushBufChunk(chunks
, chunk
, encoding
);
212 return origWrite(chunk
, encoding
, ...rest
);
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
);
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
230 static setHeadHandler(req
, res
, ctx
) {
231 if (req
.method
=== 'HEAD') {
232 const origEnd
= res
.end
.bind(res
);
234 res
.write = function (chunk
, encoding
) {
235 Dingus
.pushBufChunk(chunks
, chunk
, encoding
);
236 // No call to original res.write.
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
);
249 * Dispatch the handler for a request
250 * @param {http.ClientRequest} req
251 * @param {http.ServerResponse} res
252 * @param {object} ctx
254 async
dispatch(req
, res
, ctx
= {}) {
255 const _scope
= _fileScope('dispatch');
257 const { pathPart
, queryParams
} = this._splitUrl(req
.url
);
258 ctx
.queryParams
= queryParams
;
262 handler
= this.router
.lookup(req
.method
, pathPart
, ctx
);
264 if (e
instanceof DingusError
) {
267 handler
= this.handlerNotFound
.bind(this);
270 handler
= this.handlerMethodNotAllowed
.bind(this);
273 this.logger
.error(_scope
, 'unknown dingus error', { error: e
});
274 handler
= this.handlerInternalServerError
.bind(this);
276 } else if (e
instanceof URIError
) {
277 handler
= this.handlerBadRequest
.bind(this);
279 this.logger
.error(_scope
, 'lookup failure', { error: e
});
280 handler
= this.handlerInternalServerError
.bind(this);
285 await
this.preHandler(req
, res
, ctx
);
286 return await
handler(req
, res
, ctx
);
289 this.sendErrorResponse(e
, req
, res
, ctx
);
295 * Return normalized type, without any parameters.
296 * @param {http.ClientRequest} req
299 static getRequestContentType(req
) {
300 const contentType
= req
.getHeader(Enum
.Header
.ContentType
);
301 return (contentType
|| '').split(';')[0].trim().toLowerCase();
306 * Parse rawBody from ctx as contentType into parsedBody.
307 * @param {string} contentType
308 * @param {object} ctx
310 parseBody(contentType
, ctx
) {
311 const _scope
= _fileScope('parseBody');
313 switch (contentType
) {
314 case Enum
.ContentType
.ApplicationForm:
315 ctx
.parsedBody
= this.querystring
.parse(ctx
.rawBody
);
318 case Enum
.ContentType
.ApplicationJson:
320 ctx
.parsedBody
= JSON
.parse(ctx
.rawBody
);
322 this.logger
.debug(_scope
, 'JSON parse failed', { requestId: ctx
.requestId
, error: e
});
323 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
, e
.message
);
328 this.logger
.debug(_scope
, 'unhandled content-type', { requestId: ctx
.requestId
, contentType
});
329 throw new ResponseError(Enum
.ErrorResponse
.UnsupportedMediaType
);
335 * Return all body data from a request.
336 * @param {http.ClientRequest} req
338 async
bodyData(req
) {
339 const _scope
= _fileScope('bodyData');
340 return new Promise((resolve
, reject
) => {
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
});
353 * Read and parse request body data.
354 * @param {http.ClientRequest} req
355 * @param {http.ServerResponse} res
356 * @param {object} ctx
358 async
ingestBody(req
, res
, ctx
) {
359 ctx
.rawBody
= await
this.bodyData(req
);
360 const contentType
= Dingus
.getRequestContentType(req
);
361 this.parseBody(contentType
, ctx
);
366 * Return the best matching response type.
367 * @param {string[]} responseTypes
368 * @param {http.ClientRequest} req
370 static getResponseContentType(responseTypes
, req
) {
371 const acceptHeader
= req
.getHeader(Enum
.Header
.Accept
);
372 return ContentNegotiation
.accept(responseTypes
, acceptHeader
);
377 * Returns a list of the most-preferred content encodings for the response.
378 * @param {string[]} responseEncodings
379 * @param {http.ClientRequest} req
381 static getResponseEncoding(responseEncodings
, req
) {
382 const acceptEncodingHeader
= req
.getHeader(Enum
.Header
.AcceptEncoding
);
383 return ContentNegotiation
.preferred(responseEncodings
, acceptEncodingHeader
);
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
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
);
402 ctx
.responseType
= responseTypes
[0];
405 res
.setHeader(Enum
.Header
.ContentType
, ctx
.responseType
);
410 * Inserts an encoding
411 * @param {http.ServerResponse} res
412 * @param {string} encoding
414 static addEncodingHeader(res
, encoding
) {
415 const existingEncodings
= res
.getHeader(Enum
.Header
.ContentEncoding
);
416 if (existingEncodings
) {
417 encoding
= `${encoding}, ${existingEncodings}`;
419 res
.setHeader(Enum
.Header
.ContentEncoding
, encoding
);
424 * Attempt to fetch both data and metadata for a file.
425 * @param {string} filePath
427 async
_readFileInfo(filePath
) {
428 const _scope
= _fileScope('_readFileInfo');
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
]);
437 if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e
.code
)) {
440 this.logger
.error(_scope
, 'fs error', { error: e
, filePath
});
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
456 async
serveFile(req
, res
, ctx
, directory
, fileName
) {
457 const _scope
= _fileScope('serveFile');
458 this.logger
.debug(_scope
, 'called', { req: common
.requestLogData(req
), ctx
});
460 // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present.
461 fileName
= path
.normalize(fileName
);
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
);
471 const filePath
= path
.join(directory
, fileName
);
473 // File must exist, before any alternate static encodings will be considered.
474 let [stat
, data
] = await
this._readFileInfo(filePath
);
476 return this.handlerNotFound(req
, res
, ctx
);
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
);
487 for (const encoding
of ctx
.availableEncodings
) {
488 if (encoding
=== Enum
.EncodingType
.Identity
) {
491 const suffix
= Enum
.EncodingTypeSuffix
[encoding
];
493 const encodedFilePath
= `${filePath}${suffix}`;
494 const [ encodedStat
, encodedData
] = await
this._readFileInfo(encodedFilePath
);
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
});
506 const lastModifiedDate
= new Date(stat
.mtimeMs
);
507 res
.setHeader(Enum
.Header
.LastModified
, lastModifiedDate
.toGMTString());
509 const eTag
= common
.generateETag(filePath
, stat
, data
);
510 res
.setHeader(Enum
.Header
.ETag
, eTag
);
512 if (common
.isClientCached(req
, stat
.mtimeMs
, eTag
)) {
513 this.logger
.debug(_scope
, 'client cached file', { filePath
});
514 res
.statusCode
= 304; // Not Modified
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
);
524 // We presume static files are relatively cacheable.
525 res
.setHeader(Enum
.Header
.CacheControl
, 'public');
527 this.logger
.debug(_scope
, 'serving file', { filePath
, contentType
});
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
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
);
546 case Enum
.ContentType
.TextHTML:
547 return Template
.errorHTML(err
);
549 case Enum
.ContentType
.TextPlain:
551 return [err
.errorMessage
, err
.details
].join('\r\n');
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
564 sendErrorResponse(err
, req
, res
, ctx
) {
565 const _scope
= _fileScope('sendErrorResponse');
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
);
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
) });
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
) });
587 * @param {http.ClientRequest} req
588 * @param {http.ServerResponse} res
589 * @param {object} ctx
591 async
handlerMethodNotAllowed(req
, res
, ctx
) {
592 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
593 throw new ResponseError(Enum
.ErrorResponse
.MethodNotAllowed
);
598 * @param {http.ClientRequest} req
599 * @param {http.ServerResponse} res
600 * @param {object} ctx
602 async
handlerNotFound(req
, res
, ctx
) {
603 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
604 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
609 * @param {http.ClientRequest} req
610 * @param {http.ServerResponse} res
611 * @param {object} ctx
613 async
handlerBadRequest(req
, res
, ctx
) {
614 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
615 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
619 * @param {http.ClientRequest} req
620 * @param {http.ServerResponse} res
621 * @param {object} ctx
623 async
handlerInternalServerError(req
, res
, ctx
) {
624 this.setResponseType(this.responseTypes
, req
, res
, ctx
);
625 throw new ResponseError(Enum
.ErrorResponse
.InternalServerError
);
630 module
.exports
= Dingus
;