450 lines
13 KiB
JavaScript
450 lines
13 KiB
JavaScript
/**
|
|
* 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<Object>} 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<Boolean>} 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<Object>} 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()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a JSON replacer function that handles Map, Set, and circular references.
|
|
* This replacer can be used with JSON.stringify to safely serialize complex objects.
|
|
*
|
|
* @returns {Function} A replacer function for JSON.stringify that:
|
|
* - Converts Map instances to plain objects
|
|
* - Converts Set instances to arrays
|
|
* - Replaces circular references with '[Circular]' marker
|
|
*
|
|
* @example
|
|
* const obj = { name: 'test', map: new Map([['key', 'value']]) };
|
|
* obj.self = obj; // circular reference
|
|
* JSON.stringify(obj, Utils.getCircularReplacer());
|
|
* // Output: {"name":"test","map":{"key":"value"},"self":"[Circular]"}
|
|
*/
|
|
static getCircularReplacer() {
|
|
const seen = new WeakSet();
|
|
return (key, value) => {
|
|
if (value instanceof Map) {
|
|
return Object.fromEntries(value);
|
|
}
|
|
if (value instanceof Set) {
|
|
return Array.from(value);
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
if (seen.has(value)) {
|
|
return '[Circular]';
|
|
}
|
|
seen.add(value);
|
|
}
|
|
return value;
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = Utils;
|