import type { NextFunction, Request, Response } from 'express'; import { Base64 } from 'js-base64'; import type { User } from './user.ts'; import type locales from '~~/locale/locales.ts'; import type { Database } from '~~/server/db.ts'; export type Primitive = string | number | boolean | null | undefined; export type Nullable = Base | null | undefined; export interface DeepObject { [key: string]: Primitive | DeepObject | DeepObject[]; } const runningSnapshotTests = (() => { // Server or Node-like environment if (typeof process !== 'undefined' && process.env?.RUN_SNAPSHOT_TESTS) { return true; } // Nuxt client environment try { // In a try-catch block to prevent errors if not running in the correct // context (e.g. Cannot use 'import.meta' outside a module). if (import.meta.env?.RUN_SNAPSHOT_TESTS) { return true; } } catch { // ignore errors } return false; })(); /** * Generates a random number greater than or equal to 0 and less than 1. If the * app is being run in a snapshot test environment, will always return 0.5. This * is to ensure that the snapshots are consistent by enforcing a deterministic * result. */ export const random = runningSnapshotTests ? () => 0.5 : Math.random; /** * Gets the current date and time. If the app is being run in a snapshot test * environment, will return a fixed date of 2025-01-01T00:00:00.000Z. This is to * ensure that the snapshots are consistent by enforcing a deterministic result. */ export const newDate = runningSnapshotTests ? () => new Date('2025-01-01T00:00:00.000Z') : () => new Date(); /** * Gets the current timestamp in seconds. If the app is being run in a snapshot * test environment, will return a fixed timestamp equivalent to * 2025-01-01T00:00:00.000Z. This is to ensure that the snapshots are consistent * by enforcing a deterministic result. */ export const now = runningSnapshotTests ? () => 1735689600 // 2025-01-01T00:00:00.000Z : () => Math.floor(Date.now() / 1000); type GeneratorFunc = (...args: Args) => Generator; export const buildDict = ( fn: GeneratorFunc<[K, V], Args>, ...args: Args ): Record => { const dict: Record = {} as Record; for (const [key, value] of fn(...args)) { dict[key] = value; } return dict; }; export const buildList = (fn: GeneratorFunc, ...args: Args): V[] => { const list = []; for (const value of fn(...args)) { list.push(value); } return list; }; export const deepGet = (obj: object, path: string): unknown => { let value: any = obj; for (const part of path.split('.')) { value = value[part]; if (value === undefined || value === null) { break; } } return value; }; export function* deepListKeys(obj: object): Generator { for (const [key, value] of Object.entries(obj)) { if (value instanceof Object && !Array.isArray(value)) { for (const subkey of deepListKeys(value)) { yield `${key}.${subkey}`; } } else { yield key; } } } export const clearUrl = (url: string): string => decodeURIComponent( url .trim() // Remove leading and trailing whitespace .replace(/http(s)?:\/\/(www\.)?|mailto:/g, '') // Remove protocol and www .split(/(\?|#)/)[0] // Remove query and fragment parts .replace(/\/$/, ''), // Remove trailing slash ); export const buildImageUrl = (cloudfrontUrl: string, imageId: string, size: string): string => { return `${cloudfrontUrl}/images/${imageId}-${size}.png`; }; export const makeId = ( length: number, characters: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', ): string => Array(length) .fill('') .map(() => characters.charAt(Math.floor(random() * characters.length))) .join(''); export const fallbackAvatar = (user: Pick, size: number = 240): string => { return `https://avi.avris.it/shape-${size}/${Base64.encode(user.username).replace(/\+/g, '-') .replace(/\//g, '_')}.png`; }; export const sha256 = async (message: string) => Array.from(new Uint8Array( await crypto.subtle.digest( 'sha-256', new TextEncoder().encode(message), ), )) .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); export const gravatar = async ( user: Pick & { emailHash?: string }, size: number = 240, ): Promise => { const emailHash = user.emailHash ?? await sha256(user.email); return `https://gravatar.com/avatar/${emailHash}?d=${encodeURIComponent(fallbackAvatar(user, size))}&s=${size}`; }; export interface DictEntry { key: K; value: V; } export const dictToList = (dict: Record): DictEntry[] => { const list = []; for (const key in dict) { if (Object.hasOwn(dict, key)) { list.push({ key, value: dict[key] }); } } return list; }; export const listToDict = (list: DictEntry[]): Record => { if (Object.keys(list).length === 0) { return {} as Record; } const dict: Record = {} as Record; for (const el of list) { dict[el.key] = el.value; } return dict; }; export const fromUnionEntries = ( entries: Iterable, ): Record => { return Object.fromEntries(entries) as Record; }; export function curry(func: (this: T, a: A, b: B) => R): (a: A) => (this: T, b: B) => R { return function curried(a: A) { return function (b: B) { return func.apply(this, [a, b]); }; }; } export const capitalise = (word: string): string => word.substring(0, 1).toUpperCase() + word.substring(1); export const camelCase = (words: string[]): string => { const text = words.map(capitalise).join(''); return text.substring(0, 1).toLowerCase() + text.substring(1); }; export const removeSuffix = (word: string, suffix: string) => { if (!word.endsWith(suffix)) { return word; } return word.substring(0, word.length - suffix.length); }; export const isEmoji = (string: string): boolean => { return /^((?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|[\u2700-\u27bf]|[\u25aa-\u25ab]|[\u25fb-\u25fe]|[\u2600-\u26FF]|[\u23e9-\u23f3]|[\u23f8-\u23fa]|[\u2190-\u21ff]|\ud83c([\udd70-\udd71]|[\udd7e-\udd7f]|[\udd91-\udd9a]|[\udde6-\uddff]|[\ude01-\ude02]|[\ude32-\ude3a]|[\ude50-\ude51]|\udd8e|\ude1a|\ude2f|\udccf|\udc04)|\u3299|\u3297|\u303d|\u3030|\u24c2|\u203c|\u2049|\u25b6|\u25c0|\u00a9|\u00ae|\u2122|\u2139|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|\ud83c|\u2934|\u2935)$/.test(string); }; export function zip(list: [K, V][], reverse: false): Record; export function zip(list: [V, K][], reverse: true): Record; export function zip(list: [K, V][] | [V, K][], reverse: boolean): Record { const zipped = {} as Record; for (const [key, value] of list) { if (reverse) { // @ts-expect-error: General Type Error; No actual issue zipped[value] = key; } else { // @ts-expect-error: General Type Error; No actual issue zipped[key] = value; } } return zipped; } // https://stackoverflow.com/a/6274381/3297012 export const shuffle = (array: T[]): T[] => new Array(...array).sort(() => random() - 0.5) as T[]; export const randomItem = (array: T[]): T => shuffle(array)[0]; interface WeightedItem { chance?: number; } export const randomItemWeighted = (array: T[]): T => { const totalChance = array.reduce((sum, obj) => sum + (obj.chance ?? 1), 0); let randomChance = random() * totalChance; for (const el of array) { randomChance -= el.chance ?? 1; if (randomChance <= 0) { return el; } } return array[array.length - 1]; }; export const randomNumber = (min: number, max: number): number => Math.floor(random() * (max - min + 1)) + min; export type LocaleCode = typeof locales[number]['code']; export enum PermissionAreas { Superuser = '*', Basic = 'basic', Panel = 'panel', Users = 'users', Sources = 'sources', Nouns = 'nouns', Terms = 'terms', Inclusive = 'inclusive', Census = 'census', Names = 'names', Translations = 'translations', Code = 'code', Org = 'org', Impersonate = 'impersonate', Community = 'community', Blog = 'blog', External = 'external', Null = '', } const RESTRICTED_AREAS = ['code', 'org', 'impersonate', 'community']; export const isPermissionArea = (value: string): value is PermissionAreas => { return Object.values(PermissionAreas).includes(value as PermissionAreas); }; export const isGrantedForUser = ( user: Pick, requestedLocale: LocaleCode | null, requestedArea: PermissionAreas = PermissionAreas.Null, ): boolean => { if (requestedArea === PermissionAreas.Superuser) { // only super-admin return user.roles.split('|').includes('*'); } if (!user.roles) { return false; } for (const permission of user.roles.split('|')) { if (permission === PermissionAreas.Superuser && !RESTRICTED_AREAS.includes(requestedArea)) { return true; } const [givenLocale, givenArea] = permission.split('-'); if (!( givenLocale === PermissionAreas.Superuser || givenLocale === requestedLocale || requestedLocale === null )) { continue; } if (givenArea === PermissionAreas.External && requestedArea !== PermissionAreas.External) { continue; } if (givenArea === PermissionAreas.Superuser && !RESTRICTED_AREAS.includes(requestedArea)) { return true; } if ( givenArea === requestedArea || requestedArea === PermissionAreas.Null || requestedArea === PermissionAreas.Panel ) { return true; } } return false; }; type ErrorAsyncFunction = (req: Request, res: Response, next: NextFunction) => Promise; export const handleErrorAsync = (func: ErrorAsyncFunction) => { return (req: Request, res: Response, next: NextFunction): void => { func(req, res, next).catch((error: unknown) => next(error)); }; }; export const clearLinkedText = (text: string, quotes: boolean = true): string => { text = text .replace(/{[^}]+=([^}=]+)}/g, '$1') .replace(/{([^}]+)}/g, '$1'); if (quotes) { text = text.replace(/[„”"']/g, ''); } text = text.replace(/\s+/g, ' '); return text; }; export const sortClearedLinkedText = , K extends string>( items: T[], key: K, ): T[] => items.sort( (a, b) => clearLinkedText(a[key].toLowerCase()) .localeCompare(clearLinkedText(b[key].toLowerCase())), ); export const clearKey = (key: string | null): string | null => key ? key.replace(/'/g, '_').toLowerCase() : null; export const sleep = (milliseconds: number): Promise => new Promise( (resolve) => setTimeout(resolve, milliseconds), ); export const splitSlashes = (path: string): string[] => { const chunks = []; let escape = false; let currentChunk = ''; for (const character of path) { if (escape) { currentChunk += `\`${character}`; escape = false; continue; } switch (character) { case '`': escape = true; break; case '/': chunks.push(currentChunk); currentChunk = ''; break; default: currentChunk += character; break; } } chunks.push(currentChunk); return chunks; }; const escapeChars = { '&': '&', '<': '<', '>': '>', '"': '"', }; export const escapeHtml = (text: string): string => { return Object.entries(escapeChars).reduce( (cur, [character, replacement]) => cur.replaceAll(character, replacement), text, ); }; export const escapeControlSymbols = (text: string | null): string | null => { // a backtick is used because browsers replace backslashes // with forward slashes if they are not url-encoded return text ? text.replaceAll(/[`&/|,:]/g, '`$&') : null; }; export const unescapeControlSymbols = ( text: string | null, ): string | null => text ? text.replaceAll(/`(.)/g, '$1') : null; export const escapePronunciationString = (text: string): string => { return text.replaceAll('\\', '\\\\') .replaceAll('/', '\\/'); }; export const unescapePronunciationString = (pronunciationString: string): string => { return pronunciationString.replaceAll('\\/', '/') .replaceAll('\\\\', '\\'); }; export const convertPronunciationStringToSsml = (pronunciationString: string): string => { const escapedString = escapeHtml(pronunciationString); let ssml = ''; let escape = false; let currentPhonemes = null; for (const character of escapedString) { if (escape) { if (currentPhonemes === null) { ssml += character; } else { currentPhonemes += character; } escape = false; } else { if (character === '\\') { escape = true; } else if (character === '/') { if (currentPhonemes === null) { currentPhonemes = ''; } else { ssml += ``; currentPhonemes = null; } } else { if (currentPhonemes === null) { ssml += character; } else { currentPhonemes += character; } } } } if (currentPhonemes !== null) { ssml += `/${currentPhonemes}`; } return `${ssml}`; }; export const convertPronunciationStringToNarakeetFormat = (pronunciationString: string): string => { const escapedString = escapeHtml(pronunciationString); let output = ''; let escape = false; let currentPhonemes = null; for (const character of escapedString) { if (escape) { if (currentPhonemes === null) { output += character; } else { currentPhonemes += character; } escape = false; } else { if (character === '\\') { escape = true; } else if (character === '/') { if (currentPhonemes === null) { currentPhonemes = ''; } else { output += `[${currentPhonemes}]{ipa}`; currentPhonemes = null; } } else { if (currentPhonemes === null) { output += character; } else { currentPhonemes += character; } } } } if (currentPhonemes !== null) { output += `/${currentPhonemes}`; } return output; }; export class ImmutableArray extends Array { override map(callbackFn: (value: T, index: number, array: T[]) => U): ImmutableArray { return super.map(callbackFn) as ImmutableArray; } override filter(predicate: (value: T, index: number, array: T[]) => unknown): ImmutableArray { return super.filter(predicate) as ImmutableArray; } sorted(compareFn?: (a: T, b: T) => number): ImmutableArray { return new ImmutableArray(...[...this].sort(compareFn)); } randomElement(): T { return this[Math.floor(random() * this.length)]; } groupBy(m: (element: T) => string | number): ImmutableArray<[string | number, ImmutableArray]> { const keys: Record = {}; const grouped: ImmutableArray<[string | number, ImmutableArray]> = new ImmutableArray(); for (const el of this) { const key = m(el); if (!Object.hasOwn(keys, key)) { keys[key] = grouped.length; grouped.push([key, new ImmutableArray()]); } grouped[keys[key]][1].push(el); } return grouped; } indexOrFallback(index: number, fallback: T): T { return this.length > index ? this[index] : fallback; } } export const groupBy = (list: V[], fn: (element: V) => string): Record => { const grouped: Record = {}; for (const el of list) { const key = fn(el); if (!Object.hasOwn(grouped, key)) { grouped[key] = []; } grouped[key].push(el); } return grouped; }; export const obfuscateEmail = (email: string): string | null => { const [username, hostname] = email.toLowerCase().split('@'); const tld = hostname.split('.').slice(-1).pop(); return tld === 'oauth' ? null : `${username.substring(0, username.length <= 5 ? 1 : 3)}*****@*****.${tld}`; }; // https://newbedev.com/dynamic-deep-setting-for-a-javascript-object export const deepSet = (obj: object, path: string, value: unknown): void => { const a = path.split('.'); let o: any = obj; while (a.length - 1) { const n = a.shift()!; if (!(n in o)) { o[n] = {}; } o = o[n]; } o[a[0]] = value; }; type AdminUser = Pick; export const findAdmins = async (db: Database, locale: LocaleCode, area: PermissionAreas): Promise => { const admins = await db.all(` SELECT username, email, roles, adminNotifications FROM users WHERE (roles != '' AND roles != '*-external') `); return admins.filter((admin) => isGrantedForUser(admin, locale, area as PermissionAreas)); }; export const isValidLink = (url: string | URL): boolean => { return URL.canParse(url) ? /^(http(s)?)|mailto:/.test(new URL(url).protocol) : false; }; export const addSlash = (link: string): string => { return link + (['*', '\''].includes(link.substring(link.length - 1)) ? '/' : ''); }; export const filterObjectKeys = , K extends keyof T>( obj: T, keysToKeep: K[], ): Pick => { return keysToKeep.reduce((filteredObj, key) => { if (key in obj) { filteredObj[key] = obj[key]; } return filteredObj; }, {} as Pick); }; export const formatSize = (number: number): string => { const pow = Math.log10(number); if (pow > 9) { return `${(number / 10 ** 9).toFixed(1)}\u00a0GB`; } if (pow > 6) { return `${(number / 10 ** 6).toFixed(1)}\u00a0MB`; } if (pow > 3) { return `${(number / 10 ** 3).toFixed(1)}\u00a0kB`; } return `${number}\u00a0B`; }; export const executeUnlessPrerendering = (fn: () => void): (() => void) => { return () => { if ((document as any).prerendering) { return void document.addEventListener('prerenderingchange', fn, { once: true }); } fn(); }; };