update CHANGELOG.md
[squeep-api-dingus] / lib / common.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * Utility and miscellaneous functions.
6 */
7
8 const crypto = require('node:crypto');
9 const uuid = require('uuid');
10 const Enum = require('./enum');
11 const { fileScope } = require('@squeep/log-helper');
12
13 /**
14 * @typedef {import('node:http')} http
15 * @typedef {import('node:fs')} fs
16 */
17
18 /**
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
25 */
26 const generateETag = (_filePath, fileStat, fileData) => {
27 const hash = crypto.createHash('sha256');
28 if (fileStat?.mtimeMs) {
29 hash.update(fileStat.mtimeMs.toString());
30 }
31 hash.update(fileData);
32 const digest = hash.digest('base64').replace('=', '');
33 return `"${digest}"`;
34 };
35
36 /**
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
42 */
43 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
44
45 /**
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
53 */
54 const isClientCached = (req, modifiedTimeMs, eTag) => {
55 let clientCached = false;
56
57 const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
58 if (ifModifiedSince) {
59 const ifModifiedSinceMs = Date.parse(ifModifiedSince);
60 if (modifiedTimeMs < ifModifiedSinceMs) {
61 clientCached = true;
62 }
63 }
64
65 const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
66 if (ifNoneMatch) {
67 const matches = ifNoneMatch.split(',').map((m) => m.trim());
68 if (matches.includes(eTag)
69 || (ifNoneMatch === '*' && eTag)) {
70 clientCached = true;
71 } else {
72 // If if-none-matched header is present, it takes precedence over modified-since.
73 clientCached = false;
74 }
75 }
76
77 return clientCached;
78 };
79
80 /**
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
86 */
87 const mergeEnum = (origEnum, additionalEnum) => {
88 for (const e of Object.keys(additionalEnum)) {
89 if (typeof additionalEnum[e] === 'object') {
90 if (! (e in origEnum)) {
91 origEnum[e] = {};
92 }
93 Object.assign(origEnum[e], additionalEnum[e]);
94 } else {
95 origEnum[e] = additionalEnum[e];
96 }
97 }
98 return origEnum;
99 };
100
101 /**
102 * Isolate the general category of an http status code.
103 * @param {number} statusCode of response
104 * @returns {number} status category
105 */
106 const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
107
108 const _isObject = (obj) => obj && typeof obj === 'object';
109 const _isArray = (obj) => Array.isArray(obj);
110
111 /**
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
116 */
117 const mergeDeep = (...objects) => {
118 return objects.reduce((acc, obj) => {
119 const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
120 objectProperties.forEach((k) => {
121 const aVal = acc[k];
122 const oVal = obj[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);
127 } else {
128 acc[k] = oVal;
129 }
130 });
131 return acc;
132 }, {});
133 };
134
135
136 /**
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
141 */
142 const pick = (obj, props) => {
143 const picked = {};
144 props.forEach((prop) => {
145 if (prop in obj) {
146 picked[prop] = obj[prop];
147 }
148 });
149 return picked;
150 };
151
152 /**
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
158 */
159 const setOptions = (target, defaultOptions, options) => {
160 Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
161 return target;
162 };
163
164 /**
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]
170 */
171 const splitFirst = (src, delimiter, fill) => {
172 const idx = src.indexOf(delimiter);
173 if (idx >= 0) {
174 return [ src.slice(0, idx), src.slice(idx + 1) ];
175 } else {
176 return [ src, fill ];
177 }
178 };
179
180 /**
181 * Generate a new request identifier, a time/host-based uuid.
182 * @returns {string} uuid
183 */
184 const requestId = () => {
185 return uuid.v1();
186 };
187
188 /**
189 * Merges folded header lines
190 * @param {string[]} lines header lines
191 * @returns {string} unfolded header string
192 */
193 const unfoldHeaderLines = (lines) => {
194 const foldedLineRE = /^(\t| +)(.*)$/;
195 if (lines) {
196 lines.reduceRight((_, line, idx) => { // NOSONAR
197 const result = foldedLineRE.exec(line);
198 if (result && idx) {
199 const prevIdx = idx - 1;
200 const mergedLine = `${lines[prevIdx]} ${result[2]}`;
201 lines.splice(prevIdx, 2, mergedLine);
202 return mergedLine;
203 }
204 }, null);
205 }
206 return lines;
207 };
208
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 = /--|^-|-$/;
214
215 /**
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
230 */
231 function addCookie(res, name, value, opt = {}) {
232 const options = {
233 domain: undefined,
234 expires: undefined,
235 httpOnly: false,
236 maxAge: undefined,
237 path: undefined,
238 sameSite: undefined,
239 secure: false,
240 extension: [],
241 ...opt,
242 };
243
244 if (!validTokenRE.test(name)) {
245 throw new RangeError('invalid cookie name');
246 }
247
248 if (value.startsWith('"') && value.endsWith('"')) {
249 if (!validValueRE.test(value.slice(1, value.length - 1))) {
250 throw new RangeError('invalid cookie value');
251 };
252 } else if (!validValueRE.test(value)) {
253 throw new RangeError('invalid cookie value');
254 }
255
256 const cookieParts = [
257 `${name}=${value}`,
258 ];
259
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');
264 }
265
266 }
267 cookieParts.push(`Domain=${options.domain}`);
268 }
269
270 if (options.expires) {
271 if (!(options.expires instanceof Date)) {
272 throw new TypeError('cookie expires must be Date');
273 }
274 cookieParts.push(`Expires=${options.expires.toUTCString()}`);
275 }
276
277 if (options.httpOnly) {
278 cookieParts.push('HttpOnly');
279 }
280
281 if (options.maxAge) {
282 cookieParts.push(`Max-Age=${options.maxAge}`);
283 }
284
285 if (options.path) {
286 if (!validPathRE.test(options.path)) {
287 throw new RangeError('cookie path value not valid');
288 }
289 cookieParts.push(`Path=${options.path}`);
290 }
291
292 if (options.sameSite) {
293 if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) {
294 throw new RangeError('cookie sameSite value not valid');
295 }
296 if (options.sameSite === 'None'
297 && !options.secure) {
298 throw new RangeError('cookie with sameSite None must also be secure');
299 }
300 cookieParts.push(`SameSite=${options.sameSite}`);
301 }
302
303 if (options.secure) {
304 cookieParts.push('Secure');
305 }
306
307 if (!Array.isArray(options.extension)) {
308 throw new TypeError('cookie extension must be Array');
309 }
310 for (const extension of options.extension) {
311 if (!validPathRE.test(extension)) {
312 throw new RangeError('cookie extension value not valid');
313 }
314 cookieParts.push(extension);
315 }
316
317 res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; '));
318 }
319
320
321 module.exports = {
322 addCookie,
323 fileScope,
324 generateETag,
325 get,
326 httpStatusCodeClass,
327 isClientCached,
328 mergeDeep,
329 mergeEnum,
330 pick,
331 requestId,
332 setOptions,
333 splitFirst,
334 unfoldHeaderLines,
335 };