X-Git-Url: https://git.squeep.com/?a=blobdiff_plain;f=lib%2Fdingus.js;h=f060c18f00d251f610a97929fdce663c9f01de89;hb=HEAD;hp=0143585c1d83732a9e064d2f8687596af88f42be;hpb=110a5fe8f19b564e76162de567a6bffe2a6285d1;p=squeep-api-dingus diff --git a/lib/dingus.js b/lib/dingus.js index 0143585..c53e469 100644 --- a/lib/dingus.js +++ b/lib/dingus.js @@ -1,4 +1,3 @@ -/* eslint-disable security/detect-object-injection */ 'use strict'; /** @@ -6,44 +5,59 @@ * Just a self-contained router and some request glue. */ +/** + * @typedef {import('node:http')} http + */ + require('./patches'); -const { promises: fsPromises } = require('fs'); -const path = require('path'); -const querystring = require('querystring'); +const { promises: fsPromises } = require('node:fs'); +const path = require('node:path'); +const querystring = require('node:querystring'); const common = require('./common'); const ContentNegotiation = require('./content-negotiation'); const Enum = require('./enum'); -const { DingusError, ResponseError } = require('./errors'); +const { ResponseError, RouterNoPathError, RouterNoMethodError } = require('./errors'); const { extensionToMime } = require('./mime-helper'); const Router = require('./router'); const Template = require('./template'); // For logging. -const _fileScope = common.fileScope(__filename); +const { fileScope } = require('@squeep/log-helper'); +const _fileScope = fileScope(__filename); const defaultOptions = { - ignoreTrailingSlash: false, + ignoreTrailingSlash: true, proxyPrefix: '', strictAccept: true, selfBaseUrl: '', staticMetadata: true, + staticPath: undefined, // No reasonable default trustProxy: true, + intrinsicHeadMethod: true, + intrinsicHeadPersistBody: false, querystring, }; +const cookieSplitRE = /\s*;\s*/; +const headerParseRE = /^(?[^:]+): +(?.*)$/; +const lineBreakRE = /\r\n|\n|\r/; +const proxySplitRE = /\s*,\s*/u; + class Dingus { /** - * @param {Object} logger object which implements logging methods - * @param {Object} options - * @param {Boolean} options.ignoreTrailingSlash + * @param {object} logger console-like object which implements logging methods + * @param {object} options options + * @param {boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route * @param {string} options.proxyPrefix leading part of url path to strip - * @param {Boolean} options.strictAccept whether to error on unsupported Accept type + * @param {boolean} options.strictAccept whether to error on unsupported Accept type * @param {string} options.selfBaseUrl for constructing links - * @param {Boolean} options.staticMetadata serve static headers with static files - * @param {Boolean} options.trustProxy trust some header data to be provided by proxy - * @param {Object} options.querystring alternate qs parser to use + * @param {boolean} options.staticMetadata serve static headers with static files + * @param {boolean} options.trustProxy trust some header data to be provided by proxy + * @param {boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method + * @param {boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests + * @param {object} options.querystring alternate qs parser to use */ - constructor(logger = common.nullLogger, options = {}) { + constructor(logger = console, options = {}) { common.setOptions(this, defaultOptions, options); this.router = new Router(options); @@ -59,13 +73,13 @@ class Dingus { ]; this.logger = logger; - common.ensureLoggerLevels(this.logger); } /** * Resolve relative and empty paths in url * @param {string} p path + * @returns {string} normalized path */ _normalizePath(p) { const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy... @@ -77,6 +91,7 @@ class Dingus { * Remove a leading portion of url path * N.B. This method gets obliterated if there is no prefix defined at construction * @param {string} p path + * @returns {string} de-prefixed path */ _stripPrefix(p) { if (p.startsWith(this.proxyPrefix)) { @@ -88,7 +103,8 @@ class Dingus { /** * Returns the path part, and querystring object, from a request url. - * @param {string} url + * @param {string} url url + * @returns {object} path string and query params */ _splitUrl(url) { const [ p, qs ] = common.splitFirst(url, '?'); @@ -101,21 +117,26 @@ class Dingus { /** * Insert a new path handler - * @param {string} method - * @param {string} urlPath - * @param {fn} handler + * @param {string|string[]} methods methods to match for this path, '*' allowed + * @param {string} urlPath request path to match + * @param {Function} handler handler to invoke on match + * @param {any[]} handlerArgs additional arguments for handler */ - on(method, urlPath, handler) { - this.router.on(method, urlPath, handler); + on(methods, urlPath, handler, ...handlerArgs) { + this.router.on(methods, urlPath, handler, handlerArgs); } /** * Common header tagging for all requests. * Add our own identifier, and persist any external transit identifiers. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * Sets requestId on ctx to a new uuid. + * If X-Request-Id or X-Correlation-Id exist on incoming headers, sets them + * on outgoing headers and sets on ctx. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @returns {string} id added request */ static tagContext(req, res, ctx) { const requestId = common.requestId(); @@ -134,35 +155,37 @@ class Dingus { /** * - * @param {http.ClientRequest} req + * @param {http.ClientRequest} req request + * @returns {string} first forwarded header ip */ _getAddress(req) { // TODO: RFC7239 Forwarded support - const address = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedFor)) || - (this.trustProxy && req && req.getHeader(Enum.Header.XRealIP)) || - (req && req.connection && req.connection.remoteAddress) || + const address = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedFor)) || + (this.trustProxy && req?.getHeader(Enum.Header.XRealIP)) || + (req?.connection?.remoteAddress) || ''; - return address.split(/\s*,\s*/u)[0]; + return address.split(proxySplitRE)[0]; } /** * - * @param {http.ClientRequest} req + * @param {http.ClientRequest} req request + * @returns {string} first forwarded header protocol */ _getProtocol(req) { // TODO: RFC7239 Forwarded support - const protocol = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedProto)) || - ((req && req.connection && req.connection.encrypted) ? 'https' : 'http'); - return protocol.split(/\s*,\s*/u)[0]; + const protocol = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedProto)) || + ((req?.connection?.encrypted) ? 'https' : 'http'); + return protocol.split(proxySplitRE)[0]; } /** - * - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * Sets ctx.clientAddress and ctx.clientProtocol. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ clientAddressContext(req, res, ctx) { ctx.clientAddress = this._getAddress(req); @@ -170,23 +193,49 @@ class Dingus { } + /** + * Sets ctx.cookie from Cookie header. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + */ + static ingestCookie(req, res, ctx) { + ctx.cookie = {}; + req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => { + const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => { + try { + return decodeURIComponent(x.trim()); + } catch (e) { // eslint-disable-line no-unused-vars + return x; + } + }); + if (name && !(name in ctx.cookie)) { + const isQuoted = value?.startsWith('"') && value.endsWith('"'); + ctx.cookie[name] = isQuoted ? value.slice(1, -1) : value; // eslint-disable-line security/detect-object-injection + } + }); + } + + /** * Called before every request handler. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * Sets tracking identifiers and client information on ctx. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async preHandler(req, res, ctx) { - Dingus.tagContext(req, res, ctx); + this.constructor.tagContext(req, res, ctx); this.clientAddressContext(req, res, ctx); + this.constructor.ingestCookie(req, res, ctx); } /** * Helper for collecting chunks as array of buffers. - * @param {Buffer[]} chunks - * @param {string|Buffer} chunk - * @param {string} encoding + * @param {Buffer[]} chunks array to collect chunks in + * @param {string|Buffer} chunk chunk to collect + * @param {string} encoding chunk encoding */ static pushBufChunk(chunks, chunk, encoding = 'utf8') { if (chunk) { @@ -200,9 +249,9 @@ class Dingus { /** * Sets ctx.responseBody and calls handler upon res.end(). - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context * @param {*} handler fn(req, res, ctx) */ static setEndBodyHandler(req, res, ctx, handler) { @@ -225,11 +274,13 @@ class Dingus { /** * Intercept writes for head requests, do not send to client, * but send length, and make body available in context. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {boolean} persistResponseBody store body buffer on context */ - static setHeadHandler(req, res, ctx) { + static setHeadHandler(req, res, ctx, persistResponseBody = false) { if (req.method === 'HEAD') { const origEnd = res.end.bind(res); const chunks = []; @@ -239,8 +290,11 @@ class Dingus { }; res.end = function (data, encoding, ...rest) { Dingus.pushBufChunk(chunks, data, encoding); - ctx.responseBody = Buffer.concat(chunks); - res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(ctx.responseBody)); + const responseBody = Buffer.concat(chunks); + res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(responseBody)); + if (persistResponseBody) { + ctx.responseBody = responseBody; + } return origEnd(undefined, encoding, ...rest); }; } @@ -248,44 +302,79 @@ class Dingus { /** - * Dispatch the handler for a request - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * Resolve the handler to invoke for a request. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @returns {object} handler and args */ - async dispatch(req, res, ctx = {}) { - const _scope = _fileScope('dispatch'); + _determineHandler(req, res, ctx) { + const _scope = _fileScope('_determineHandler'); const { pathPart, queryParams } = this._splitUrl(req.url); ctx.queryParams = queryParams; - let handler; + let handler, handlerArgs = []; try { - handler = this.router.lookup(req.method, pathPart, ctx); + ({ handler, handlerArgs } = this.router.lookup(req.method, pathPart, ctx)); } catch (e) { - if (e instanceof DingusError) { - switch (e.message) { - case 'NoPath': - handler = this.handlerNotFound.bind(this); - break; - case 'NoMethod': - handler = this.handlerMethodNotAllowed.bind(this); - break; - default: - this.logger.error(_scope, 'unknown dingus error', { error: e }); - handler = this.handlerInternalServerError.bind(this); - } - } else if (e instanceof URIError) { + if (e instanceof URIError) { handler = this.handlerBadRequest.bind(this); + } else if (e instanceof RouterNoPathError) { + handler = this.handlerNotFound.bind(this); + } else if (e instanceof RouterNoMethodError) { + if (this.intrinsicHeadMethod && req.method === 'HEAD') { + ({ handler, handlerArgs } = this._determineHeadHandler(req, res, ctx, pathPart)); + } else { + handler = this.handlerMethodNotAllowed.bind(this); + } } else { - this.logger.error(_scope, 'lookup failure', { error: e }); + this.logger.error(_scope, 'unexpected error', { error: e }); handler = this.handlerInternalServerError.bind(this); } } + return { handler, handlerArgs }; + } + + /** + * For intrinsic HEAD requests, resolve the handler to invoke. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} pathPart router path + * @returns {object} hander and handler args + */ + _determineHeadHandler(req, res, ctx, pathPart) { + const _scope = _fileScope('_determineHeadHandler'); + let handler, handlerArgs = []; + try { + ({ handler, handlerArgs } = this.router.lookup('GET', pathPart, ctx)); + Dingus.setHeadHandler(req, res, ctx, this.intrinsicHeadPersistBody); + } catch (e) { + if (e instanceof RouterNoMethodError) { + handler = this.handlerMethodNotAllowed.bind(this); + } else { + this.logger.error(_scope, 'unexpected error', { error: e }); + handler = this.handlerInternalServerError.bind(this); + } + } + return { handler, handlerArgs }; + } + + + /** + * Dispatch the handler for a request + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @returns {Promise} handler promise + */ + async dispatch(req, res, ctx = {}) { + const { handler, handlerArgs } = this._determineHandler(req, res, ctx); try { await this.preHandler(req, res, ctx); - return await handler(req, res, ctx); + return await handler(req, res, ctx, ...handlerArgs); } catch (e) { ctx.error = e; this.sendErrorResponse(e, req, res, ctx); @@ -295,8 +384,8 @@ class Dingus { /** * Return normalized type, without any parameters. - * @param {http.ClientRequest} req - * @returns {string} + * @param {http.ClientRequest} req request + * @returns {string} bare content type */ static getRequestContentType(req) { const contentType = req.getHeader(Enum.Header.ContentType); @@ -305,21 +394,22 @@ class Dingus { /** - * Parse rawBody from ctx as contentType into parsedBody. - * @param {string} contentType - * @param {object} ctx + * Parse rawBody as contentType into ctx.parsedBody. + * @param {string} contentType type + * @param {object} ctx context + * @param {string|Buffer} rawBody body data */ - parseBody(contentType, ctx) { + parseBody(contentType, ctx, rawBody) { const _scope = _fileScope('parseBody'); switch (contentType) { case Enum.ContentType.ApplicationForm: - ctx.parsedBody = this.querystring.parse(ctx.rawBody); + ctx.parsedBody = this.querystring.parse(rawBody); break; case Enum.ContentType.ApplicationJson: try { - ctx.parsedBody = JSON.parse(ctx.rawBody); + ctx.parsedBody = JSON.parse(rawBody); } catch (e) { this.logger.debug(_scope, 'JSON parse failed', { requestId: ctx.requestId, error: e }); throw new ResponseError(Enum.ErrorResponse.BadRequest, e.message); @@ -335,14 +425,28 @@ class Dingus { /** * Return all body data from a request. - * @param {http.ClientRequest} req + * @param {http.ClientRequest} req request + * @param {number=} maximumBodySize bytes + * @param {boolean=} toString whether to convert body data buffer to string + * @returns {Promise} body data */ - async bodyData(req) { + async bodyData(req, maximumBodySize, toString = true) { const _scope = _fileScope('bodyData'); return new Promise((resolve, reject) => { const body = []; - req.on('data', (chunk) => body.push(chunk)); - req.on('end', () => resolve(Buffer.concat(body).toString())); + let length = 0; + req.on('data', (chunk) => { + body.push(chunk); + length += Buffer.byteLength(chunk); + if (maximumBodySize && length > maximumBodySize) { + this.logger.debug(_scope, 'body data exceeded limit', { length, maximumBodySize }); + reject(new ResponseError(Enum.ErrorResponse.RequestEntityTooLarge)); + } + }); + req.on('end', () => { + const bodyBuffer = Buffer.concat(body); + resolve(toString ? bodyBuffer.toString() : bodyBuffer); + }); req.on('error', (e) => { this.logger.error(_scope, 'failed', { error: e }); reject(e); @@ -353,21 +457,32 @@ class Dingus { /** * Read and parse request body data. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * Sets ctx.parsedBody, and optionally ctx.rawBody. + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {object} options options + * @param {boolean} options.parseEmptyBody attempt to parse an empty body, results may vary by encoding + * @param {boolean} options.persistRawBody store raw body buffer on context + * @param {number=} options.maximumBodySize bytes */ - async ingestBody(req, res, ctx) { - ctx.rawBody = await this.bodyData(req); - const contentType = Dingus.getRequestContentType(req); - this.parseBody(contentType, ctx); + async ingestBody(req, res, ctx, { parseEmptyBody = true, persistRawBody = false, maximumBodySize } = {}) { + const rawBody = await this.bodyData(req, maximumBodySize); + if (persistRawBody) { + ctx.rawBody = rawBody; + } + if (rawBody || parseEmptyBody) { + const contentType = Dingus.getRequestContentType(req); + this.parseBody(contentType, ctx, rawBody); + } } /** * Return the best matching response type. - * @param {string[]} responseTypes - * @param {http.ClientRequest} req + * @param {string[]} responseTypes available content types + * @param {http.ClientRequest} req request + * @returns {string} preferred content type */ static getResponseContentType(responseTypes, req) { const acceptHeader = req.getHeader(Enum.Header.Accept); @@ -377,8 +492,9 @@ class Dingus { /** * Returns a list of the most-preferred content encodings for the response. - * @param {string[]} responseEncodings - * @param {http.ClientRequest} req + * @param {string[]} responseEncodings available encodings + * @param {http.ClientRequest} req request + * @returns {string} preferred encoding */ static getResponseEncoding(responseEncodings, req) { const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding); @@ -388,10 +504,11 @@ class Dingus { /** * Set the best content type for the response. + * Sets ctx.responseType, and Content-Type header. * @param {string[]} responseTypes default first - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ setResponseType(responseTypes, req, res, ctx) { const _scope = _fileScope('setResponseType'); @@ -409,9 +526,9 @@ class Dingus { /** - * Inserts an encoding - * @param {http.ServerResponse} res - * @param {string} encoding + * Inserts an encoding into Content-Encoding header. + * @param {http.ServerResponse} res response + * @param {string} encoding encoding */ static addEncodingHeader(res, encoding) { const existingEncodings = res.getHeader(Enum.Header.ContentEncoding); @@ -424,7 +541,8 @@ class Dingus { /** * Attempt to fetch both data and metadata for a file. - * @param {string} filePath + * @param {string} filePath path to file + * @returns {Promise<[object=, object=]>} array with file contents and stats */ async _readFileInfo(filePath) { const _scope = _fileScope('_readFileInfo'); @@ -448,14 +566,15 @@ class Dingus { /** * Potentially add additional headers from static file meta-file. - * @param {http.ServerResponse} res - * @param {string} directory + * @param {http.ServerResponse} res response + * @param {string} directory path to file * @param {string} fileName - already normalized and filtered + * @returns {boolean} if meta headers were added */ async _serveFileMetaHeaders(res, directory, fileName) { const _scope = _fileScope('_serveFileMetaHeaders'); - this.logger.debug(_scope, 'called', { directory, fileName }); + let added = false; const metaPrefix = '.'; const metaSuffix = '.meta'; const metaFileName = `${metaPrefix}${fileName}${metaSuffix}`; @@ -463,36 +582,43 @@ class Dingus { const [stat, data] = await this._readFileInfo(metaFilePath); if (!stat) { - return; + return added; } - const lineBreakRE = /\r\n|\n|\r/; const lines = data.toString().split(lineBreakRE); common.unfoldHeaderLines(lines); - const headerParseRE = /^(?[^:]+): +(?.*)$/; lines.forEach((line) => { if (line) { const result = headerParseRE.exec(line); const { groups: header } = result; res.setHeader(header.name, header.value); + added = true; } }); + return added; } /** * Serve a file from a directory, with rudimentary cache awareness. * This will also serve pre-encoded variations if available and requested. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx - * @param {string} directory - * @param {string} fileName + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} directory path to file + * @param {string} fileName file name + * @returns {Promise=} serves file or renders status code */ async serveFile(req, res, ctx, directory, fileName) { const _scope = _fileScope('serveFile'); - this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + this.logger.debug(_scope, 'called', { req, ctx }); + + // Require a directory field. + if (!directory) { + this.logger.debug(_scope, 'rejected unset directory', { fileName }); + return this.handlerNotFound(req, res, ctx); + } // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present. fileName = path.normalize(fileName); @@ -500,7 +626,7 @@ class Dingus { // We will not deal with any subdirs, nor any dot-files. // (Note that we could not deal with subdirs even if we wanted, due to simple router matching scheme.) if (fileName.indexOf(path.sep) >= 0 - || fileName.charAt(0) === '.') { + || fileName.startsWith('.')) { this.logger.debug(_scope, 'rejected filename', { fileName }); return this.handlerNotFound(req, res, ctx); } @@ -525,19 +651,21 @@ class Dingus { if (encoding === Enum.EncodingType.Identity) { break; } - const suffix = Enum.EncodingTypeSuffix[encoding]; - if (suffix) { - const encodedFilePath = `${filePath}${suffix}`; - const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath); - if (encodedStat) { - ([ stat, data ] = [ encodedStat, encodedData ]); - ctx.selectedEncoding = encoding; - Dingus.addEncodingHeader(res, encoding); - res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding); - this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath }); - } - break; + const suffix = Enum.EncodingTypeSuffix[encoding]; // eslint-disable-line security/detect-object-injection + if (!suffix) { + this.logger.error(_scope, 'supported encoding missing mapped suffix', { ctx, encoding }); + continue; } + const encodedFilePath = `${filePath}${suffix}`; + const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath); + if (encodedStat) { + ([ stat, data ] = [ encodedStat, encodedData ]); + ctx.selectedEncoding = encoding; + Dingus.addEncodingHeader(res, encoding); + res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding); + this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath }); + } + break; } const lastModifiedDate = new Date(stat.mtimeMs); @@ -562,7 +690,7 @@ class Dingus { res.setHeader(Enum.Header.CacheControl, 'public'); if (this.staticMetadata) { - await this._serveFileMetaHeaders(res, directory, fileName); + ctx.metaHeaders = await this._serveFileMetaHeaders(res, directory, fileName); } this.logger.debug(_scope, 'serving file', { filePath, contentType }); @@ -572,11 +700,12 @@ class Dingus { /** * Return a content-type appropriate rendering of an errorResponse object. - * @param {string} type content-type of response + * @param {string} contentType content-type of response * @param {object} err either an Error object, or an error response - * @param {number} err.statusCode - * @param {string} err.errorMessage - * @param {string|string[]} err.details + * @param {number} err.statusCode htttp status code + * @param {string} err.errorMessage http status message + * @param {string|string[]} err.details additional details to include + * @returns {string} rendered err */ // eslint-disable-next-line class-methods-use-this renderError(contentType, err) { @@ -598,9 +727,9 @@ class Dingus { * Send an error response. Terminal. * Logs any non-error-response errors as such. * @param {object} err either an Error object, or an error response - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ sendErrorResponse(err, req, res, ctx) { const _scope = _fileScope('sendErrorResponse'); @@ -611,23 +740,54 @@ class Dingus { res.setHeader(Enum.Header.ContentType, Enum.ContentType.TextPlain); } - if (err && err.statusCode) { + if (err?.statusCode) { res.statusCode = err.statusCode; body = this.renderError(res.getHeader(Enum.Header.ContentType), err); - this.logger.debug(_scope, 'handler error', { err, ...common.handlerLogData(req, res, ctx) }); + this.logger.debug(_scope, 'handler error', { err, req, res, ctx }); } else { res.statusCode = 500; body = this.renderError(res.getHeader(Enum.Header.ContentType), Enum.ErrorResponse.InternalServerError); - this.logger.error(_scope, 'handler exception', { err, ...common.handlerLogData(req, res, ctx) }); + this.logger.error(_scope, 'handler exception', { err, req, res, ctx }); } res.end(body); } /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} file - override ctx.params.file + */ + async handlerGetStaticFile(req, res, ctx, file) { + Dingus.setHeadHandler(req, res, ctx); + + // Set a default response type to handle any errors; will be re-set to serve actual static content type. + this.setResponseType(this.responseTypes, req, res, ctx); + + await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file); + } + + + /** + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} newPath url to redirect to + * @param {number=} statusCode status code to use for redirect, default 307 + */ + async handlerRedirect(req, res, ctx, newPath, statusCode = 307) { + this.setResponseType(this.responseTypes, req, res, ctx); + res.setHeader(Enum.Header.Location, newPath); + res.statusCode = statusCode; + res.end(); + } + + + /** + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async handlerMethodNotAllowed(req, res, ctx) { this.setResponseType(this.responseTypes, req, res, ctx); @@ -636,9 +796,9 @@ class Dingus { /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async handlerNotFound(req, res, ctx) { this.setResponseType(this.responseTypes, req, res, ctx); @@ -647,9 +807,9 @@ class Dingus { /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async handlerBadRequest(req, res, ctx) { this.setResponseType(this.responseTypes, req, res, ctx); @@ -658,9 +818,9 @@ class Dingus { /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async handlerInternalServerError(req, res, ctx) { this.setResponseType(this.responseTypes, req, res, ctx);