X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fcommon.js;h=e2e2a8c640f58ba4df827cd6e7afcc5ffb69fd4b;hb=HEAD;hp=40a9f554f9ee4f687fe63402e3fed0908162408b;hpb=f944684c37532e67a7d28e34909178435cba03a5;p=squeep-api-dingus diff --git a/lib/common.js b/lib/common.js index 40a9f55..2f0aeb8 100644 --- a/lib/common.js +++ b/lib/common.js @@ -5,40 +5,26 @@ * 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'); +const { fileScope } = require('@squeep/log-helper'); /** - * @callback ScopeFn - * @param {String} scope - * @returns {String} + * @typedef {import('node:http')} http */ -/** - * Return a function which prefixes a provided scope with the most- - * relevant part of the filename, for use in logging. - * @param {String} filename - * @returns {ScopeFn} - */ -const fileScope = (filename) => { - let fScope = path.basename(filename, '.js'); - if (fScope === 'index') { - fScope = path.basename(path.dirname(filename)); - } - return (scope) => `${fScope}:${scope}`; -} /** * Simple ETag from data. - * @param {String} filePath (currently unused) - * @param {fs.Stats} fileStat + * @param {string} _filePath (currently unused) + * @param {object} fileStat node:fs.Stats object + * @param {number} fileStat.mtimeMs node:fs.Stats object * @param {crypto.BinaryLike} fileData content - * @returns {String} + * @returns {string} etag */ 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); @@ -48,32 +34,21 @@ const generateETag = (_filePath, fileStat, fileData) => { /** * Access property with default. - * @param {Object} obj - * @param {String} prop + * @param {object} obj target object + * @param {string} prop target property * @param {*} def default value if prop does not exist for obj - * @return {*} + * @returns {*} property value or default */ const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def; -/** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @deprecated after v1.2.5 (integrated into logger module) - */ -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 - * @returns {Boolean} + * @param {http.ClientRequest} req request + * @param {(string) => string} req.getHeader header accessor + * @param {number} modifiedTimeMs ms timestamp from client + * @param {string} eTag etag from client + * @returns {boolean} whether our version matches what client knows */ const isClientCached = (req, modifiedTimeMs, eTag) => { let clientCached = false; @@ -104,9 +79,9 @@ const isClientCached = (req, modifiedTimeMs, eTag) => { /** * Shallow merge for enums, to be called by derived constructor. * Expects only one-level deep, is not recursive! - * @param {Object} origEnum - * @param {Object} additionalEnum - * @returns {Object} + * @param {object} origEnum enum object to be extended + * @param {object} additionalEnum enum object to add + * @returns {object} lightly merged enum object */ const mergeEnum = (origEnum, additionalEnum) => { for (const e of Object.keys(additionalEnum)) { @@ -124,17 +99,19 @@ const mergeEnum = (origEnum, additionalEnum) => { /** * Isolate the general category of an http status code. - * @param {Number} statusCode - * @returns {Number} + * @param {number} statusCode of response + * @returns {number} status category */ 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, later properties taking precedence. - * @param {...Object} objects - * @returns {Object} + * Arrays are concated. + * @param {...object} objects to be merged onto a new object + * @returns {object} new merged object */ const mergeDeep = (...objects) => { return objects.reduce((acc, obj) => { @@ -157,9 +134,9 @@ const mergeDeep = (...objects) => { /** * Return a new object with selected props. - * @param {Object} obj - * @param {String[]} props - * @returns {Object} + * @param {object} obj source object + * @param {string[]} props list of property names + * @returns {object} object with selected properties */ const pick = (obj, props) => { const picked = {}; @@ -171,86 +148,24 @@ const pick = (obj, props) => { return picked; }; -/** - * Return a subset of a request object, suitable for logging. - * Obscures sensitive header values. - * @param {http.ClientRequest} req - * @deprecated after v1.2.5 (integrated into logger module) - */ -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 - * @deprecated after v1.2.5 (integrated into logger module) - */ -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} - * @deprecated after v1.2.5 (integrated into logger module) - */ -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 - * @deprecated after v1.2.5 (integrated into logger module) - */ -const responseLogData = (res) => { - const response = pick(res, [ - 'statusCode', - 'statusMessage', - ]); - response.headers = res.getHeaders(); - return response; -}; - - /** * Store all properties in defaultOptions on target from either options or defaultOptions. - * @param {Object} target - * @param {Object} defaultOptions - * @param {Object} options + * @param {object} target object to populate + * @param {object} defaultOptions object with default property values + * @param {object} options object with potential overrides for defaults + * @returns {object} object with properties */ const setOptions = (target, defaultOptions, options) => { Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions))); + return target; }; /** * 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 + * @param {string} src source + * @param {string} delimiter delimiter + * @param {string} fill trailing stand-in if no delimiter in src + * @returns {string[]} [before-first-delimiter, rest-or-fill] */ const splitFirst = (src, delimiter, fill) => { const idx = src.indexOf(delimiter); @@ -263,51 +178,21 @@ const splitFirst = (src, delimiter, fill) => { /** * Generate a new request identifier, a time/host-based uuid. - * @returns {String} + * @returns {string} uuid */ const requestId = () => { return uuid.v1(); }; -/** - * Do nothing. - */ -const nop = () => { /**/ }; - -/** - * A logger object which does nothing. - */ -const nullLogger = { - error: nop, - warn: nop, - info: nop, - log: nop, - debug: nop, -}; - -/** - * Populates any absent logger level functions on a logger object. - * @param {Object} logger - * @returns {Object} - */ -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} + * @param {string[]} lines header lines + * @returns {string} unfolded header 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; @@ -320,24 +205,129 @@ const unfoldHeaderLines = (lines) => { return lines; }; +const validTokenRE = /^[!#$%&'*+-.0-9A-Z^_`a-z~]+$/; +const validValueRE = /^[!#$%&'()*+-./0-9:<=>?@A-Z[\]^_`a-z{|}~]*$/; +const validPathRE = /^[ !"#$%&'()*+,-./0-9:<=>?@A-Z[\\\]^_`a-z{|}~]*$/; +const validLabelRE = /^[a-zA-Z0-9-]+$/; +const invalidLabelRE = /--|^-|-$/; + +/** + * Adds a new set-cookie header value to response, with supplied data. + * @param {http.ServerResponse} res response + * @param {(string, string) => void} res.appendHeader sets header values + * @param {string} name cookie name + * @param {string} value cookie value + * @param {object=} opt cookie options + * @param {string=} opt.domain cookie domain + * @param {Date=} opt.expires cookie expiration + * @param {boolean=} opt.httpOnly cookie client visibility + * @param {number=} opt.maxAge cookie lifetime + * @param {string=} opt.path cookie path + * @param {string=} opt.sameSite cookie sharing + * @param {boolean=} opt.secure cookie security + * @param {string[]=} opt.extension cookie extension attribute values + */ +function addCookie(res, name, value, opt = {}) { + const options = { + domain: undefined, + expires: undefined, + httpOnly: false, + maxAge: undefined, + path: undefined, + sameSite: undefined, + secure: false, + extension: [], + ...opt, + }; + + if (!validTokenRE.test(name)) { + throw new RangeError('invalid cookie name'); + } + + if (value.startsWith('"') && value.endsWith('"')) { + if (!validValueRE.test(value.slice(1, value.length - 1))) { + throw new RangeError('invalid cookie value'); + }; + } else if (!validValueRE.test(value)) { + throw new RangeError('invalid cookie value'); + } + + const cookieParts = [ + `${name}=${value}`, + ]; + + if (options.domain) { + for (const label of options.domain.split('.')) { + if (!validLabelRE.test(label) || invalidLabelRE.test(label)) { + throw new RangeError('invalid cookie 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) { + if (!validPathRE.test(options.path)) { + throw new RangeError('cookie path value not valid'); + } + cookieParts.push(`Path=${options.path}`); + } + + if (options.sameSite) { + if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) { + throw new RangeError('cookie sameSite value not valid'); + } + if (options.sameSite === 'None' + && !options.secure) { + throw new RangeError('cookie with sameSite None must also be secure'); + } + cookieParts.push(`SameSite=${options.sameSite}`); + } + + if (options.secure) { + cookieParts.push('Secure'); + } + + if (!Array.isArray(options.extension)) { + throw new TypeError('cookie extension must be Array'); + } + for (const extension of options.extension) { + if (!validPathRE.test(extension)) { + throw new RangeError('cookie extension value not valid'); + } + cookieParts.push(extension); + } + + 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,