Merge branch 'v2.1-dev'
[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 * Simple ETag from data.
15 * @param {String} filePath (currently unused)
16 * @param {fs.Stats} fileStat
17 * @param {crypto.BinaryLike} fileData content
18 * @returns {String}
19 */
20 const generateETag = (_filePath, fileStat, fileData) => {
21 const hash = crypto.createHash('sha256');
22 if (fileStat?.mtimeMs) {
23 hash.update(fileStat.mtimeMs.toString());
24 }
25 hash.update(fileData);
26 const digest = hash.digest('base64').replace('=', '');
27 return `"${digest}"`;
28 };
29
30 /**
31 * Access property with default.
32 * @param {Object} obj
33 * @param {String} prop
34 * @param {*} def default value if prop does not exist for obj
35 * @return {*}
36 */
37 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
38
39 /**
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
45 * @returns {Boolean}
46 */
47 const isClientCached = (req, modifiedTimeMs, eTag) => {
48 let clientCached = false;
49
50 const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
51 if (ifModifiedSince) {
52 const ifModifiedSinceMs = Date.parse(ifModifiedSince);
53 if (modifiedTimeMs < ifModifiedSinceMs) {
54 clientCached = true;
55 }
56 }
57
58 const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
59 if (ifNoneMatch) {
60 const matches = ifNoneMatch.split(',').map((m) => m.trim());
61 if (matches.includes(eTag)
62 || (ifNoneMatch === '*' && eTag)) {
63 clientCached = true;
64 } else {
65 // If if-none-matched header is present, it takes precedence over modified-since.
66 clientCached = false;
67 }
68 }
69
70 return clientCached;
71 };
72
73 /**
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
78 * @returns {Object}
79 */
80 const mergeEnum = (origEnum, additionalEnum) => {
81 for (const e of Object.keys(additionalEnum)) {
82 if (typeof additionalEnum[e] === 'object') {
83 if (! (e in origEnum)) {
84 origEnum[e] = {};
85 }
86 Object.assign(origEnum[e], additionalEnum[e]);
87 } else {
88 origEnum[e] = additionalEnum[e];
89 }
90 }
91 return origEnum;
92 };
93
94 /**
95 * Isolate the general category of an http status code.
96 * @param {Number} statusCode
97 * @returns {Number}
98 */
99 const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
100
101 const _isObject = (obj) => obj && typeof obj === 'object';
102 const _isArray = (obj) => Array.isArray(obj);
103 /**
104 * Return a new object with all objects combined, later properties taking precedence.
105 * @param {...Object} objects
106 * @returns {Object}
107 */
108 const mergeDeep = (...objects) => {
109 return objects.reduce((acc, obj) => {
110 const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
111 objectProperties.forEach((k) => {
112 const aVal = acc[k];
113 const oVal = obj[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);
118 } else {
119 acc[k] = oVal;
120 }
121 });
122 return acc;
123 }, {});
124 };
125
126
127 /**
128 * Return a new object with selected props.
129 * @param {Object} obj
130 * @param {String[]} props
131 * @returns {Object}
132 */
133 const pick = (obj, props) => {
134 const picked = {};
135 props.forEach((prop) => {
136 if (prop in obj) {
137 picked[prop] = obj[prop];
138 }
139 });
140 return picked;
141 };
142
143 /**
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
148 */
149 const setOptions = (target, defaultOptions, options) => {
150 Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
151 };
152
153 /**
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
158 */
159 const splitFirst = (src, delimiter, fill) => {
160 const idx = src.indexOf(delimiter);
161 if (idx >= 0) {
162 return [ src.slice(0, idx), src.slice(idx + 1) ];
163 } else {
164 return [ src, fill ];
165 }
166 };
167
168 /**
169 * Generate a new request identifier, a time/host-based uuid.
170 * @returns {String}
171 */
172 const requestId = () => {
173 return uuid.v1();
174 };
175
176 /**
177 * Merges folded header lines
178 * @param {String[]} lines
179 * @returns {String}
180 */
181 const unfoldHeaderLines = (lines) => {
182 const foldedLineRE = /^(\t| +)(.*)$/;
183 if (lines) {
184 lines.reduceRight((_, line, idx) => { // NOSONAR
185 const result = foldedLineRE.exec(line);
186 if (result && idx) {
187 const prevIdx = idx - 1;
188 const mergedLine = `${lines[prevIdx]} ${result[2]}`;
189 lines.splice(prevIdx, 2, mergedLine);
190 return mergedLine;
191 }
192 }, null);
193 }
194 return lines;
195 };
196
197 /**
198 * Adds a new cookie.
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
210 */
211 function addCookie(res, name, value, opt = {}) {
212 const options = {
213 domain: undefined,
214 expires: undefined,
215 httpOnly: false,
216 maxAge: undefined,
217 path: undefined,
218 sameSite: undefined,
219 secure: false,
220 ...opt,
221 };
222 // TODO: validate name, value
223 const cookieParts = [
224 `${name}=${value}`,
225 ];
226 if (options.domain) {
227 cookieParts.push(`Domain=${options.domain}`);
228 }
229 if (options.expires) {
230 if (!(options.expires instanceof Date)) {
231 throw new TypeError('cookie expires must be Date');
232 }
233 cookieParts.push(`Expires=${options.expires.toUTCString()}`);
234 }
235 if (options.httpOnly) {
236 cookieParts.push('HttpOnly');
237 }
238 if (options.maxAge) {
239 cookieParts.push(`Max-Age=${options.maxAge}`);
240 }
241 if (options.path) {
242 cookieParts.push(`Path=${options.path}`);
243 }
244 if (options.sameSite) {
245 if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) {
246 throw new RangeError('cookie sameSite value not valid');
247 }
248 cookieParts.push(`SameSite=${options.sameSite}`);
249 }
250 if (options.secure) {
251 cookieParts.push('Secure');
252 }
253 res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; '));
254 }
255
256
257 module.exports = {
258 addCookie,
259 fileScope,
260 generateETag,
261 get,
262 httpStatusCodeClass,
263 isClientCached,
264 mergeDeep,
265 mergeEnum,
266 pick,
267 requestId,
268 setOptions,
269 splitFirst,
270 unfoldHeaderLines,
271 };