Merge branch 'v2.1-dev'
[squeep-api-dingus] / lib / common.js
index 21070b8782918f267bf32e3adf96537df08e615a..162b751ef23805d30bbf97eb71a69f367adf3d59 100644 (file)
@@ -5,32 +5,21 @@
  * Utility and miscellaneous functions.
  */
 
-const path = require('path');
-const crypto = require('crypto');
+const crypto = require('node:crypto');
 const uuid = require('uuid');
 const Enum = require('./enum');
-
-/**
- * Return a function which combines a part of the filename with a scope, for use in logging.
- * @param {string} filename
- */
-const fileScope = (filename) => {
-  let fScope = path.basename(filename, '.js');
-  if (fScope === 'index') {
-    fScope = path.basename(path.dirname(filename));
-  }
-  return (scope) => `${fScope}:${scope}`;
-}
+const { fileScope } = require('@squeep/log-helper');
 
 /**
  * Simple ETag from data.
- * @param {string} filePath 
- * @param {object} fileStat 
- * @param {*} fileData 
+ * @param {String} filePath (currently unused)
+ * @param {fs.Stats} fileStat
+ * @param {crypto.BinaryLike} fileData content
+ * @returns {String}
  */
 const generateETag = (_filePath, fileStat, fileData) => {
   const hash = crypto.createHash('sha256');
-  if (fileStat && fileStat.mtimeMs) {
+  if (fileStat?.mtimeMs) {
     hash.update(fileStat.mtimeMs.toString());
   }
   hash.update(fileData);
@@ -39,28 +28,21 @@ const generateETag = (_filePath, fileStat, fileData) => {
 };
 
 /**
- * @param {object} obj 
- * @param {string} prop 
- * @param {*} def 
+ * Access property with default.
+ * @param {Object} obj
+ * @param {String} prop
+ * @param {*} def default value if prop does not exist for obj
+ * @return {*}
  */
 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
 
 /**
- * @param {http.ClientRequest} req 
- * @param {http.ServerResponse} res 
- * @param {object} ctx 
- */
-const handlerLogData = (req, res, ctx) => ({
-  req: requestLogData(req),
-  res: responseLogData(res),
-  ctx,
-});
-
-/**
- * 
+ * Determine whether a client has already requested a resource,
+ * based on If-Modified-Since and If-None-Match headers.
  * @param {http.ClientRequest} req 
  * @param {Number} modifiedTimeMs 
- * @param {string} eTag 
+ * @param {String} eTag 
+ * @returns {Boolean}
  */
 const isClientCached = (req, modifiedTimeMs, eTag) => {
   let clientCached = false;
@@ -93,6 +75,7 @@ const isClientCached = (req, modifiedTimeMs, eTag) => {
  * Expects only one-level deep, is not recursive!
  * @param {Object} origEnum
  * @param {Object} additionalEnum
+ * @returns {Object}
  */
 const mergeEnum = (origEnum, additionalEnum) => {
   for (const e of Object.keys(additionalEnum)) {
@@ -118,9 +101,9 @@ const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
 const _isObject = (obj) => obj && typeof obj === 'object';
 const _isArray = (obj) => Array.isArray(obj);
 /**
- * Return a new object with all objects combined.
- * @param  {...any} objects
- * @returns
+ * Return a new object with all objects combined, later properties taking precedence.
+ * @param  {...Object} objects
+ * @returns {Object}
  */
 const mergeDeep = (...objects) => {
   return objects.reduce((acc, obj) => {
@@ -144,7 +127,8 @@ const mergeDeep = (...objects) => {
 /**
  * Return a new object with selected props.
  * @param {Object} obj
- * @param {string[]} props
+ * @param {String[]} props
+ * @returns {Object}
  */
 const pick = (obj, props) => {
   const picked = {};
@@ -157,68 +141,7 @@ const pick = (obj, props) => {
 };
 
 /**
- * Return a subset of a request object, suitable for logging.
- * Obscures sensitive header values.
- * @param {http.ClientRequest} req 
- */
-const requestLogData = (req) => {
-  const data = pick(req, [
-    'method',
-    'url',
-    'httpVersion',
-    'headers',
-    'trailers',
-  ]);
-  scrubHeaderObject(data);
-  return data;
-};
-
-
-/**
- * Remove sensitive header data.
- * @param {Object} data
- * @param {Object} data.headers
- */
-const scrubHeaderObject = (data) => {
-  if (data && data.headers && 'authorization' in data.headers) {
-    data.headers = Object.assign({}, data.headers, {
-      authorization: obscureAuthorizationHeader(data.headers['authorization']),
-    });
-  }
-}
-
-
-/**
- * Hide sensitive part of an Authorization header.
- * @param {String} authHeader
- * @returns {String}
- */
-const obscureAuthorizationHeader = (authHeader) => {
-  if (!authHeader) {
-    return authHeader;
-  }
-  const space = authHeader.indexOf(' ');
-  // This blurs entire string if no space found, because -1.
-  return authHeader.slice(0, space + 1) + '*'.repeat(authHeader.length - (space + 1));
-}
-
-
-/**
- * Return a subset of a response object, suitable for logging.
- * @param {http.ServerResponse} res 
- */
-const responseLogData = (res) => {
-  const response = pick(res, [
-    'statusCode',
-    'statusMessage',
-  ]);
-  response.headers = res.getHeaders();
-  return response;
-};
-
-
-/**
- * Store updates to defaultOptions, but no new properties.
+ * Store all properties in defaultOptions on target from either options or defaultOptions.
  * @param {Object} target 
  * @param {Object} defaultOptions 
  * @param {Object} options 
@@ -228,10 +151,10 @@ const setOptions = (target, defaultOptions, options) => {
 };
 
 /**
- * Return a list of source split at first delimiter.
- * @param {string} src 
- * @param {string} delimiter 
- * @param {string} fill trailing stand-in if no delimiter in src
+ * Return a two-item list of src, split at first delimiter encountered.
+ * @param {String} src 
+ * @param {String} delimiter 
+ * @param {String} fill trailing stand-in if no delimiter in src
  */
 const splitFirst = (src, delimiter, fill) => {
   const idx = src.indexOf(delimiter);
@@ -243,43 +166,22 @@ const splitFirst = (src, delimiter, fill) => {
 };
 
 /**
- * Generate a new request identifier.
+ * Generate a new request identifier, a time/host-based uuid.
  * @returns {String}
  */
 const requestId = () => {
   return uuid.v1();
 };
 
-const nop = () => { /**/ };
-const nullLogger = {
-  error: nop,
-  warn: nop,
-  info: nop,
-  log: nop,
-  debug: nop,
-};
-
-/**
- * Populates any absent logger levels.
- * @param {Object} logger
- */
-const ensureLoggerLevels = (logger = {}) => {
-  for (const level in nullLogger) {
-    if (! (level in logger)) {
-      logger[level] = nullLogger[level];
-    }
-  }
-  return logger;
-};
-
 /**
  * Merges folded header lines
  * @param {String[]} lines
+ * @returns {String}
  */
 const unfoldHeaderLines = (lines) => {
   const foldedLineRE = /^(\t| +)(.*)$/;
   if (lines) {
-    lines.reduceRight((_, line, idx) => {
+    lines.reduceRight((_, line, idx) => { // NOSONAR
       const result = foldedLineRE.exec(line);
       if (result && idx) {
         const prevIdx = idx - 1;
@@ -292,24 +194,77 @@ const unfoldHeaderLines = (lines) => {
   return lines;
 };
 
+/**
+ * Adds a new cookie.
+ * @param {http.ServerResponse} res
+ * @param {String} name
+ * @param {String} value
+ * @param {Object=} opt
+ * @param {String=} opt.domain
+ * @param {Date=} opt.expires
+ * @param {Boolean=} opt.httpOnly
+ * @param {Number=} opt.maxAge
+ * @param {String=} opt.path
+ * @param {String=} opt.sameSite
+ * @param {Boolean=} opt.secure
+ */
+function addCookie(res, name, value, opt = {}) {
+  const options = {
+    domain: undefined,
+    expires: undefined,
+    httpOnly: false,
+    maxAge: undefined,
+    path: undefined,
+    sameSite: undefined,
+    secure: false,
+    ...opt,
+  };
+  // TODO: validate name, value
+  const cookieParts = [
+    `${name}=${value}`,
+  ];
+  if (options.domain) {
+    cookieParts.push(`Domain=${options.domain}`);
+  }
+  if (options.expires) {
+    if (!(options.expires instanceof Date)) {
+      throw new TypeError('cookie expires must be Date');
+    }
+    cookieParts.push(`Expires=${options.expires.toUTCString()}`);
+  }
+  if (options.httpOnly) {
+    cookieParts.push('HttpOnly');
+  }
+  if (options.maxAge) {
+    cookieParts.push(`Max-Age=${options.maxAge}`);
+  }
+  if (options.path) {
+    cookieParts.push(`Path=${options.path}`);
+  }
+  if (options.sameSite) {
+    if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) {
+      throw new RangeError('cookie sameSite value not valid');
+    }
+    cookieParts.push(`SameSite=${options.sameSite}`);
+  }
+  if (options.secure) {
+    cookieParts.push('Secure');
+  }
+  res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; '));
+}
+
+
 module.exports = {
-  ensureLoggerLevels,
+  addCookie,
   fileScope,
   generateETag,
   get,
-  handlerLogData,
   httpStatusCodeClass,
   isClientCached,
   mergeDeep,
   mergeEnum,
-  nop,
-  nullLogger,
-  obscureAuthorizationHeader,
   pick,
   requestId,
-  requestLogData,
-  responseLogData,
-  scrubHeaderObject,
   setOptions,
   splitFirst,
   unfoldHeaderLines,