2f0aeb835694b5eb0f5e6130b8974ff645c585b6
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
);
208 const validTokenRE
= /^[!#$%&'*+-.0-9A-Z^_`a-z~]+$/;
209 const validValueRE
= /^[!#$%&'()*+-./0-9:<=>?@A
-Z
[\]^_
`a-z{|}~]*$/;
210 const validPathRE = /^[ !"#$%&'()*+,-./0-9:<=>?@A-Z[\\\]^_`a
-z
{|}~]*$/;
211 const validLabelRE
= /^[a-zA-Z0-9-]+$/;
212 const invalidLabelRE
= /--|^-|-$/;
215 * Adds a new set-cookie header value to response, with supplied data.
216 * @param {http.ServerResponse} res response
217 * @param {(string, string) => void} res.appendHeader sets header values
218 * @param {string} name cookie name
219 * @param {string} value cookie value
220 * @param {object=} opt cookie options
221 * @param {string=} opt.domain cookie domain
222 * @param {Date=} opt.expires cookie expiration
223 * @param {boolean=} opt.httpOnly cookie client visibility
224 * @param {number=} opt.maxAge cookie lifetime
225 * @param {string=} opt.path cookie path
226 * @param {string=} opt.sameSite cookie sharing
227 * @param {boolean=} opt.secure cookie security
228 * @param {string[]=} opt.extension cookie extension attribute values
230 function addCookie(res
, name
, value
, opt
= {}) {
243 if (!validTokenRE
.test(name
)) {
244 throw new RangeError('invalid cookie name');
247 if (value
.startsWith('"') && value
.endsWith('"')) {
248 if (!validValueRE
.test(value
.slice(1, value
.length
- 1))) {
249 throw new RangeError('invalid cookie value');
251 } else if (!validValueRE
.test(value
)) {
252 throw new RangeError('invalid cookie value');
255 const cookieParts
= [
259 if (options
.domain
) {
260 for (const label
of options
.domain
.split('.')) {
261 if (!validLabelRE
.test(label
) || invalidLabelRE
.test(label
)) {
262 throw new RangeError('invalid cookie domain');
266 cookieParts
.push(`Domain=${options.domain}`);
269 if (options
.expires
) {
270 if (!(options
.expires
instanceof Date
)) {
271 throw new TypeError('cookie expires must be Date');
273 cookieParts
.push(`Expires=${options.expires.toUTCString()}`);
276 if (options
.httpOnly
) {
277 cookieParts
.push('HttpOnly');
280 if (options
.maxAge
) {
281 cookieParts
.push(`Max-Age=${options.maxAge}`);
285 if (!validPathRE
.test(options
.path
)) {
286 throw new RangeError('cookie path value not valid');
288 cookieParts
.push(`Path=${options.path}`);
291 if (options
.sameSite
) {
292 if (!(['Strict', 'Lax', 'None'].includes(options
.sameSite
))) {
293 throw new RangeError('cookie sameSite value not valid');
295 if (options
.sameSite
=== 'None'
296 && !options
.secure
) {
297 throw new RangeError('cookie with sameSite None must also be secure');
299 cookieParts
.push(`SameSite=${options.sameSite}`);
302 if (options
.secure
) {
303 cookieParts
.push('Secure');
306 if (!Array
.isArray(options
.extension
)) {
307 throw new TypeError('cookie extension must be Array');
309 for (const extension
of options
.extension
) {
310 if (!validPathRE
.test(extension
)) {
311 throw new RangeError('cookie extension value not valid');
313 cookieParts
.push(extension
);
316 res
.appendHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));