/** * utils.js * @file General purpose utilities * @description General purpose utilities. */ const path = require('path'); const fs = require('fs').promises; /** * The general purpose utilities. */ class Utils { /** * @function getLocalizedPath * @description Returns a localized file path accoring to the locale. * * Localized files are searched in subfolders of a given path, e.g. * * root/ * ├── base/ // base path to files * │ ├── example.html // default file * │ └── de/ // de language folder * │ │ └── example.html // de localized file * │ └── de-AT/ // de-AT locale folder * │ │ └── example.html // de-AT localized file * * Files are matched with the locale in the following order: * 1. Locale match, e.g. locale `de-AT` matches file in folder `de-AT`. * 2. Language match, e.g. locale `de-AT` matches file in folder `de`. * 3. Default; file in base folder is returned. * * @param {String} defaultPath The absolute file path, which is also * the default path returned if localization is not available. * @param {String} locale The locale. * @returns {Promise} The object contains: * - `path`: The path to the localized file, or the original path if * localization is not available. * - `subdir`: The subdirectory of the localized file, or undefined if * there is no matching localized file. */ static async getLocalizedPath(defaultPath, locale) { // Get file name and paths const file = path.basename(defaultPath); const basePath = path.dirname(defaultPath); // If locale is not set return default file if (!locale) { return { path: defaultPath }; } // Check file for locale exists const localePath = path.join(basePath, locale, file); const localeFileExists = await Utils.fileExists(localePath); // If file for locale exists return file if (localeFileExists) { return { path: localePath, subdir: locale }; } // Check file for language exists const language = locale.split('-')[0]; const languagePath = path.join(basePath, language, file); const languageFileExists = await Utils.fileExists(languagePath); // If file for language exists return file if (languageFileExists) { return { path: languagePath, subdir: language }; } // Return default file return { path: defaultPath }; } /** * @function fileExists * @description Checks whether a file exists. * @param {String} path The file path. * @returns {Promise} Is true if the file can be accessed, false otherwise. */ static async fileExists(path) { try { await fs.access(path); return true; } catch (e) { return false; } } /** * @function isPath * @description Evaluates whether a string is a file path (as opposed to a URL for example). * @param {String} s The string to evaluate. * @returns {Boolean} Returns true if the evaluated string is a path. */ static isPath(s) { return /(^\/)|(^\.\/)|(^\.\.\/)/.test(s); } /** * Flattens an object and crates new keys with custom delimiters. * @param {Object} obj The object to flatten. * @param {String} [delimiter='.'] The delimiter of the newly generated keys. * @param {Object} result * @returns {Object} The flattened object. **/ static flattenObject(obj, parentKey, delimiter = '.', result = {}) { for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const newKey = parentKey ? parentKey + delimiter + key : key; if (typeof obj[key] === 'object' && obj[key] !== null) { this.flattenObject(obj[key], newKey, delimiter, result); } else { result[newKey] = obj[key]; } } } return result; } /** * Determines whether an object is a Promise. * @param {any} object The object to validate. * @returns {Boolean} Returns true if the object is a promise. */ static isPromise(object) { return object instanceof Promise; } /** * Creates an object with all permutations of the original keys. * For example, this definition: * ``` * { * a: [true, false], * b: [1, 2], * c: ['x'] * } * ``` * permutates to: * ``` * [ * { a: true, b: 1, c: 'x' }, * { a: true, b: 2, c: 'x' }, * { a: false, b: 1, c: 'x' }, * { a: false, b: 2, c: 'x' } * ] * ``` * @param {Object} object The object to permutate. * @param {Integer} [index=0] The current key index. * @param {Object} [current={}] The current result entry being composed. * @param {Array} [results=[]] The resulting array of permutations. */ static getObjectKeyPermutations(object, index = 0, current = {}, results = []) { const keys = Object.keys(object); const key = keys[index]; const values = object[key]; for (const value of values) { current[key] = value; const nextIndex = index + 1; if (nextIndex < keys.length) { Utils.getObjectKeyPermutations(object, nextIndex, current, results); } else { const result = Object.assign({}, current); results.push(result); } } return results; } /** * Validates parameters and throws if a parameter is invalid. * Example parameter types syntax: * ``` * { * parameterName: { * t: 'boolean', * v: isBoolean, * o: true * }, * ... * } * ``` * @param {Object} params The parameters to validate. * @param {Array} types The parameter types used for validation. * @param {Object} types.t The parameter type; used for error message, not for validation. * @param {Object} types.v The function to validate the parameter value. * @param {Boolean} [types.o=false] Is true if the parameter is optional. */ static validateParams(params, types) { for (const key of Object.keys(params)) { const type = types[key]; const isOptional = !!type.o; const param = params[key]; if (!(isOptional && param == null) && !type.v(param)) { throw `Invalid parameter ${key} must be of type ${type.t} but is ${typeof param}`; } } } /** * Computes the relative date based on a string. * @param {String} text The string to interpret the date from. * @param {Date} now The date the string is comparing against. * @returns {Object} The relative date object. **/ static relativeTimeToDate(text, now = new Date()) { text = text.toLowerCase(); let parts = text.split(' '); // Filter out whitespace parts = parts.filter(part => part !== ''); const future = parts[0] === 'in'; const past = parts[parts.length - 1] === 'ago'; if (!future && !past && text !== 'now') { return { status: 'error', info: "Time should either start with 'in' or end with 'ago'", }; } if (future && past) { return { status: 'error', info: "Time cannot have both 'in' and 'ago'", }; } // strip the 'ago' or 'in' if (future) { parts = parts.slice(1); } else { // past parts = parts.slice(0, parts.length - 1); } if (parts.length % 2 !== 0 && text !== 'now') { return { status: 'error', info: 'Invalid time string. Dangling unit or number.', }; } const pairs = []; while (parts.length) { pairs.push([parts.shift(), parts.shift()]); } let seconds = 0; for (const [num, interval] of pairs) { const val = Number(num); if (!Number.isInteger(val)) { return { status: 'error', info: `'${num}' is not an integer.`, }; } switch (interval) { case 'yr': case 'yrs': case 'year': case 'years': seconds += val * 31536000; // 365 * 24 * 60 * 60 break; case 'wk': case 'wks': case 'week': case 'weeks': seconds += val * 604800; // 7 * 24 * 60 * 60 break; case 'd': case 'day': case 'days': seconds += val * 86400; // 24 * 60 * 60 break; case 'hr': case 'hrs': case 'hour': case 'hours': seconds += val * 3600; // 60 * 60 break; case 'min': case 'mins': case 'minute': case 'minutes': seconds += val * 60; break; case 'sec': case 'secs': case 'second': case 'seconds': seconds += val; break; default: return { status: 'error', info: `Invalid interval: '${interval}'`, }; } } const milliseconds = seconds * 1000; if (future) { return { status: 'success', info: 'future', result: new Date(now.valueOf() + milliseconds), }; } else if (past) { return { status: 'success', info: 'past', result: new Date(now.valueOf() - milliseconds), }; } else { return { status: 'success', info: 'present', result: new Date(now.valueOf()), }; } } /** * Deep-scans an object for a matching key/value definition. * @param {Object} obj The object to scan. * @param {String | undefined} key The key to match, or undefined if only the value should be matched. * @param {any | undefined} value The value to match, or undefined if only the key should be matched. * @returns {Boolean} True if a match was found, false otherwise. */ static objectContainsKeyValue(obj, key, value) { const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b; const isKeyMatch = k => isMatch(k, key); const isValueMatch = v => isMatch(v, value); for (const [k, v] of Object.entries(obj)) { if (key !== undefined && value === undefined && isKeyMatch(k)) { return true; } else if (key === undefined && value !== undefined && isValueMatch(v)) { return true; } else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) { return true; } if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) { return Utils.objectContainsKeyValue(v, key, value); } } return false; } static checkProhibitedKeywords(config, data) { if (config?.requestKeywordDenylist) { // Scan request data for denied keywords for (const keyword of config.requestKeywordDenylist) { const match = Utils.objectContainsKeyValue(data, keyword.key, keyword.value); if (match) { throw `Prohibited keyword in request data: ${JSON.stringify(keyword)}.`; } } } } /** * Moves the nested keys of a specified key in an object to the root of the object. * * @param {Object} obj The object to modify. * @param {String} key The key whose nested keys will be moved to root. * @returns {Object} The modified object, or the original object if no modification happened. * @example * const obj = { * a: 1, * b: { * c: 2, * d: 3 * }, * e: 4 * }; * addNestedKeysToRoot(obj, 'b'); * console.log(obj); * // Output: { a: 1, e: 4, c: 2, d: 3 } */ static addNestedKeysToRoot(obj, key) { if (obj[key] && typeof obj[key] === 'object') { // Add nested keys to root Object.assign(obj, { ...obj[key] }); // Delete original nested key delete obj[key]; } return obj; } /** * Encodes a string to be used in a URL. * @param {String} input The string to encode. * @returns {String} The encoded string. */ static encodeForUrl(input) { return encodeURIComponent(input).replace(/[!'.()*]/g, char => '%' + char.charCodeAt(0).toString(16).toUpperCase() ); } } module.exports = Utils;