update devDependencies, add jsdoc lint, fix lint issues
[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 */
16
17 /**
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
24 */
25 const generateETag = (_filePath, fileStat, fileData) => {
26 const hash = crypto.createHash('sha256');
27 if (fileStat?.mtimeMs) {
28 hash.update(fileStat.mtimeMs.toString());
29 }
30 hash.update(fileData);
31 const digest = hash.digest('base64').replace('=', '');
32 return `"${digest}"`;
33 };
34
35 /**
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
41 */
42 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
43
44 /**
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
52 */
53 const isClientCached = (req, modifiedTimeMs, eTag) => {
54 let clientCached = false;
55
56 const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
57 if (ifModifiedSince) {
58 const ifModifiedSinceMs = Date.parse(ifModifiedSince);
59 if (modifiedTimeMs < ifModifiedSinceMs) {
60 clientCached = true;
61 }
62 }
63
64 const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
65 if (ifNoneMatch) {
66 const matches = ifNoneMatch.split(',').map((m) => m.trim());
67 if (matches.includes(eTag)
68 || (ifNoneMatch === '*' && eTag)) {
69 clientCached = true;
70 } else {
71 // If if-none-matched header is present, it takes precedence over modified-since.
72 clientCached = false;
73 }
74 }
75
76 return clientCached;
77 };
78
79 /**
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
85 */
86 const mergeEnum = (origEnum, additionalEnum) => {
87 for (const e of Object.keys(additionalEnum)) {
88 if (typeof additionalEnum[e] === 'object') {
89 if (! (e in origEnum)) {
90 origEnum[e] = {};
91 }
92 Object.assign(origEnum[e], additionalEnum[e]);
93 } else {
94 origEnum[e] = additionalEnum[e];
95 }
96 }
97 return origEnum;
98 };
99
100 /**
101 * Isolate the general category of an http status code.
102 * @param {number} statusCode of response
103 * @returns {number} status category
104 */
105 const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
106
107 const _isObject = (obj) => obj && typeof obj === 'object';
108 const _isArray = (obj) => Array.isArray(obj);
109
110 /**
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
115 */
116 const mergeDeep = (...objects) => {
117 return objects.reduce((acc, obj) => {
118 const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
119 objectProperties.forEach((k) => {
120 const aVal = acc[k];
121 const oVal = obj[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);
126 } else {
127 acc[k] = oVal;
128 }
129 });
130 return acc;
131 }, {});
132 };
133
134
135 /**
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
140 */
141 const pick = (obj, props) => {
142 const picked = {};
143 props.forEach((prop) => {
144 if (prop in obj) {
145 picked[prop] = obj[prop];
146 }
147 });
148 return picked;
149 };
150
151 /**
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
157 */
158 const setOptions = (target, defaultOptions, options) => {
159 Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
160 return target;
161 };
162
163 /**
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]
169 */
170 const splitFirst = (src, delimiter, fill) => {
171 const idx = src.indexOf(delimiter);
172 if (idx >= 0) {
173 return [ src.slice(0, idx), src.slice(idx + 1) ];
174 } else {
175 return [ src, fill ];
176 }
177 };
178
179 /**
180 * Generate a new request identifier, a time/host-based uuid.
181 * @returns {string} uuid
182 */
183 const requestId = () => {
184 return uuid.v1();
185 };
186
187 /**
188 * Merges folded header lines
189 * @param {string[]} lines header lines
190 * @returns {string} unfolded header string
191 */
192 const unfoldHeaderLines = (lines) => {
193 const foldedLineRE = /^(\t| +)(.*)$/;
194 if (lines) {
195 lines.reduceRight((_, line, idx) => { // NOSONAR
196 const result = foldedLineRE.exec(line);
197 if (result && idx) {
198 const prevIdx = idx - 1;
199 const mergedLine = `${lines[prevIdx]} ${result[2]}`;
200 lines.splice(prevIdx, 2, mergedLine);
201 return mergedLine;
202 }
203 }, null);
204 }
205 return lines;
206 };
207
208 /**
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
222 */
223 function addCookie(res, name, value, opt = {}) {
224 const options = {
225 domain: undefined,
226 expires: undefined,
227 httpOnly: false,
228 maxAge: undefined,
229 path: undefined,
230 sameSite: undefined,
231 secure: false,
232 ...opt,
233 };
234 // TODO: validate name, value
235 const cookieParts = [
236 `${name}=${value}`,
237 ];
238 if (options.domain) {
239 cookieParts.push(`Domain=${options.domain}`);
240 }
241 if (options.expires) {
242 if (!(options.expires instanceof Date)) {
243 throw new TypeError('cookie expires must be Date');
244 }
245 cookieParts.push(`Expires=${options.expires.toUTCString()}`);
246 }
247 if (options.httpOnly) {
248 cookieParts.push('HttpOnly');
249 }
250 if (options.maxAge) {
251 cookieParts.push(`Max-Age=${options.maxAge}`);
252 }
253 if (options.path) {
254 cookieParts.push(`Path=${options.path}`);
255 }
256 if (options.sameSite) {
257 if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) {
258 throw new RangeError('cookie sameSite value not valid');
259 }
260 cookieParts.push(`SameSite=${options.sameSite}`);
261 }
262 if (options.secure) {
263 cookieParts.push('Secure');
264 }
265 res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; '));
266 }
267
268
269 module.exports = {
270 addCookie,
271 fileScope,
272 generateETag,
273 get,
274 httpStatusCodeClass,
275 isClientCached,
276 mergeDeep,
277 mergeEnum,
278 pick,
279 requestId,
280 setOptions,
281 splitFirst,
282 unfoldHeaderLines,
283 };