1 /* eslint-disable security/detect-object-injection */
5 * Utility and miscellaneous functions.
8 const crypto
= require('node:crypto');
9 const uuid
= require('uuid');
10 const Enum
= require('./enum');
11 const { fileScope
} = require('@squeep/log-helper');
14 * @typedef {import('node:http')} http
18 * Simple ETag from data.
19 * @param {string} _filePath (currently unused)
20 * @param {object} fileStat node:fs.Stats object
21 * @param {number} fileStat.mtimeMs node:fs.Stats object
22 * @param {crypto.BinaryLike} fileData content
23 * @returns {string} etag
25 const generateETag
= (_filePath
, fileStat
, fileData
) => {
26 const hash
= crypto
.createHash('sha256');
27 if (fileStat
?.mtimeMs
) {
28 hash
.update(fileStat
.mtimeMs
.toString());
30 hash
.update(fileData
);
31 const digest
= hash
.digest('base64').replace('=', '');
36 * Access property with default.
37 * @param {object} obj target object
38 * @param {string} prop target property
39 * @param {*} def default value if prop does not exist for obj
40 * @returns {*} property value or default
42 const get = (obj
, prop
, def
) => obj
&& prop
&& (prop
in obj
) ? obj
[prop
] : def
;
45 * Determine whether a client has already requested a resource,
46 * based on If-Modified-Since and If-None-Match headers.
47 * @param {http.ClientRequest} req request
48 * @param {(string) => string} req.getHeader header accessor
49 * @param {number} modifiedTimeMs ms timestamp from client
50 * @param {string} eTag etag from client
51 * @returns {boolean} whether our version matches what client knows
53 const isClientCached
= (req
, modifiedTimeMs
, eTag
) => {
54 let clientCached
= false;
56 const ifModifiedSince
= req
.getHeader(Enum
.Header
.IfModifiedSince
);
57 if (ifModifiedSince
) {
58 const ifModifiedSinceMs
= Date
.parse(ifModifiedSince
);
59 if (modifiedTimeMs
< ifModifiedSinceMs
) {
64 const ifNoneMatch
= req
.getHeader(Enum
.Header
.IfNoneMatch
);
66 const matches
= ifNoneMatch
.split(',').map((m
) => m
.trim());
67 if (matches
.includes(eTag
)
68 || (ifNoneMatch
=== '*' && eTag
)) {
71 // If if-none-matched header is present, it takes precedence over modified-since.
80 * Shallow merge for enums, to be called by derived constructor.
81 * Expects only one-level deep, is not recursive!
82 * @param {object} origEnum enum object to be extended
83 * @param {object} additionalEnum enum object to add
84 * @returns {object} lightly merged enum object
86 const mergeEnum
= (origEnum
, additionalEnum
) => {
87 for (const e
of Object
.keys(additionalEnum
)) {
88 if (typeof additionalEnum
[e
] === 'object') {
89 if (! (e
in origEnum
)) {
92 Object
.assign(origEnum
[e
], additionalEnum
[e
]);
94 origEnum
[e
] = additionalEnum
[e
];
101 * Isolate the general category of an http status code.
102 * @param {number} statusCode of response
103 * @returns {number} status category
105 const httpStatusCodeClass
= (statusCode
) => Math
.floor(statusCode
/ 100);
107 const _isObject
= (obj
) => obj
&& typeof obj
=== 'object';
108 const _isArray
= (obj
) => Array
.isArray(obj
);
111 * Return a new object with all objects combined, later properties taking precedence.
112 * Arrays are concated.
113 * @param {...object} objects to be merged onto a new object
114 * @returns {object} new merged object
116 const mergeDeep
= (...objects
) => {
117 return objects
.reduce((acc
, obj
) => {
118 const objectProperties
= [...Object
.getOwnPropertyNames(obj
), ...Object
.getOwnPropertySymbols(obj
)];
119 objectProperties
.forEach((k
) => {
122 if (_isArray(oVal
)) {
123 acc
[k
] = (_isArray(aVal
) ? aVal : []).concat(oVal
);
124 } else if (_isObject(oVal
)) {
125 acc
[k
] = mergeDeep(_isObject(aVal
) ? aVal : {}, oVal
);
136 * Return a new object with selected props.
137 * @param {object} obj source object
138 * @param {string[]} props list of property names
139 * @returns {object} object with selected properties
141 const pick
= (obj
, props
) => {
143 props
.forEach((prop
) => {
145 picked
[prop
] = obj
[prop
];
152 * Store all properties in defaultOptions on target from either options or defaultOptions.
153 * @param {object} target object to populate
154 * @param {object} defaultOptions object with default property values
155 * @param {object} options object with potential overrides for defaults
156 * @returns {object} object with properties
158 const setOptions
= (target
, defaultOptions
, options
) => {
159 Object
.assign(target
, defaultOptions
, pick(options
, Object
.keys(defaultOptions
)));
164 * Return a two-item list of src, split at first delimiter encountered.
165 * @param {string} src source
166 * @param {string} delimiter delimiter
167 * @param {string} fill trailing stand-in if no delimiter in src
168 * @returns {string[]} [before-first-delimiter, rest-or-fill]
170 const splitFirst
= (src
, delimiter
, fill
) => {
171 const idx
= src
.indexOf(delimiter
);
173 return [ src
.slice(0, idx
), src
.slice(idx
+ 1) ];
175 return [ src
, fill
];
180 * Generate a new request identifier, a time/host-based uuid.
181 * @returns {string} uuid
183 const requestId
= () => {
188 * Merges folded header lines
189 * @param {string[]} lines header lines
190 * @returns {string} unfolded header string
192 const unfoldHeaderLines
= (lines
) => {
193 const foldedLineRE
= /^(\t| +)(.*)$/;
195 lines
.reduceRight((_
, line
, idx
) => { // NOSONAR
196 const result
= foldedLineRE
.exec(line
);
198 const prevIdx
= idx
- 1;
199 const mergedLine
= `${lines[prevIdx]} ${result[2]}`;
200 lines
.splice(prevIdx
, 2, mergedLine
);
209 * Adds a new set-cookie header value to response, with supplied data.
210 * @param {http.ServerResponse} res response
211 * @param {(string, string) => void} res.appendHeader sets header values
212 * @param {string} name cookie name
213 * @param {string} value cookie value
214 * @param {object=} opt cookie options
215 * @param {string=} opt.domain cookie domain
216 * @param {Date=} opt.expires cookie expiration
217 * @param {boolean=} opt.httpOnly cookie client visibility
218 * @param {number=} opt.maxAge cookie lifetime
219 * @param {string=} opt.path cookie path
220 * @param {string=} opt.sameSite cookie sharing
221 * @param {boolean=} opt.secure cookie security
223 function addCookie(res
, name
, value
, opt
= {}) {
234 // TODO: validate name, value
235 const cookieParts
= [
238 if (options
.domain
) {
239 cookieParts
.push(`Domain=${options.domain}`);
241 if (options
.expires
) {
242 if (!(options
.expires
instanceof Date
)) {
243 throw new TypeError('cookie expires must be Date');
245 cookieParts
.push(`Expires=${options.expires.toUTCString()}`);
247 if (options
.httpOnly
) {
248 cookieParts
.push('HttpOnly');
250 if (options
.maxAge
) {
251 cookieParts
.push(`Max-Age=${options.maxAge}`);
254 cookieParts
.push(`Path=${options.path}`);
256 if (options
.sameSite
) {
257 if (!(['Strict', 'Lax', 'None'].includes(options
.sameSite
))) {
258 throw new RangeError('cookie sameSite value not valid');
260 cookieParts
.push(`SameSite=${options.sameSite}`);
262 if (options
.secure
) {
263 cookieParts
.push('Secure');
265 res
.appendHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));