| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | // all content here is shared between client and server |
| 3 | import _ from 'lodash' |
| 4 | import { VfsNodeStored } from './vfs' |
| 5 | import picomatch from 'picomatch/lib/picomatch' |
| 6 | import { HFS_REPO } from './cross-const' // point directly to the browser-compatible source |
| 7 | export * from './cross-const' |
| 8 | |
| 9 | export const WEBSITE = 'https://rejetto.com/hfs/' |
| 10 | export const REPO_URL = `https://github.com/${HFS_REPO}/` |
| 11 | export const WIKI_URL = REPO_URL + 'wiki/' |
| 12 | export const MINUTE = 60_000 |
| 13 | export const HOUR = 60 * MINUTE |
| 14 | export const DAY = 24 * HOUR |
| 15 | export const MAX_TILE_SIZE = 10 |
| 16 | export const FRONTEND_OPTIONS = { |
| 17 | file_menu_on_link: true, |
| 18 | tile_size: 0, |
| 19 | page_size: 100, |
| 20 | sort_by: 'name', |
| 21 | invert_order: false, |
| 22 | folders_first: true, |
| 23 | sort_numerics: false, |
| 24 | title_with_path: true, |
| 25 | theme: '', |
| 26 | auto_play_seconds: 5, |
| 27 | disableTranslation: false, |
| 28 | } |
| 29 | export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time', 'creation'] |
| 30 | export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' } |
| 31 | // had found an interesting way to infer a type from all the calls to defineConfig (by the literals passed), but would not be usable also by admin-panel |
| 32 | export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url', |
| 33 | 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips', |
| 34 | 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads', |
| 35 | 'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html', 'comments_storage', |
| 36 | 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy', 'mapped_port', 'upnp_enabled', 'show_uploader']) |
| 37 | export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' } |
| 38 | export type Dict<T=any> = Record<string, T> |
| 39 | export type Falsy = false | null | undefined | '' | 0 |
| 40 | type Truthy<T> = T extends false | '' | 0 | null | undefined | void ? never : T |
| 41 | export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>> |
| 42 | export type Callback<IN=void, OUT=void> = (x:IN) => OUT |
| 43 | export type Promisable<T> = T | Promise<T> |
| 44 | export type Functionable<T, Args extends any[] = any[]> = T | ((...args: Args) => T) |
| 45 | export type Timeout = ReturnType<typeof setTimeout> |
| 46 | export interface VfsPerms { |
| 47 | can_see?: WhoVfs |
| 48 | can_read?: WhoVfs |
| 49 | can_list?: WhoVfs |
| 50 | can_upload?: WhoVfs |
| 51 | can_delete?: WhoVfs |
| 52 | can_archive?: WhoVfs |
| 53 | } |
| 54 | export const WHO_ANYONE = true |
| 55 | export const WHO_NO_ONE = false |
| 56 | export const WHO_ANY_ACCOUNT = '*' |
| 57 | export const WHO_ADMIN = 'admin' |
| 58 | type AccountList = string[] |
| 59 | export type Who = typeof WHO_ANYONE | typeof WHO_NO_ONE | typeof WHO_ANY_ACCOUNT | typeof WHO_ADMIN |
| 60 | | AccountList // use false instead of empty array to keep the type boolean-able |
| 61 | export type WhoVfs = Who | keyof VfsPerms | WhoObject |
| 62 | export interface WhoObject { this?: WhoVfs, children?: WhoVfs } |
| 63 | export type Jsonify<T> = T extends string | number | boolean | null | undefined ? T : // undefined is necessary to preserve union types, like number|undefined |
| 64 | T extends Date ? string : |
| 65 | T extends (infer U)[] ? Jsonify<U>[] : |
| 66 | T extends object ? { [K in keyof T]: Jsonify<T[K]> } : |
| 67 | never |
| 68 | |
| 69 | export const defaultPerms: Required<VfsPerms> = { |
| 70 | can_read: WHO_ANYONE, |
| 71 | can_see: 'can_read', |
| 72 | can_list: 'can_read', |
| 73 | can_archive: 'can_read', |
| 74 | can_upload: WHO_ADMIN, |
| 75 | can_delete: WHO_ADMIN, |
| 76 | } |
| 77 | |
| 78 | export type VfsNodeAdminSend = { |
| 79 | name: string |
| 80 | type?: 'folder' |
| 81 | size?: number |
| 82 | birthtime?: Date |
| 83 | mtime?: Date |
| 84 | website?: true |
| 85 | byMasks?: VfsPerms |
| 86 | inherited?: VfsPerms |
| 87 | children?: VfsNodeAdminSend[] |
| 88 | } & Omit<VfsNodeStored, 'children'> |
| 89 | |
| 90 | export const PERM_KEYS = typedKeys(defaultPerms) |
| 91 | |
| 92 | export const VFS_STORED_KEYS: (keyof VfsNodeStored)[] = ['name', 'source', 'masks', 'default', 'accept', 'rename', |
| 93 | 'mime', 'url', 'target', 'comment', 'icon', 'order', 'children', ...PERM_KEYS] |
| 94 | |
| 95 | function constMap<T extends string>(a: T[]): { [K in T]: K } { |
| 96 | return Object.fromEntries(a.map(x => [x, x])) as { [K in T]: K }; |
| 97 | } |
| 98 | |
| 99 | export function isWhoObject(v: undefined | WhoVfs): v is WhoObject { |
| 100 | return v !== null && typeof v === 'object' && !Array.isArray(v) |
| 101 | } |
| 102 | |
| 103 | const MULTIPLIERS = ['', 'K', 'M', 'G', 'T'] |
| 104 | export declare namespace formatBytes { let k: number } |
| 105 | export function formatBytes(n: number, { post='B', k=0, digits=NaN, sep=' ' }={}) { |
| 106 | if (isNaN(Number(n)) || n < 0) |
| 107 | return '' |
| 108 | k ||= formatBytes.k ?? 1024 // default value |
| 109 | const i = n && Math.min(MULTIPLIERS.length - 1, Math.floor(Math.log2(n) / Math.log2(k))) |
| 110 | n /= k ** i |
| 111 | const nAsString = i && !isNaN(digits) ? n.toFixed(digits) |
| 112 | : _.round(n, isNaN(digits) ? (n >= 100 ? 0 : 1) : digits) |
| 113 | return nAsString + sep + (MULTIPLIERS[i]||'') + post |
| 114 | } // formatBytes |
| 115 | |
| 116 | export function formatSpeed(n: number, options: Parameters<typeof formatBytes>[1]={}) { |
| 117 | return formatBytes(n, { post: 'B/s', ...options }) |
| 118 | } |
| 119 | |
| 120 | export function prefix(pre: Falsy | string, v: string | number | undefined | null | false, post: Falsy | string='') { |
| 121 | return v ? (pre||'') + v + (post || '') : '' |
| 122 | } |
| 123 | |
| 124 | export function join(a: string, b: string, joiner='/') { // similar to path.join but OS independent |
| 125 | if (!b) return a |
| 126 | if (!a) return b |
| 127 | const ends = a.at(-1) === joiner |
| 128 | const starts = b[0] === joiner |
| 129 | return a + (!ends && !starts ? joiner + b : ends && starts ? b.slice(1) : b) |
| 130 | } |
| 131 | |
| 132 | export function wait<T=undefined>(ms: number, val?: T): Promise<T | undefined> { |
| 133 | return new Promise(res=> setTimeout(res,ms,val)) |
| 134 | } |
| 135 | |
| 136 | // throws after ms |
| 137 | export function haveTimeout<T>(ms: number, job: Promise<T>, error?: any) { |
| 138 | let h: Timeout |
| 139 | return Promise.race([ |
| 140 | job.finally(() => clearTimeout(h)), // don't leave pending timeout if the job is done first |
| 141 | new Promise<never>((_resolve, reject) => |
| 142 | h = setTimeout(() => reject(error || Error('timeout')), ms)) |
| 143 | ]) |
| 144 | } |
| 145 | |
| 146 | export function objFromKeys<K extends string, VR=unknown>(src: K[], getValue: (value: K)=> VR) { |
| 147 | return Object.fromEntries(src.map(k => [k, getValue(k)])) |
| 148 | } |
| 149 | |
| 150 | export function enforceFinal(sub:string, s:string, evenEmpty=false) { |
| 151 | return (s ? !s.endsWith(sub) : evenEmpty) ? s + sub : s |
| 152 | } |
| 153 | |
| 154 | export function removeFinal(sub:string, s:string) { |
| 155 | return s.endsWith(sub) ? s.slice(0, -sub.length) : s |
| 156 | } |
| 157 | |
| 158 | export function enforceStarting(sub:string, s:string, evenEmpty=false) { |
| 159 | return (s ? !s.startsWith(sub) : evenEmpty) ? sub + s : s |
| 160 | } |
| 161 | |
| 162 | export function removeStarting(sub: string, s: string) { |
| 163 | return s.startsWith(sub) ? s.slice(sub.length) : s |
| 164 | } |
| 165 | |
| 166 | export function strinsert(s: string, at: number, insert: string, remove=0) { |
| 167 | return s.slice(0, at) + insert + s.slice(at + remove) |
| 168 | } |
| 169 | |
| 170 | export function splitAt(sub: string | number, all: string): [string, string] { |
| 171 | if (typeof sub === 'number') |
| 172 | return [all.slice(0, sub), all.slice(sub + 1)] |
| 173 | const i = all.indexOf(sub) |
| 174 | return i < 0 ? [all,''] : [all.slice(0, i), all.slice(i + sub.length)] |
| 175 | } |
| 176 | |
| 177 | export function stringAfter(sub: string, all: string) { |
| 178 | const i = all.indexOf(sub) |
| 179 | return i < 0 ? '' : all.slice(i + sub.length) |
| 180 | } |
| 181 | |
| 182 | export function stringBefore(sub: string, all: string, returnEmptyWhenSubIsMissing=true) { |
| 183 | const i = all.indexOf(sub) |
| 184 | return i >= 0 ? all.slice(0, i) : returnEmptyWhenSubIsMissing ? '' : all |
| 185 | } |
| 186 | |
| 187 | export function truthy<T>(value: T): value is Truthy<T> { |
| 188 | return Boolean(value) |
| 189 | } |
| 190 | |
| 191 | export function onlyTruthy<T>(arr: T[]) { |
| 192 | return arr.filter(truthy) |
| 193 | } |
| 194 | |
| 195 | export function countUniqueBy<T, K>(items: Iterable<T>, keyFn: (item: T) => K, predicate?: (item: T) => boolean) { |
| 196 | // use a Set so unique counting stays linear even on very large live lists |
| 197 | const seen = new Set<K>() |
| 198 | let count = 0 |
| 199 | for (const item of items) { |
| 200 | if (predicate && !predicate(item)) |
| 201 | continue |
| 202 | const key = keyFn(item) |
| 203 | if (seen.has(key)) |
| 204 | continue |
| 205 | seen.add(key) |
| 206 | count++ |
| 207 | } |
| 208 | return count |
| 209 | } |
| 210 | |
| 211 | export function setHidden<T, ADD>(dest: T, src: ADD) { |
| 212 | return Object.defineProperties(dest, newObj(src as any, value => ({ |
| 213 | enumerable: false, |
| 214 | writable: true, |
| 215 | value, |
| 216 | }))) as T & ADD |
| 217 | } |
| 218 | |
| 219 | export function try_<T,E=undefined>(cb: () => T, onException?: (e:any) => E) { |
| 220 | try { return cb() } |
| 221 | catch(e) { |
| 222 | return onException?.(e) as E |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | export function with_<T,RT>(par:T, cb: (par:T) => RT) { |
| 227 | return cb(par) |
| 228 | } |
| 229 | |
| 230 | export function formatPerc(p: number) { |
| 231 | return (p*100).toFixed(1) + '%' |
| 232 | } |
| 233 | |
| 234 | export function wantArray<T>(x?: void | T | T[]) { |
| 235 | return x == null ? [] : Array.isArray(x) ? x : [x] |
| 236 | } |
| 237 | |
| 238 | export function _log(...args: any[]) { |
| 239 | console.log('**', ...args) |
| 240 | return args[args.length-1] |
| 241 | } |
| 242 | |
| 243 | export function _dbg(x: any) { |
| 244 | debugger |
| 245 | return x |
| 246 | } |
| 247 | |
| 248 | export type PendingPromise<T=unknown> = Promise<T> & { resolve: (value?: T) => void, reject: (reason?: any) => void } |
| 249 | export function pendingPromise<T>() { |
| 250 | let takeOut |
| 251 | const ret = new Promise<T>((resolve, reject) => |
| 252 | takeOut = { resolve, reject }) |
| 253 | return Object.assign(ret, takeOut) as PendingPromise<T> |
| 254 | } |
| 255 | |
| 256 | export function tryJson(s?: string, except?: (s?: string) => unknown) { |
| 257 | try { return s && JSON.parse(s) } |
| 258 | catch { return except?.(s) } |
| 259 | } |
| 260 | |
| 261 | export function swap<T>(obj: T, k1: keyof T, k2: keyof T) { |
| 262 | const temp = obj[k1] |
| 263 | obj[k1] = obj[k2] |
| 264 | obj[k2] = temp |
| 265 | return obj |
| 266 | } |
| 267 | |
| 268 | export function isOrderedEqual(a: any, b: any): boolean { |
| 269 | return _.isEqualWith(a, b, (a1, b1) => { |
| 270 | if (!_.isPlainObject(a1) || !_.isPlainObject(b1)) return |
| 271 | const ka = Object.keys(a1) |
| 272 | const kb = Object.keys(b1) |
| 273 | return ka.length === kb.length && ka.every((ka1, i) => { |
| 274 | const kb1 = kb[i] |
| 275 | return ka1 === kb1 && isOrderedEqual(a1[ka1], b1[kb1]) |
| 276 | }) |
| 277 | }) |
| 278 | } |
| 279 | |
| 280 | export function findDefined<I, O>(a: I[] | Record<string, I>, cb:(v:I, k: string | number)=>O): any { |
| 281 | if (a) for (const k in a) { |
| 282 | const ret = cb((a as any)[k] as I, k) |
| 283 | if (ret !== undefined) |
| 284 | return ret |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // create new object with values returned by callback. Keys are kept the same unless you call `setK('myKey')`. Calling `setK` without parameters, which implies `undefined`, will remove the key. |
| 289 | export function newObj<S extends (object | undefined | null),VR=unknown>( |
| 290 | src: S, |
| 291 | returnNewValue: (value: S[keyof S], key: Exclude<keyof S, symbol>, setK:(newK?: string)=>true, depth: number) => any, |
| 292 | recur: boolean | number=false // recur on the returned value, if it's an object |
| 293 | ) { |
| 294 | let _k: undefined | string |
| 295 | const entries = Object.entries(src || {}).map( ([k,v]) => { |
| 296 | const curDepth = typeof recur === 'number' ? recur : 0 |
| 297 | _k = k |
| 298 | let newV = returnNewValue(v, k as Exclude<keyof S, symbol>, setK, curDepth) |
| 299 | if ((recur !== false || returnNewValue.length === 4) // if callback is using depth parameter, then it wants recursion |
| 300 | && _.isPlainObject(newV)) // is it recurrable? |
| 301 | newV = newObj(newV, returnNewValue, curDepth + 1) |
| 302 | return _k !== undefined && [_k, newV] |
| 303 | }) |
| 304 | return Object.fromEntries(onlyTruthy(entries)) as S extends undefined | null ? S : { [K in keyof S]:VR } |
| 305 | |
| 306 | function setK(newK: typeof _k) { // declare once (optimization) |
| 307 | _k = newK |
| 308 | return true as const // for convenient expression concatenation: setK('newK') && 'newValue' |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // returns undefined if timeout is reached, otherwise the value returned by the callback |
| 313 | export async function waitFor<T>(cb: ()=> Promisable<T>, { interval=200, timeout=Infinity }={}) { |
| 314 | const started = Date.now() |
| 315 | while (1) { |
| 316 | const res = await cb() |
| 317 | if (res) |
| 318 | return res |
| 319 | if (Date.now() - started >= timeout) |
| 320 | return |
| 321 | await wait(interval) |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | export function getOrSet<T>(o: Record<string,T> | Map<string, T>, k:string, creator:()=>T): T { |
| 326 | if (o instanceof Map) |
| 327 | return o.get(k) |
| 328 | || with_(creator(), x => o.set(k, x) && x) |
| 329 | return k in o ? o[k]! |
| 330 | : (o[k] = creator()) |
| 331 | } |
| 332 | |
| 333 | // non-cryptographic; 10 chars is 51+bits, 8 is 41+bits |
| 334 | export function randomId(len = 10): string { |
| 335 | if (len > 10) |
| 336 | return randomId(10) + randomId(len - 10) |
| 337 | return Math.random() |
| 338 | .toString(36) |
| 339 | .substring(2, 2+len) |
| 340 | .replace(/l/g, 'L'); // avoid confusion reading l1 |
| 341 | } |
| 342 | |
| 343 | export function objRenameKey(o: Dict | undefined, from: string, to: string) { |
| 344 | if (!o || !o.hasOwnProperty(from) || from === to) return |
| 345 | o[to] = o[from] |
| 346 | delete o[from] |
| 347 | return true |
| 348 | } |
| 349 | |
| 350 | export function typedKeys<T extends {}>(o: T) { |
| 351 | return Object.keys(o) as (keyof T)[] |
| 352 | } |
| 353 | |
| 354 | export function typedEntries<T extends {}>(o: T): [keyof T, T[keyof T]][] { |
| 355 | return Object.entries(o) as [keyof T, T[keyof T]][]; |
| 356 | } |
| 357 | |
| 358 | export function hasProp<T extends object>(obj: T, key: PropertyKey): key is keyof T { |
| 359 | return key in obj; |
| 360 | } |
| 361 | |
| 362 | export function throw_(err: any): never { |
| 363 | throw err |
| 364 | } |
| 365 | |
| 366 | export function isAsyncIterable<T>(iterable: AsyncIterable<T> | Iterable<T>): iterable is AsyncIterable<T> { |
| 367 | return Symbol.asyncIterator in iterable |
| 368 | } |
| 369 | |
| 370 | export async function* filterMapGenerator<IN,OUT>(generator: AsyncIterableIterator<IN>, filterMap: (el: IN) => Promise<OUT>) { |
| 371 | for await (const x of generator) { |
| 372 | const res:OUT = await filterMap(x) |
| 373 | if (res !== undefined) |
| 374 | yield res as Exclude<OUT,undefined> |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | export async function asyncGeneratorToArray<T>(generator: AsyncIterable<T>): Promise<T[]> { |
| 379 | const ret: T[] = [] |
| 380 | for await(const x of generator) |
| 381 | ret.push(x) |
| 382 | return ret |
| 383 | } |
| 384 | |
| 385 | // like setInterval but: async executions don't overlap AND the first execution is immediate |
| 386 | export function repeat(everyMs: number, cb: Callback<Callback>): Callback { |
| 387 | let stop = false |
| 388 | ;(async () => { |
| 389 | while (!stop) { |
| 390 | try { await cb(stopIt) } // you can use stopIt passed as a parameter or the returned value, whatever makes you happy |
| 391 | catch {} |
| 392 | await wait(everyMs) |
| 393 | } |
| 394 | })() |
| 395 | return stopIt |
| 396 | function stopIt() { |
| 397 | stop = true |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | export function formatTimestamp(x: number | string | Date, includeSeconds=true) { |
| 402 | if (!x) return '' |
| 403 | if (!(x instanceof Date)) |
| 404 | x = new Date(x) |
| 405 | return formatDate(x) + ' ' + formatTime(x, includeSeconds) |
| 406 | } |
| 407 | |
| 408 | export function formatTime(d: Date, includeSeconds=true) { |
| 409 | // bundled nodejs doesn't have locales |
| 410 | return String(d.getHours()).padStart(2, '0') |
| 411 | + ':' + String(d.getMinutes()).padStart(2, '0') |
| 412 | + (includeSeconds ? ':' + String(d.getSeconds()).padStart(2, '0') : '') |
| 413 | } |
| 414 | |
| 415 | export function formatDate(d: Date) { |
| 416 | return [d.getFullYear(), d.getMonth() + 1, d.getDate()].map(x => x.toString().padStart(2, '0')).join('-') |
| 417 | } |
| 418 | |
| 419 | export function isNumeric(x: unknown) { |
| 420 | return _.isNumber(x) || _.isString(x) && !isNaN(Number(x)) |
| 421 | } |
| 422 | |
| 423 | export function isPrimitive(x: unknown): x is boolean | string | number | undefined | null { |
| 424 | return x === null || typeof x !== 'object' && typeof x !== 'function' // from node's documentation |
| 425 | } |
| 426 | |
| 427 | export function isIP(address: string) { |
| 428 | return /^([.:\da-f]+)$/i.test(address) |
| 429 | } |
| 430 | |
| 431 | export function isWindowsDrive(s?: string) { |
| 432 | return s && /^[a-zA-Z]:$/.test(s) |
| 433 | } |
| 434 | |
| 435 | export function isTimestampString(v: unknown) { |
| 436 | return typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z*$/.test(v) |
| 437 | } |
| 438 | |
| 439 | export function isEqualLax(a: any,b: any, overrideRule?: (a: any, b: any) => boolean | undefined): boolean { |
| 440 | return overrideRule?.(a, b) ?? ( |
| 441 | a == b || a && b && typeof a === 'object' && typeof b === 'object' |
| 442 | && Object.entries(a).every(([k, v]) => isEqualLax(v, b[k], overrideRule)) |
| 443 | && Object.entries(b).every(([k, v]) => k in a /*already checked*/ || isEqualLax(v, a[k], overrideRule)) |
| 444 | ) |
| 445 | } |
| 446 | |
| 447 | export function xlate(input: any, table: Record<string, any>) { |
| 448 | return table[input] ?? input |
| 449 | } |
| 450 | |
| 451 | // remove brackets and port (if any) |
| 452 | export function normalizeHost(host: string) { |
| 453 | return host[0] === '[' ? host.slice(1, host.indexOf(']')) : host?.split(':')[0] |
| 454 | } |
| 455 | |
| 456 | export function isIpLocalHost(ip: string) { |
| 457 | return ip === '::1' || ip.endsWith('127.0.0.1') |
| 458 | } |
| 459 | |
| 460 | export function isIpLan(ip: string) { |
| 461 | return /^(?:10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|fe80::)/.test(ip) |
| 462 | } |
| 463 | |
| 464 | export function ipForUrl(ip: string) { |
| 465 | if (ip.startsWith('[')) |
| 466 | return ip |
| 467 | const i = ip.indexOf(':') |
| 468 | return i >= 0 && ip.indexOf(':', i + 1) >= 0 ? `[${ip}]` : ip |
| 469 | } |
| 470 | |
| 471 | export function escapeHTML(text: string) { |
| 472 | return text.replace(/[\u0000-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u00FF]/g, |
| 473 | c => '&#' + ('000' + c.charCodeAt(0)).slice(-4) + ';') |
| 474 | } |
| 475 | |
| 476 | // wait for all, but returns only those that resolved |
| 477 | export async function promiseBestEffort<T>(promises: Promise<T>[]) { |
| 478 | const res = await Promise.allSettled(promises) |
| 479 | return res.filter(x => x.status === 'fulfilled').map((x: any) => x.value as T) |
| 480 | } |
| 481 | |
| 482 | // encode paths leaving / separator unencoded (not like encodeURIComponent), but still encode # |
| 483 | export function pathEncode(s: string, all=false) { |
| 484 | return all ? encodeURI(s).replace(/#/g, escape) : s.replace(/[:&#'"% ?\\]/g, escape) // escape() is not utf8, but we are encoding only ascii chars |
| 485 | } |
| 486 | export function pathDecode(s: string) { |
| 487 | return decodeURI(s).replace(/%23/g, '#') |
| 488 | } |
| 489 | export function pathDecodeSegments(s: string, map: (segment: string) => string = String) { |
| 490 | // decode segment by segment so reserved escapes are decoded without turning encoded slashes into separators |
| 491 | return s.split('/').map(x => map(safeDecodeURIComponent(x)).replaceAll('/', '%2F')).join('/') |
| 492 | } |
| 493 | |
| 494 | // run at a specific point in time, also solving the limit of setTimeout, which doesn't work with +32bit delays |
| 495 | export function runAt(ts: number, cb: Callback) { |
| 496 | let cancel = false |
| 497 | let t: any |
| 498 | setTimeout(async () => { |
| 499 | if (missing() < 0) return |
| 500 | const max = 0x7FFFFFFF |
| 501 | while (!cancel && missing() > max) |
| 502 | await wait(max) |
| 503 | if (cancel) return |
| 504 | t = setTimeout(cb, missing()) |
| 505 | |
| 506 | function missing() { |
| 507 | return ts - Date.now() |
| 508 | } |
| 509 | }) |
| 510 | return () => { |
| 511 | cancel = true |
| 512 | clearTimeout(t) |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | export function makeMatcher(mask: string, emptyMaskReturns=false, extglobs=true) { |
| 517 | if (!mask) return () => emptyMaskReturns |
| 518 | const wrapped = mask.replace(/^(!)?/, '$1(') + ')' // adding () will allow us to use the pipe at root level |
| 519 | const opts = { nocase: true, noextglob: !extglobs } |
| 520 | // reject patterns that compile to nested quantified groups, causing catastrophic backtracking (CVE-2026-33671) |
| 521 | return /\)\)[+*]/.test(picomatch.makeRe(wrapped, opts).source) ? () => false |
| 522 | : picomatch(wrapped, opts) |
| 523 | } |
| 524 | |
| 525 | // this is caching all matchers, so don't use it with frequently changing masks. Benchmarks revealed that _.memoize make it slower than not using it, while this simple cache can speed up to 30x |
| 526 | export function matches(s: string, mask: string, emptyMaskReturns=false) { |
| 527 | const cache = (matches as any).cache ||= {} |
| 528 | return (cache[mask + (emptyMaskReturns ? '1' : '0')] ||= makeMatcher(mask, emptyMaskReturns))(s) |
| 529 | } |
| 530 | |
| 531 | // if delimiter is specified, it is prefixed to symbols. If it contains a space, the part after the space is considered as suffix. |
| 532 | export function replace(s: string, symbols: Dict<string | Callback<string, string>>, delimiter='') { |
| 533 | const [open, close] = splitAt(' ', delimiter) |
| 534 | for (const [k, v] of Object.entries(symbols)) |
| 535 | s = s.replaceAll(open + k + close, v as any) // typescript doesn't handle overloaded functions (like replaceAll) with union types https://stackoverflow.com/a/66510061/646132 |
| 536 | return s |
| 537 | } |
| 538 | |
| 539 | export function inCommon<T extends string | unknown[]>(a: T, b: T) { |
| 540 | let i = 0 |
| 541 | const n = a.length |
| 542 | while (i < n && a[i] === b[i]) i++ |
| 543 | return i |
| 544 | } |
| 545 | |
| 546 | type MapFilterResult<R, F> = F extends undefined ? Exclude<R, undefined>[] |
| 547 | : F extends (x: R) => x is (infer S extends R) ? S[] |
| 548 | : R[] |
| 549 | |
| 550 | export function mapFilter<T=unknown, R=T, F extends ((x: R) => unknown) | undefined=undefined>( |
| 551 | arr: T[], |
| 552 | map: (x:T, idx: number) => R, |
| 553 | filter?: F, |
| 554 | invert=false |
| 555 | ): MapFilterResult<R, F> { |
| 556 | const keep = filter ?? ((x: R) => x !== undefined) |
| 557 | return arr[invert ? 'reduceRight' : 'reduce']((ret, x, idx) => { |
| 558 | const y = map(x, idx) |
| 559 | if (keep(y)) |
| 560 | ret.push(y) // push is much faster than unshift, therefore, invert using reduceRight https://measurethat.net/Benchmarks/Show/29/0/array-push-vs-unshift |
| 561 | return ret |
| 562 | }, [] as R[]) as MapFilterResult<R, F> |
| 563 | } |
| 564 | |
| 565 | export function callable<T>(x: Functionable<T>, ...args: unknown[]) { |
| 566 | return _.isFunction(x) ? x(...args) : x |
| 567 | } |
| 568 | |
| 569 | export function safeDecodeURIComponent(s: string, fallback: string=s) { |
| 570 | try { return decodeURIComponent(s) } |
| 571 | catch { return fallback } |
| 572 | } |
| 573 | |
| 574 | export function popKey(o: any, k: string) { |
| 575 | if (!o) return |
| 576 | const x = o[k] |
| 577 | delete o[k] |
| 578 | return x |
| 579 | } |
| 580 | |
| 581 | export function patchKey(o: any, k: string, replacer: (was: unknown) => unknown) { |
| 582 | o[k] = replacer(o[k]) |
| 583 | return o |
| 584 | } |
| 585 | |
| 586 | // consider the callback successful if it returns a truthy value |
| 587 | export async function retry(cb: () => Promise<any>, delay=1000) { |
| 588 | let retry = 3 |
| 589 | while (true) { |
| 590 | if (await cb()) break |
| 591 | if (! retry--) break |
| 592 | await wait(delay) |
| 593 | } |
| 594 | } |
| 595 | |
| 596 | export type Mutable<T> = { -readonly [K in keyof T]: T[K] } |
| 597 | export function toMutable<T>(value: readonly T[]): T[] |
| 598 | export function toMutable<T extends object>(value: T): Mutable<T> |
| 599 | export function toMutable(value: readonly unknown[] | object) { |
| 600 | return Array.isArray(value) ? value.slice() : { ...value } |
| 601 | } |
| 602 | |
| 603 | export function shortenAgent(agent: string) { |
| 604 | return _.findKey(BROWSERS, re => re.test(agent)) |
| 605 | || /^[^/(]+ ?/.exec(agent)?.[0] |
| 606 | || agent |
| 607 | } |
| 608 | const BROWSERS = { |
| 609 | YaBrowser: /yabrowser/i, |
| 610 | AlamoFire: /alamofire/i, |
| 611 | Edge: /edge|edga|edgios|edg/i, |
| 612 | PhantomJS: /phantomjs/i, |
| 613 | Konqueror: /konqueror/i, |
| 614 | Amaya: /amaya/i, |
| 615 | Epiphany: /epiphany/i, |
| 616 | SeaMonkey: /seamonkey/i, |
| 617 | Flock: /flock/i, |
| 618 | OmniWeb: /omniweb/i, |
| 619 | Opera: /opera|OPR\//i, |
| 620 | Chromium: /chromium/i, |
| 621 | Facebook: /FBA[NV]/, |
| 622 | Chrome: /chrome|crios/i, |
| 623 | WinJs: /msapphost/i, |
| 624 | IE: /msie|trident/i, |
| 625 | Firefox: /firefox|fxios/i, |
| 626 | Safari: /safari/i, |
| 627 | PS5: /playstation 5/i, |
| 628 | PS4: /playstation 4/i, |
| 629 | PS3: /playstation 3/i, |
| 630 | PSP: /playstation portable/i, |
| 631 | PS: /playstation/i, |
| 632 | Xbox: /xbox/i, |
| 633 | UC: /UCBrowser/i, |
| 634 | Finder: /WebDAVFS.+Darwin|WebDAVLib/, |
| 635 | Cyberduck: /^Cyberduck/, |
| 636 | ForkLift: /^ForkLift/, |
| 637 | Explorer: /^Microsoft-WebDAV-MiniRedir/, |
| 638 | } |