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 * Simple ETag from data.
15 * @param {String} filePath (currently unused)
16 * @param {fs.Stats} fileStat
17 * @param {crypto.BinaryLike} fileData content
20 const generateETag
= (_filePath
, fileStat
, fileData
) => {
21 const hash
= crypto
.createHash('sha256');
22 if (fileStat
?.mtimeMs
) {
23 hash
.update(fileStat
.mtimeMs
.toString());
25 hash
.update(fileData
);
26 const digest
= hash
.digest('base64').replace('=', '');
31 * Access property with default.
33 * @param {String} prop
34 * @param {*} def default value if prop does not exist for obj
37 const get = (obj
, prop
, def
) => obj
&& prop
&& (prop
in obj
) ? obj
[prop
] : def
;
40 * Determine whether a client has already requested a resource,
41 * based on If-Modified-Since and If-None-Match headers.
42 * @param {http.ClientRequest} req
43 * @param {Number} modifiedTimeMs
44 * @param {String} eTag
47 const isClientCached
= (req
, modifiedTimeMs
, eTag
) => {
48 let clientCached
= false;
50 const ifModifiedSince
= req
.getHeader(Enum
.Header
.IfModifiedSince
);
51 if (ifModifiedSince
) {
52 const ifModifiedSinceMs
= Date
.parse(ifModifiedSince
);
53 if (modifiedTimeMs
< ifModifiedSinceMs
) {
58 const ifNoneMatch
= req
.getHeader(Enum
.Header
.IfNoneMatch
);
60 const matches
= ifNoneMatch
.split(',').map((m
) => m
.trim());
61 if (matches
.includes(eTag
)
62 || (ifNoneMatch
=== '*' && eTag
)) {
65 // If if-none-matched header is present, it takes precedence over modified-since.
74 * Shallow merge for enums, to be called by derived constructor.
75 * Expects only one-level deep, is not recursive!
76 * @param {Object} origEnum
77 * @param {Object} additionalEnum
80 const mergeEnum
= (origEnum
, additionalEnum
) => {
81 for (const e
of Object
.keys(additionalEnum
)) {
82 if (typeof additionalEnum
[e
] === 'object') {
83 if (! (e
in origEnum
)) {
86 Object
.assign(origEnum
[e
], additionalEnum
[e
]);
88 origEnum
[e
] = additionalEnum
[e
];
95 * Isolate the general category of an http status code.
96 * @param {Number} statusCode
99 const httpStatusCodeClass
= (statusCode
) => Math
.floor(statusCode
/ 100);
101 const _isObject
= (obj
) => obj
&& typeof obj
=== 'object';
102 const _isArray
= (obj
) => Array
.isArray(obj
);
104 * Return a new object with all objects combined, later properties taking precedence.
105 * @param {...Object} objects
108 const mergeDeep
= (...objects
) => {
109 return objects
.reduce((acc
, obj
) => {
110 const objectProperties
= [...Object
.getOwnPropertyNames(obj
), ...Object
.getOwnPropertySymbols(obj
)];
111 objectProperties
.forEach((k
) => {
114 if (_isArray(oVal
)) {
115 acc
[k
] = (_isArray(aVal
) ? aVal : []).concat(oVal
);
116 } else if (_isObject(oVal
)) {
117 acc
[k
] = mergeDeep(_isObject(aVal
) ? aVal : {}, oVal
);
128 * Return a new object with selected props.
129 * @param {Object} obj
130 * @param {String[]} props
133 const pick
= (obj
, props
) => {
135 props
.forEach((prop
) => {
137 picked
[prop
] = obj
[prop
];
144 * Store all properties in defaultOptions on target from either options or defaultOptions.
145 * @param {Object} target
146 * @param {Object} defaultOptions
147 * @param {Object} options
149 const setOptions
= (target
, defaultOptions
, options
) => {
150 Object
.assign(target
, defaultOptions
, pick(options
, Object
.keys(defaultOptions
)));
154 * Return a two-item list of src, split at first delimiter encountered.
155 * @param {String} src
156 * @param {String} delimiter
157 * @param {String} fill trailing stand-in if no delimiter in src
159 const splitFirst
= (src
, delimiter
, fill
) => {
160 const idx
= src
.indexOf(delimiter
);
162 return [ src
.slice(0, idx
), src
.slice(idx
+ 1) ];
164 return [ src
, fill
];
169 * Generate a new request identifier, a time/host-based uuid.
172 const requestId
= () => {
177 * Merges folded header lines
178 * @param {String[]} lines
181 const unfoldHeaderLines
= (lines
) => {
182 const foldedLineRE
= /^(\t| +)(.*)$/;
184 lines
.reduceRight((_
, line
, idx
) => { // NOSONAR
185 const result
= foldedLineRE
.exec(line
);
187 const prevIdx
= idx
- 1;
188 const mergedLine
= `${lines[prevIdx]} ${result[2]}`;
189 lines
.splice(prevIdx
, 2, mergedLine
);
199 * @param {http.ServerResponse} res
200 * @param {String} name
201 * @param {String} value
202 * @param {Object=} opt
203 * @param {String=} opt.domain
204 * @param {Date=} opt.expires
205 * @param {Boolean=} opt.httpOnly
206 * @param {Number=} opt.maxAge
207 * @param {String=} opt.path
208 * @param {String=} opt.sameSite
209 * @param {Boolean=} opt.secure
211 function addCookie(res
, name
, value
, opt
= {}) {
222 // TODO: validate name, value
223 const cookieParts
= [
226 if (options
.domain
) {
227 cookieParts
.push(`Domain=${options.domain}`);
229 if (options
.expires
) {
230 if (!(options
.expires
instanceof Date
)) {
231 throw new TypeError('cookie expires must be Date');
233 cookieParts
.push(`Expires=${options.expires.toUTCString()}`);
235 if (options
.httpOnly
) {
236 cookieParts
.push('HttpOnly');
238 if (options
.maxAge
) {
239 cookieParts
.push(`Max-Age=${options.maxAge}`);
242 cookieParts
.push(`Path=${options.path}`);
244 if (options
.sameSite
) {
245 if (!(['Strict', 'Lax', 'None'].includes(options
.sameSite
))) {
246 throw new RangeError('cookie sameSite value not valid');
248 cookieParts
.push(`SameSite=${options.sameSite}`);
250 if (options
.secure
) {
251 cookieParts
.push('Secure');
253 res
.appendHeader(Enum
.Header
.SetCookie
, cookieParts
.join('; '));