Merge branch 'v2.1-dev'
[squeep-api-dingus] / lib / dingus.js
index 343283974d7c723a6cbb8eb85fc3e3b942230fff..f060c18f00d251f610a97929fdce663c9f01de89 100644 (file)
@@ -13,37 +13,45 @@ const querystring = require('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 = /; */;
+
 class Dingus {
   /**
    * @param {Object} logger object which implements logging methods
    * @param {Object} options
-   * @param {Boolean} options.ignoreTrailingSlash 
+   * @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 {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 {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,7 +67,6 @@ class Dingus {
     ];
 
     this.logger = logger;
-    common.ensureLoggerLevels(this.logger);
   }
 
 
@@ -113,6 +120,9 @@ class Dingus {
   /**
    * Common header tagging for all requests.
    * Add our own identifier, and persist any external transit identifiers.
+   * 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 
    * @param {http.ServerResponse} res 
    * @param {object} ctx 
@@ -138,9 +148,9 @@ class Dingus {
    */
   _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];
   }
@@ -152,14 +162,14 @@ class Dingus {
    */
   _getProtocol(req) {
     // TODO: RFC7239 Forwarded support
-    const protocol = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedProto)) ||
-      ((req && req.connection && req.connection.encrypted) ? 'https' : 'http');
+    const protocol = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedProto)) ||
+      ((req?.connection?.encrypted) ? 'https' : 'http');
     return protocol.split(/\s*,\s*/u)[0];
   }
 
 
   /**
-   * 
+   * Sets ctx.clientAddress and ctx.clientProtocol.
    * @param {http.ClientRequest} req 
    * @param {http.ServerResponse} res 
    * @param {object} ctx 
@@ -171,14 +181,40 @@ class Dingus {
 
 
   /**
-   * Called before every request handler.
+   * Sets ctx.cookie from Cookie header.
    * @param {http.ClientRequest} req 
    * @param {http.ServerResponse} res 
    * @param {object} ctx 
    */
+  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) {
+          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.
+   * Sets tracking identifiers and client information on ctx.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
   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);
   }
 
 
@@ -225,11 +261,13 @@ class Dingus {
   /**
    * 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 = [];
@@ -239,8 +277,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,13 +289,14 @@ 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
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   * @returns {object}
    */
-  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;
@@ -263,26 +305,59 @@ class Dingus {
     try {
       ({ 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, '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
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   * @param {string} pathPart
+   * @returns {object}
+   */
+  _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, 'lookup failure', { error: e });
+        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
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  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, ...handlerArgs);
@@ -305,21 +380,22 @@ class Dingus {
 
 
   /**
-   * 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} rawBody
+  */
+  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);
@@ -336,13 +412,26 @@ class Dingus {
   /**
    * 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);
@@ -353,14 +442,23 @@ class Dingus {
 
   /**
    * Read and parse request body data.
+   * Sets ctx.parsedBody, and optionally ctx.rawBody.
    * @param {http.ClientRequest} req
    * @param {http.ServerResponse} res
    * @param {object} ctx
-   */
-  async ingestBody(req, res, ctx) {
-    ctx.rawBody = await this.bodyData(req);
-    const contentType = Dingus.getRequestContentType(req);
-    this.parseBody(contentType, ctx);
+   * @param {object}
+   * @param {Boolean} .parseEmptyBody
+   * @param {Boolean} .persistRawBody
+   */
+  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);
+    }
   }
 
 
@@ -388,6 +486,7 @@ 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 
@@ -409,7 +508,7 @@ class Dingus {
 
 
   /**
-   * Inserts an encoding
+   * Inserts an encoding into Content-Encoding header.
    * @param {http.ServerResponse} res
    * @param {string} encoding
    */
@@ -454,8 +553,8 @@ class Dingus {
    */
   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,7 +562,7 @@ class Dingus {
 
     const [stat, data] = await this._readFileInfo(metaFilePath);
     if (!stat) {
-      return;
+      return added;
     }
 
     const lineBreakRE = /\r\n|\n|\r/;
@@ -476,8 +575,10 @@ class Dingus {
         const result = headerParseRE.exec(line);
         const { groups: header } = result;
         res.setHeader(header.name, header.value);
+        added = true;
       }
     });
+    return added;
   }
 
 
@@ -492,7 +593,13 @@ class Dingus {
    */
   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 +607,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);
     }
@@ -526,18 +633,20 @@ class Dingus {
         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);
@@ -562,7 +671,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 });
@@ -611,14 +720,14 @@ 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);
   }