main
ts 638 lines 22.4 KB
Raw
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 }