723f922b20930355d6adc53ead1fc97f6e719597
[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 path = require('path');
9 const crypto = require('crypto');
10 const uuid = require('uuid');
11 const Enum = require('./enum');
12
13 /**
14 * @callback ScopeFn
15 * @param {String} scope
16 * @returns {String}
17 */
18 /**
19 * Return a function which prefixes a provided scope with the most-
20 * relevant part of the filename, for use in logging.
21 * @param {String} filename
22 * @returns {ScopeFn}
23 */
24 const fileScope = (filename) => {
25 let fScope = path.basename(filename, '.js');
26 if (fScope === 'index') {
27 fScope = path.basename(path.dirname(filename));
28 }
29 return (scope) => `${fScope}:${scope}`;
30 };
31
32 /**
33 * Simple ETag from data.
34 * @param {String} filePath (currently unused)
35 * @param {fs.Stats} fileStat
36 * @param {crypto.BinaryLike} fileData content
37 * @returns {String}
38 */
39 const generateETag = (_filePath, fileStat, fileData) => {
40 const hash = crypto.createHash('sha256');
41 if (fileStat?.mtimeMs) {
42 hash.update(fileStat.mtimeMs.toString());
43 }
44 hash.update(fileData);
45 const digest = hash.digest('base64').replace('=', '');
46 return `"${digest}"`;
47 };
48
49 /**
50 * Access property with default.
51 * @param {Object} obj
52 * @param {String} prop
53 * @param {*} def default value if prop does not exist for obj
54 * @return {*}
55 */
56 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
57
58 /**
59 * @param {http.ClientRequest} req
60 * @param {http.ServerResponse} res
61 * @param {Object} ctx
62 * @deprecated after v1.2.5 (integrated into logger module)
63 */
64 const handlerLogData = (req, res, ctx) => ({
65 req: requestLogData(req),
66 res: responseLogData(res),
67 ctx,
68 });
69
70 /**
71 * Determine whether a client has already requested a resource,
72 * based on If-Modified-Since and If-None-Match headers.
73 * @param {http.ClientRequest} req
74 * @param {Number} modifiedTimeMs
75 * @param {String} eTag
76 * @returns {Boolean}
77 */
78 const isClientCached = (req, modifiedTimeMs, eTag) => {
79 let clientCached = false;
80
81 const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
82 if (ifModifiedSince) {
83 const ifModifiedSinceMs = Date.parse(ifModifiedSince);
84 if (modifiedTimeMs < ifModifiedSinceMs) {
85 clientCached = true;
86 }
87 }
88
89 const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
90 if (ifNoneMatch) {
91 const matches = ifNoneMatch.split(',').map((m) => m.trim());
92 if (matches.includes(eTag)
93 || (ifNoneMatch === '*' && eTag)) {
94 clientCached = true;
95 } else {
96 // If if-none-matched header is present, it takes precedence over modified-since.
97 clientCached = false;
98 }
99 }
100
101 return clientCached;
102 };
103
104 /**
105 * Shallow merge for enums, to be called by derived constructor.
106 * Expects only one-level deep, is not recursive!
107 * @param {Object} origEnum
108 * @param {Object} additionalEnum
109 * @returns {Object}
110 */
111 const mergeEnum = (origEnum, additionalEnum) => {
112 for (const e of Object.keys(additionalEnum)) {
113 if (typeof additionalEnum[e] === 'object') {
114 if (! (e in origEnum)) {
115 origEnum[e] = {};
116 }
117 Object.assign(origEnum[e], additionalEnum[e]);
118 } else {
119 origEnum[e] = additionalEnum[e];
120 }
121 }
122 return origEnum;
123 };
124
125 /**
126 * Isolate the general category of an http status code.
127 * @param {Number} statusCode
128 * @returns {Number}
129 */
130 const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
131
132 const _isObject = (obj) => obj && typeof obj === 'object';
133 const _isArray = (obj) => Array.isArray(obj);
134 /**
135 * Return a new object with all objects combined, later properties taking precedence.
136 * @param {...Object} objects
137 * @returns {Object}
138 */
139 const mergeDeep = (...objects) => {
140 return objects.reduce((acc, obj) => {
141 const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
142 objectProperties.forEach((k) => {
143 const aVal = acc[k];
144 const oVal = obj[k];
145 if (_isArray(oVal)) {
146 acc[k] = (_isArray(aVal) ? aVal : []).concat(oVal);
147 } else if (_isObject(oVal)) {
148 acc[k] = mergeDeep(_isObject(aVal) ? aVal : {}, oVal);
149 } else {
150 acc[k] = oVal;
151 }
152 });
153 return acc;
154 }, {});
155 };
156
157
158 /**
159 * Return a new object with selected props.
160 * @param {Object} obj
161 * @param {String[]} props
162 * @returns {Object}
163 */
164 const pick = (obj, props) => {
165 const picked = {};
166 props.forEach((prop) => {
167 if (prop in obj) {
168 picked[prop] = obj[prop];
169 }
170 });
171 return picked;
172 };
173
174 /**
175 * Return a subset of a request object, suitable for logging.
176 * Obscures sensitive header values.
177 * @param {http.ClientRequest} req
178 * @deprecated after v1.2.5 (integrated into logger module)
179 */
180 const requestLogData = (req) => {
181 const data = pick(req, [
182 'method',
183 'url',
184 'httpVersion',
185 'headers',
186 'trailers',
187 ]);
188 scrubHeaderObject(data);
189 return data;
190 };
191
192
193 /**
194 * Remove sensitive header data.
195 * @param {Object} data
196 * @param {Object} data.headers
197 * @deprecated after v1.2.5 (integrated into logger module)
198 */
199 const scrubHeaderObject = (data) => {
200 if (data?.headers && 'authorization' in data.headers) {
201 data.headers = Object.assign({}, data.headers, {
202 authorization: obscureAuthorizationHeader(data.headers['authorization']),
203 });
204 }
205 };
206
207
208 /**
209 * Hide sensitive part of an Authorization header.
210 * @param {String} authHeader
211 * @returns {String}
212 * @deprecated after v1.2.5 (integrated into logger module)
213 */
214 const obscureAuthorizationHeader = (authHeader) => {
215 if (!authHeader) {
216 return authHeader;
217 }
218 const space = authHeader.indexOf(' ');
219 // This blurs entire string if no space found, because -1.
220 return authHeader.slice(0, space + 1) + '*'.repeat(authHeader.length - (space + 1));
221 };
222
223
224 /**
225 * Return a subset of a response object, suitable for logging.
226 * @param {http.ServerResponse} res
227 * @deprecated after v1.2.5 (integrated into logger module)
228 */
229 const responseLogData = (res) => {
230 const response = pick(res, [
231 'statusCode',
232 'statusMessage',
233 ]);
234 response.headers = res.getHeaders();
235 return response;
236 };
237
238
239 /**
240 * Store all properties in defaultOptions on target from either options or defaultOptions.
241 * @param {Object} target
242 * @param {Object} defaultOptions
243 * @param {Object} options
244 */
245 const setOptions = (target, defaultOptions, options) => {
246 Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
247 };
248
249 /**
250 * Return a two-item list of src, split at first delimiter encountered.
251 * @param {String} src
252 * @param {String} delimiter
253 * @param {String} fill trailing stand-in if no delimiter in src
254 */
255 const splitFirst = (src, delimiter, fill) => {
256 const idx = src.indexOf(delimiter);
257 if (idx >= 0) {
258 return [ src.slice(0, idx), src.slice(idx + 1) ];
259 } else {
260 return [ src, fill ];
261 }
262 };
263
264 /**
265 * Generate a new request identifier, a time/host-based uuid.
266 * @returns {String}
267 */
268 const requestId = () => {
269 return uuid.v1();
270 };
271
272 /**
273 * Do nothing.
274 */
275 const nop = () => { /**/ };
276
277 /**
278 * A logger object which does nothing.
279 */
280 const nullLogger = {
281 error: nop,
282 warn: nop,
283 info: nop,
284 log: nop,
285 debug: nop,
286 };
287
288 /**
289 * Populates any absent logger level functions on a logger object.
290 * @param {Object} logger
291 * @returns {Object}
292 * @deprecated after v1.2.9 (this is not our responsibility)
293 */
294 const ensureLoggerLevels = (logger = {}) => {
295 for (const level in nullLogger) {
296 if (! (level in logger)) {
297 logger[level] = nullLogger[level];
298 }
299 }
300 return logger;
301 };
302
303 /**
304 * Merges folded header lines
305 * @param {String[]} lines
306 * @returns {String}
307 */
308 const unfoldHeaderLines = (lines) => {
309 const foldedLineRE = /^(\t| +)(.*)$/;
310 if (lines) {
311 lines.reduceRight((_, line, idx) => { // NOSONAR
312 const result = foldedLineRE.exec(line);
313 if (result && idx) {
314 const prevIdx = idx - 1;
315 const mergedLine = `${lines[prevIdx]} ${result[2]}`;
316 lines.splice(prevIdx, 2, mergedLine);
317 return mergedLine;
318 }
319 }, null);
320 }
321 return lines;
322 };
323
324 module.exports = {
325 ensureLoggerLevels,
326 fileScope,
327 generateETag,
328 get,
329 handlerLogData,
330 httpStatusCodeClass,
331 isClientCached,
332 mergeDeep,
333 mergeEnum,
334 nop,
335 nullLogger,
336 obscureAuthorizationHeader,
337 pick,
338 requestId,
339 requestLogData,
340 responseLogData,
341 scrubHeaderObject,
342 setOptions,
343 splitFirst,
344 unfoldHeaderLines,
345 };