import chalk from 'chalk'; import dateFormat from 'dateformat'; import humanizeDuration, { HumanizerOptions } from 'humanize-duration'; import { DeepReadonly } from 'utility-types'; export const regexHoursMinutes = /^(?[01]?[0-9]|2[0-4]):(?[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; /** * 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 = >(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 will cause ts errors in ConfigStore // return obj as DeepReadonly; }; /** * Returns a chalk.inverse of a string with a 1ch padding */ export const chalkInversePad = (str: string) => chalk.inverse(` ${str} `);