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
15 * @typedef {import('node:fs')} fs
19 * Simple ETag from data.
20 * @param {string} _filePath (currently unused)
21 * @param {fs.Stats} fileStat node:fs.Stats object
22 * @param {number} fileStat.mtimeMs node:fs.Stats object
23 * @param {crypto.BinaryLike} fileData content
24 * @returns {string} etag
26 const generateETag
= (_filePath
, fileStat
, fileData
) => {
27 const hash
= crypto
.createHash('sha256');
28 if (fileStat
?.mtimeMs
) {
29 hash
.update(fileStat
.mtimeMs
.toString());
31 hash
.update(fileData
);
32 const digest
= hash
.digest('base64').replace('=', '');
37 * Access property with default.
38 * @param {object} obj target object
39 * @param {string} prop target property
40 * @param {*} def default value if prop does not exist for obj
41 * @returns {*} property value or default
43 const get = (obj
, prop
, def
) => obj
&& prop
&& (prop
in obj
) ? obj
[prop
] : def
;
46 * Determine whether a client has already requested a resource,
47 * based on If-Modified-Since and If-None-Match headers.
48 * @param {http.ClientRequest} req request
49 * @param {(string) => string} req.getHeader header accessor
50 * @param {number} modifiedTimeMs ms timestamp from client
51 * @param {string} eTag etag from client
52 * @returns {boolean} whether our version matches what client knows
54 const isClientCached
= (req
, modifiedTimeMs
, eTag
) => {
55 let clientCached
= false;
57 const ifModifiedSince
= req
.getHeader(Enum
.Header
.IfModifiedSince
);
58 if (ifModifiedSince
) {
59 const ifModifiedSinceMs
= Date
.parse(ifModifiedSince
);
60 if (modifiedTimeMs
< ifModifiedSinceMs
) {
65 const ifNoneMatch
= req
.getHeader(Enum
.Header
.IfNoneMatch
);
67 const matches
= ifNoneMatch
.split(',').map((m
) => m
.trim());
68 if (matches
.includes(eTag
)
69 || (ifNoneMatch
=== '*' && eTag
)) {
72 // If if-none-matched header is present, it takes precedence over modified-since.
81 * Shallow merge for enums, to be called by derived constructor.
82 * Expects only one-level deep, is not recursive!
83 * @param {object} origEnum enum object to be extended
84 * @param {object} additionalEnum enum object to add
85 * @returns {object} lightly merged enum object
87 const mergeEnum
= (origEnum
, additionalEnum
) => {
88 for (const e
of Object
.keys(additionalEnum
)) {
89 if (typeof additionalEnum
[e
] === 'object') {
90 if (! (e
in origEnum
)) {
93 Object
.assign(origEnum
[e
], additionalEnum
[e
]);
95 origEnum
[e
] = additionalEnum
[e
];
102 * Isolate the general category of an http status code.
103 * @param {number} statusCode of response
104 * @returns {number} status category
106 const httpStatusCodeClass
= (statusCode
) => Math
.floor(statusCode
/ 100);
108 const _isObject
= (obj
) => obj
&& typeof obj
=== 'object';
109 const _isArray
= (obj
) => Array
.isArray(obj
);
112 * Return a new object with all objects combined, later properties taking precedence.
113 * Arrays are concated.
114 * @param {...object} objects to be merged onto a new object
115 * @returns {object} new merged object
117 const mergeDeep
= (...objects
) => {
118 return objects
.reduce((acc
, obj
) => {
119 const objectProperties
= [...Object
.getOwnPropertyNames(obj
), ...Object
.getOwnPropertySymbols(obj
)];
120 objectProperties
.forEach((k
) => {
123 if (_isArray(oVal
)) {
124 acc
[k
] = (_isArray(aVal
) ? aVal : []).concat(oVal
);
125 } else if (_isObject(oVal
)) {
126 acc
[k
] = mergeDeep(_isObject(aVal
) ? aVal : {}, oVal
);
137 * Return a new object with selected props.
138 * @param {object} obj source object
139 * @param {string[]} props list of property names
140 * @returns {object} object with selected properties
142 const pick
= (obj
, props
) => {
144 props
.forEach((prop
) => {
146 picked
[prop
] = obj
[prop
];
153 * Store all properties in defaultOptions on target from either options or defaultOptions.
154 * @param {object} target object to populate
155 * @param {object} defaultOptions object with default property values
156 * @param {object} options object with potential overrides for defaults
157 * @returns {object} object with properties
159 const setOptions
= (target
, defaultOptions
, options
) => {
160 Object
.assign(target
, defaultOptions
, pick(options
, Object
.keys(defaultOptions
)));
165 * Return a two-item list of src, split at first delimiter encountered.
166 * @param {string} src source
167 * @param {string} delimiter delimiter
168 * @param {string} fill trailing stand-in if no delimiter in src
169 * @returns {string[]} [before-first-delimiter, rest-or-fill]
171 const splitFirst
= (src
, delimiter
, fill
) => {
172 const idx
= src
.indexOf(delimiter
);
174 return [ src
.slice(0, idx
), src
.slice(idx
+ 1) ];
176 return [ src
, fill
];
181 * Generate a new request identifier, a time/host-based uuid.
182 * @returns {string} uuid
184 const requestId
= () => {
189 * Merges folded header lines
190 * @param {string[]} lines header lines
191 * @returns {string} unfolded header string
193 const unfoldHeaderLines
= (lines
) => {
194 const foldedLineRE
= /^(\t| +)(.*)$/;
196 lines
.reduceRight((_
, line
, idx
) => { // NOSONAR
197 const result
= foldedLineRE
.exec(line
);
199 const prevIdx
= idx
- 1;
200 const mergedLine
= `${lines[prevIdx]} ${result[2]}`;
201 lines
.splice(prevIdx
, 2, mergedLine
);
209 const validTokenRE
= /^[!#$%&'*+-.0-9A-Z^_`a-z~]+$/;
210 const validValueRE
= /^[!#$%&'()*+-./0-9:<=>?@A
-Z
[\]^_
`a-z{|}~]*$/;
211 const validPathRE = /^[ !"#$%&'()*+,-./0-9:<=>?@A-Z[\\\]^_`a
-z
{|}~]*$/;
212 const validLabelRE
= /^[a-zA-Z0-9-]+$/;
213 const invalidLabelRE
= /--|^-|-$/;
216 * Adds a new set-cookie header value to response, with supplied data.
217 * @param {http.ServerResponse} res response
218 * @param {(string, string) => void} res.appendHeader sets header values
219 * @param {string} name cookie name
220 * @param {string} value cookie value
221 * @param {object=} opt cookie options
222 * @param {string=} opt.domain cookie domain
223 * @param {Date=} opt.expires cookie expiration
224 * @param {boolean=} opt.httpOnly cookie client visibility
225 * @param {number=} opt.maxAge cookie lifetime
226 * @param {string=} opt.path cookie path
227 * @param {string=} opt.sameSite cookie sharing
228 * @param {boolean=} opt.secure cookie security
229 * @param {string[]=} opt.extension cookie extension attribute values
231 function addCookie(res
, name
, value
, opt
= {}) {
244 if (!validTokenRE
.test(name
)) {
245 throw new RangeError('invalid cookie name');
248 if (value
.startsWith('"') && value
.endsWith('"')) {
249 if (!validValueRE
.test(value
.slice(1, value
.length
- 1))) {
250 throw new RangeError('invalid cookie value');
252 } else if (!validValueRE
.test(value
)) {
253 throw new RangeError('invalid cookie value');
256 const cookieParts
= [
260 if (options
.domain
) {
261 for (const label
of options
.domain
.split('.')) {
262 if (!validLabelRE
.test(label
) || invalidLabelRE
.test(label
)) {
263 throw new RangeError('invalid cookie domain');
267 cookieParts
.push(`Domain=${options.domain}`);
270 if (options
.expires
) {
271 if (!(options
.expires
instanceof Date
)) {
272 throw new TypeError('cookie expires must be Date');
274 cookieParts
.push(`Expires=${options.expires.toUTCString()}`);
277 if (options
.httpOnly
) {
278 cookieParts
.push('HttpOnly');
281 if (options
.maxAge
) {
282 cookieParts
.push(`Max-Age=${options.maxAge}`);
286 if (!validPathRE
.test(options
.path
)) {
287 throw new RangeError('cookie path value not valid');
289 cookieParts
.push(`Path=${options.path}`);
292 if (options
.sameSite
) {
293 if (!(['Strict', 'Lax', 'None'].includes(options
.sameSite
))) {
294 throw new RangeError('cookie sameSite value not valid');
296 if (options
.sameSite
=== 'None'
297 && !options
.secure
) {
298 throw new RangeError('cookie with sameSite None must also be secure');
300 cookieParts
.push(`SameSite=${options.sameSite}`);
303 if (options
.secure
) {
304 cookieParts
.push('Secure');
307 if (!Array
.isArray(options
.extension
)) {
308 throw new TypeError('cookie extension must be Array');
310 for (const extension
of options
.extension
) {
311 if (!validPathRE
.test(extension
)) {
312 throw new RangeError('cookie extension value not valid');
314 cookieParts
.push(extension
);
317 res
.appendHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));