proxyPrefix: '',
strictAccept: true,
selfBaseUrl: '',
+ staticMetadata: true,
+ staticPath: undefined, // No reasonable default
trustProxy: true,
querystring,
};
* @param {string} options.proxyPrefix leading part of url path to strip
* @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 {string} urlPath
* @param {fn} handler
*/
- on(method, urlPath, handler) {
- this.router.on(method, urlPath, handler);
+ on(method, urlPath, handler, ...handlerArgs) {
+ this.router.on(method, urlPath, handler, handlerArgs);
}
/**
* Intercept writes for head requests, do not send to client,
* but send length, and make body available in context.
+ * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging.
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
* @param {object} ctx
+ * @param {Boolean} persistResponseBody
*/
- static setHeadHandler(req, res, ctx) {
+ static setHeadHandler(req, res, ctx, persistResponseBody = false) {
if (req.method === 'HEAD') {
const origEnd = res.end.bind(res);
const chunks = [];
};
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);
};
}
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) {
default:
this.logger.error(_scope, 'unknown dingus error', { error: e });
handler = this.handlerInternalServerError.bind(this);
- }
+ }
} else if (e instanceof URIError) {
handler = this.handlerBadRequest.bind(this);
} else {
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);
/**
- * Parse rawBody from ctx as contentType into parsedBody.
- * @param {string} contentType
- * @param {object} ctx
- */
- parseBody(contentType, ctx) {
+ * Parse rawBody as contentType into ctx.parsedBody.
+ * @param {string} contentType
+ * @param {object} ctx
+ * @param {string|buffer}
+ */
+ parseBody(contentType, ctx, rawBody) {
const _scope = _fileScope('parseBody');
+ if (!rawBody) {
+ // 1.2.4 and earlier expected rawBody on context
+ rawBody = ctx.rawBody;
+ }
+
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);
/**
* Return all body data from a request.
* @param {http.ClientRequest} req
+ * @param {Number=} maximumBodySize
+ * @param {Boolean=} toString
*/
- 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);
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
* @param {object} ctx
+ * @param {object}
+ * @param {Boolean} .parseEmptyBody
+ * @param {Boolean} .persistRawBody
*/
- 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);
+ }
}
}
+ /**
+ * Potentially add additional headers from static file meta-file.
+ * @param {http.ServerResponse} res
+ * @param {string} directory
+ * @param {string} fileName - already normalized and filtered
+ */
+ async _serveFileMetaHeaders(res, directory, fileName) {
+ const _scope = _fileScope('_serveFileMetaHeaders');
+ this.logger.debug(_scope, 'called', { directory, fileName });
+
+ const metaPrefix = '.';
+ const metaSuffix = '.meta';
+ const metaFileName = `${metaPrefix}${fileName}${metaSuffix}`;
+ const metaFilePath = path.join(directory, metaFileName);
+
+ const [stat, data] = await this._readFileInfo(metaFilePath);
+ if (!stat) {
+ return;
+ }
+
+ const lineBreakRE = /\r\n|\n|\r/;
+ const lines = data.toString().split(lineBreakRE);
+ common.unfoldHeaderLines(lines);
+
+ const headerParseRE = /^(?<name>[^:]+): +(?<value>.*)$/;
+ lines.forEach((line) => {
+ if (line) {
+ const result = headerParseRE.exec(line);
+ const { groups: header } = result;
+ res.setHeader(header.name, header.value);
+ }
+ });
+ }
+
+
/**
* Serve a file from a directory, with rudimentary cache awareness.
* This will also serve pre-encoded variations if available and requested.
*/
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);
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;
+ 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);
// We presume static files are relatively cacheable.
res.setHeader(Enum.Header.CacheControl, 'public');
+ if (this.staticMetadata) {
+ await this._serveFileMetaHeaders(res, directory, fileName);
+ }
+
this.logger.debug(_scope, 'serving file', { filePath, contentType });
res.end(data);
}
if (err && 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 {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
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ * @param {String} newPath
+ * @param {Number} statusCode
+ */
+ 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
* @param {http.ServerResponse} res
* @param {http.ServerResponse} res
* @param {object} ctx
*/
- async handlerBadRequest(req, res, ctx) {
+ async handlerBadRequest(req, res, ctx) {
this.setResponseType(this.responseTypes, req, res, ctx);
throw new ResponseError(Enum.ErrorResponse.BadRequest);
}
+
/**
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
* @param {object} ctx
*/
- async handlerInternalServerError(req, res, ctx) {
+ async handlerInternalServerError(req, res, ctx) {
this.setResponseType(this.responseTypes, req, res, ctx);
throw new ResponseError(Enum.ErrorResponse.InternalServerError);
}