potentially serve static files with static headers
[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 * Return a function which combines a part of the filename with a scope, for use in logging.
15 * @param {string} filename
16 */
17 const fileScope = (filename) => {
18 let fScope = path.basename(filename, '.js');
19 if (fScope === 'index') {
20 fScope = path.basename(path.dirname(filename));
21 }
22 return (scope) => `${fScope}:${scope}`;
23 }
24
25 /**
26 * Simple ETag from data.
27 * @param {string} filePath
28 * @param {object} fileStat
29 * @param {*} fileData
30 */
31 const generateETag = (_filePath, fileStat, fileData) => {
32 const hash = crypto.createHash('sha256');
33 if (fileStat && fileStat.mtimeMs) {
34 hash.update(fileStat.mtimeMs.toString());
35 }
36 hash.update(fileData);
37 const digest = hash.digest('base64').replace('=', '');
38 return `"${digest}"`;
39 };
40
41 /**
42 * @param {object} obj
43 * @param {string} prop
44 * @param {*} def
45 */
46 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
47
48 /**
49 * @param {http.ClientRequest} req
50 * @param {http.ServerResponse} res
51 * @param {object} ctx
52 */
53 const handlerLogData = (req, res, ctx) => ({
54 req: requestLogData(req),
55 res: responseLogData(res),
56 ctx,
57 });
58
59 /**
60 *
61 * @param {http.ClientRequest} req
62 * @param {Number} modifiedTimeMs
63 * @param {string} eTag
64 */
65 const isClientCached = (req, modifiedTimeMs, eTag) => {
66 let clientCached = false;
67
68 const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
69 if (ifModifiedSince) {
70 const ifModifiedSinceMs = Date.parse(ifModifiedSince);
71 if (modifiedTimeMs < ifModifiedSinceMs) {
72 clientCached = true;
73 }
74 }
75
76 const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
77 if (ifNoneMatch) {
78 const matches = ifNoneMatch.split(',').map((m) => m.trim());
79 if (matches.includes(eTag)
80 || (ifNoneMatch === '*' && eTag)) {
81 clientCached = true;
82 } else {
83 // If if-none-matched header is present, it takes precedence over modified-since.
84 clientCached = false;
85 }
86 }
87
88 return clientCached;
89 };
90
91 /**
92 * Shallow merge for enums, to be called by derived constructor.
93 * Expects only one-level deep, is not recursive!
94 * @param {Object} origEnum
95 * @param {Object} additionalEnum
96 */
97 const mergeEnum = (origEnum, additionalEnum) => {
98 for (const e of Object.keys(additionalEnum)) {
99 if (typeof additionalEnum[e] === 'object') {
100 if (! (e in origEnum)) {
101 origEnum[e] = {};
102 }
103 Object.assign(origEnum[e], additionalEnum[e]);
104 } else {
105 origEnum[e] = additionalEnum[e];
106 }
107 }
108 return origEnum;
109 };
110
111 /**
112 * Isolate the general category of an http status code.
113 * @param {Number} statusCode
114 * @returns {Number}
115 */
116 const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
117
118 const _isObject = (obj) => obj && typeof obj === 'object';
119 const _isArray = (obj) => Array.isArray(obj);
120 /**
121 * Return a new object with all objects combined.
122 * @param {...any} objects
123 * @returns
124 */
125 const mergeDeep = (...objects) => {
126 return objects.reduce((acc, obj) => {
127 const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
128 objectProperties.forEach((k) => {
129 const aVal = acc[k];
130 const oVal = obj[k];
131 if (_isArray(oVal)) {
132 acc[k] = (_isArray(aVal) ? aVal : []).concat(oVal);
133 } else if (_isObject(oVal)) {
134 acc[k] = mergeDeep(_isObject(aVal) ? aVal : {}, oVal);
135 } else {
136 acc[k] = oVal;
137 }
138 });
139 return acc;
140 }, {});
141 };
142
143
144 /**
145 * Return a new object with selected props.
146 * @param {Object} obj
147 * @param {string[]} props
148 */
149 const pick = (obj, props) => {
150 const picked = {};
151 props.forEach((prop) => {
152 if (prop in obj) {
153 picked[prop] = obj[prop];
154 }
155 });
156 return picked;
157 };
158
159 /**
160 * Return a subset of a request object, suitable for logging.
161 * @param {http.ClientRequest} req
162 */
163 const requestLogData = (req) => {
164 return pick(req, [
165 'method',
166 'url',
167 'httpVersion',
168 'headers',
169 'trailers',
170 ]);
171 };
172
173
174 /**
175 * Return a subset of a response object, suitable for logging.
176 * @param {http.ServerResponse} res
177 */
178 const responseLogData = (res) => {
179 const response = pick(res, [
180 'statusCode',
181 'statusMessage',
182 ]);
183 response.headers = res.getHeaders();
184 return response;
185 };
186
187
188 /**
189 * Store updates to defaultOptions, but no new properties.
190 * @param {Object} target
191 * @param {Object} defaultOptions
192 * @param {Object} options
193 */
194 const setOptions = (target, defaultOptions, options) => {
195 Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
196 };
197
198 /**
199 * Return a list of source split at first delimiter.
200 * @param {string} src
201 * @param {string} delimiter
202 * @param {string} fill trailing stand-in if no delimiter in src
203 */
204 const splitFirst = (src, delimiter, fill) => {
205 const idx = src.indexOf(delimiter);
206 if (idx >= 0) {
207 return [ src.slice(0, idx), src.slice(idx + 1) ];
208 } else {
209 return [ src, fill ];
210 }
211 };
212
213 /**
214 * Generate a new request identifier.
215 * @returns {String}
216 */
217 const requestId = () => {
218 return uuid.v1();
219 };
220
221 const nop = () => { /**/ };
222 const nullLogger = {
223 error: nop,
224 warn: nop,
225 info: nop,
226 log: nop,
227 debug: nop,
228 };
229
230 /**
231 * Populates any absent logger levels.
232 * @param {Object} logger
233 */
234 const ensureLoggerLevels = (logger = {}) => {
235 for (const level in nullLogger) {
236 if (! (level in logger)) {
237 logger[level] = nullLogger[level];
238 }
239 }
240 return logger;
241 };
242
243 /**
244 * Merges folded header lines
245 * @param {String[]} lines
246 */
247 const unfoldHeaderLines = (lines) => {
248 const foldedLineRE = /^(\t| +)(.*)$/;
249 if (lines) {
250 lines.reduceRight((_, line, idx) => {
251 const result = foldedLineRE.exec(line);
252 if (result && idx) {
253 const prevIdx = idx - 1;
254 const mergedLine = `${lines[prevIdx]} ${result[2]}`;
255 lines.splice(prevIdx, 2, mergedLine);
256 return mergedLine;
257 }
258 }, null);
259 }
260 return lines;
261 };
262
263 module.exports = {
264 ensureLoggerLevels,
265 fileScope,
266 generateETag,
267 get,
268 handlerLogData,
269 httpStatusCodeClass,
270 isClientCached,
271 mergeDeep,
272 mergeEnum,
273 nop,
274 nullLogger,
275 pick,
276 requestId,
277 requestLogData,
278 responseLogData,
279 setOptions,
280 splitFirst,
281 unfoldHeaderLines,
282 };