log raw handler objects, rather than deprecated wrapper
[squeep-api-dingus] / lib / dingus.js
index c8e4909bf1edca17202e1ca26a3310b9d748ffb3..4eded66a727e1d831e7249b462abb3d5da50fa17 100644 (file)
@@ -26,6 +26,8 @@ const defaultOptions = {
   proxyPrefix: '',
   strictAccept: true,
   selfBaseUrl: '',
+  staticMetadata: true,
+  staticPath: undefined, // no reasonable default
   trustProxy: true,
   querystring,
 };
@@ -38,6 +40,7 @@ class Dingus {
    * @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
    */
@@ -103,8 +106,8 @@ class Dingus {
    * @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);
   }
 
 
@@ -223,11 +226,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 = [];
@@ -237,8 +242,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);
       };
     }
@@ -257,9 +265,9 @@ class Dingus {
     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) {
@@ -272,7 +280,7 @@ class Dingus {
           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 {
@@ -283,7 +291,7 @@ class Dingus {
 
     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);
@@ -303,21 +311,27 @@ 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}
+  */
+  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);
@@ -334,12 +348,21 @@ class Dingus {
   /**
    * Return all body data from a request.
    * @param {http.ClientRequest} req
+   * @param {Number=} maximumBodySize
    */
-  async bodyData(req) {
+  async bodyData(req, maximumBodySize) {
     const _scope = _fileScope('bodyData');
     return new Promise((resolve, reject) => {
       const body = [];
-      req.on('data', (chunk) => body.push(chunk));
+      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', () => resolve(Buffer.concat(body).toString()));
       req.on('error', (e) => {
         this.logger.error(_scope, 'failed', { error: e });
@@ -354,11 +377,19 @@ class Dingus {
    * @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);
+    }
   }
 
 
@@ -444,6 +475,41 @@ class Dingus {
   }
 
 
+  /**
+   * 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.
@@ -455,7 +521,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);
@@ -524,6 +596,10 @@ class Dingus {
     // 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);
   }
@@ -573,16 +649,47 @@ class Dingus {
     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
@@ -610,17 +717,18 @@ class Dingus {
    * @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);
   }