monitor/core/lib/misc.ts
2025-04-16 22:30:27 +07:00

302 lines
9.4 KiB
TypeScript

import chalk from 'chalk';
import dateFormat from 'dateformat';
import humanizeDuration, { HumanizerOptions } from 'humanize-duration';
import { DeepReadonly } from 'utility-types';
export const regexHoursMinutes = /^(?<hours>[01]?[0-9]|2[0-4]):(?<minutes>[0-5][0-9])$/;
/**
* Extracts hours and minutes from an string containing times
*/
export const parseSchedule = (scheduleTimes: string[]) => {
const valid: {
string: string;
hours: number;
minutes: number;
}[] = [];
const invalid = [];
for (const timeInput of scheduleTimes) {
if (typeof timeInput !== 'string') continue;
const timeTrim = timeInput.trim();
if (!timeTrim.length) continue;
const m = timeTrim.match(regexHoursMinutes);
if (m && m.groups?.hours && m.groups?.minutes) {
if (m.groups.hours === '24') m.groups.hours = '00'; //Americans, amirite?!?!
const timeStr = m.groups.hours.padStart(2, '0') + ':' + m.groups.minutes.padStart(2, '0');
if (valid.some(item => item.string === timeStr)) continue;
valid.push({
string: timeStr,
hours: parseInt(m.groups.hours),
minutes: parseInt(m.groups.minutes),
});
} else {
invalid.push(timeTrim);
}
}
valid.sort((a, b) => {
return a.hours - b.hours || a.minutes - b.minutes;
});
return { valid, invalid };
};
/**
* Redacts known keys and tokens from a string
* @deprecated Use redactApiKeysArr instead
*/
export const redactApiKeys = (src: string) => {
if (typeof src !== 'string' || !src.length) return src;
return src
.replace(/licenseKey\s+["']?cfxk_\w{1,60}_(\w+)["']?.?$/gim, 'licenseKey [REDACTED cfxk...$1]')
.replace(/steam_webApiKey\s+["']?\w{32}["']?.?$/gim, 'steam_webApiKey [REDACTED]')
.replace(/sv_tebexSecret\s+["']?\w{40}["']?.?$/gim, 'sv_tebexSecret [REDACTED]')
.replace(/rcon_password\s+["']?[^"']+["']?.?$/gim, 'rcon_password [REDACTED]')
.replace(/mysql_connection_string\s+["']?[^"']+["']?.?$/gim, 'mysql_connection_string [REDACTED]')
.replace(/discord\.com\/api\/webhooks\/\d{17,20}\/[\w\-_./=]{10,}(.*)/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]');
};
/**
* Redacts known keys and tokens from an array of startup arguments.
*/
export const redactStartupSecrets = (args: string[]): string[] => {
if (!Array.isArray(args) || args.length === 0) return args;
const redactionRules: ApiRedactionRuleset = {
sv_licenseKey: {
regex: /^cfxk_\w{1,60}_(\w+)$/i,
replacement: (_match, p1) => `[REDACTED cfxk...${p1}]`,
},
steam_webApiKey: {
regex: /^\w{32}$/i,
replacement: '[REDACTED]',
},
sv_tebexSecret: {
regex: /^\w{40}$/i,
replacement: '[REDACTED]',
},
rcon_password: {
replacement: '[REDACTED]',
},
mysql_connection_string: {
replacement: '[REDACTED]',
},
tx2faSecret: {
replacement: '[REDACTED]',
},
'txAdmin-luaComToken': {
replacement: '[REDACTED]',
},
};
let outArgs: string[] = [];
for (let i = 0; i < args.length; i++) {
const currElem = args[i];
const currElemLower = currElem.toLocaleLowerCase();
const ruleMatchingPrefix = Object.keys(redactionRules).find((key) =>
currElemLower.includes(key.toLocaleLowerCase())
);
// If no rule matches or there is no subsequent element, just push the current element.
if (!ruleMatchingPrefix || i + 1 >= args.length) {
outArgs.push(currElem);
continue;
}
const rule = redactionRules[ruleMatchingPrefix];
const nextElem = args[i + 1];
// If the secret doesn't match the expected regex, treat it as a normal argument.
if (rule.regex && !nextElem.match(rule.regex)) {
outArgs.push(currElem);
continue;
}
// Push the key and then the redacted secret.
outArgs.push(currElem);
if (typeof rule.replacement === 'string') {
outArgs.push(rule.replacement);
} else if (rule.regex) {
outArgs.push(nextElem.replace(rule.regex, rule.replacement));
}
// Skip the secret value we just processed.
i++;
}
//Apply standalone redaction rules
outArgs = outArgs.map((arg) => arg
.replace(/discord\.com\/api\/webhooks\/\d{17,20}\/[\w\-_./=]{10,}(.*)/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]')
);
if (args.length !== outArgs.length) {
throw new Error('Input and output lengths are different after redaction.');
}
return outArgs;
};
type ApiRedactionRule = {
regex?: RegExp;
replacement: string | ((...args: any[]) => string);
};
type ApiRedactionRuleset = Record<string, ApiRedactionRule>;
/**
* Returns the unix timestamp in seconds.
*/
export const now = () => Math.round(Date.now() / 1000);
/**
* Returns the current time in HH:MM:ss format
*/
export const getTimeHms = (time?: string | number | Date) => dateFormat(time ?? new Date(), 'HH:MM:ss');
/**
* Returns the current time in filename-friendly format
*/
export const getTimeFilename = (time?: string | number | Date) => dateFormat(time ?? new Date(), 'yyyy-mm-dd_HH-MM-ss');
/**
* Converts a number of milliseconds to english words
* Accepts a humanizeDuration config object
* eg: msToDuration(ms, { units: ['h', 'm'] });
*/
export const msToDuration = humanizeDuration.humanizer({
round: true,
units: ['d', 'h', 'm'],
fallbacks: ['en'],
} satisfies HumanizerOptions);
/**
* Converts a number of milliseconds to short-ish english words
*/
export const msToShortishDuration = humanizeDuration.humanizer({
round: true,
units: ['d', 'h', 'm'],
largest: 2,
language: 'shortishEn',
languages: {
shortishEn: {
y: (c) => 'year' + (c === 1 ? '' : 's'),
mo: (c) => 'month' + (c === 1 ? '' : 's'),
w: (c) => 'week' + (c === 1 ? '' : 's'),
d: (c) => 'day' + (c === 1 ? '' : 's'),
h: (c) => 'hr' + (c === 1 ? '' : 's'),
m: (c) => 'min' + (c === 1 ? '' : 's'),
s: (c) => 'sec' + (c === 1 ? '' : 's'),
ms: (c) => 'ms',
},
},
});
/**
* Converts a number of milliseconds to shortest english representation possible
*/
export const msToShortestDuration = humanizeDuration.humanizer({
round: true,
units: ['d', 'h', 'm', 's'],
delimiter: '',
spacer: '',
largest: 2,
language: 'shortestEn',
languages: {
shortestEn: {
y: () => 'y',
mo: () => 'mo',
w: () => 'w',
d: () => 'd',
h: () => 'h',
m: () => 'm',
s: () => 's',
ms: () => 'ms',
},
},
});
/**
* Shorthand to convert seconds to the shortest english representation possible
*/
export const secsToShortestDuration = (ms: number, options?: humanizeDuration.Options) => {
return msToShortestDuration(ms * 1000, options);
};
/**
* Returns false if any argument is undefined
*/
export const anyUndefined = (...args: any) => [...args].some((x) => (typeof x === 'undefined'));
/**
* Calculates expiration and duration from a ban duration string like "1 day"
*/
export const calcExpirationFromDuration = (inputDuration: string) => {
let expiration;
let duration;
if (inputDuration === 'permanent') {
expiration = false as const;
} else {
const [multiplierInput, unit] = inputDuration.split(/\s+/);
const multiplier = parseInt(multiplierInput);
if (isNaN(multiplier) || multiplier < 1) {
throw new Error(`The duration number must be at least 1.`);
}
if (unit.startsWith('hour')) {
duration = multiplier * 3600;
} else if (unit.startsWith('day')) {
duration = multiplier * 86400;
} else if (unit.startsWith('week')) {
duration = multiplier * 604800;
} else if (unit.startsWith('month')) {
duration = multiplier * 2592000; //30 days
} else {
throw new Error(`Invalid ban duration. Supported units: hours, days, weeks, months`);
}
expiration = now() + duration;
}
return { expiration, duration };
};
/**
* Parses a number or string to a float with a limited precision.
*/
export const parseLimitedFloat = (src: number | string, precision = 6) => {
const srcAsNum = typeof src === 'string' ? parseFloat(src) : src;
return parseFloat(srcAsNum.toFixed(precision));
}
/**
* Deeply freezes an object and all its nested properties
*/
export const deepFreeze = <T extends Record<string, any>>(obj: T) => {
Object.freeze(obj);
Object.getOwnPropertyNames(obj).forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(obj, prop)
&& obj[prop] !== null
&& (typeof obj[prop] === 'object' || typeof obj[prop] === 'function')
&& !Object.isFrozen(obj[prop])
) {
deepFreeze(obj[prop] as object);
}
});
return obj;
//FIXME: using DeepReadonly<T> will cause ts errors in ConfigStore
// return obj as DeepReadonly<T>;
};
/**
* Returns a chalk.inverse of a string with a 1ch padding
*/
export const chalkInversePad = (str: string) => chalk.inverse(` ${str} `);