main
ts 272 lines 10 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
3 import _ from 'lodash'
4 import { apiCall } from './api'
5 import {
6 DAY, Dict, formatBytes, HOUR, MINUTE, objFromKeys, typedEntries, wantArray, stringBefore,
7 PLUGINS_PUB_URI
8 } from '../src/cross'
9 export * from './react'
10 export * from './dialogs'
11 export * from './md'
12 export * from '../src/cross'
13 // code in this file is shared among frontends, but not backend
14
15 ;(window as any)._ = _
16
17 setTimeout(() => { // give some time for react to have started rendering
18 document.querySelectorAll('.removeAtBoot').forEach(e => e.remove())
19 }, 500)
20
21 // roughly 0.7 on m1 max
22 export const cpuSpeedIndex = (() => {
23 let ms = performance.now()
24 _.range(1E5).map(x => ++x)
25 ms = performance.now() - ms
26 return 1 / ms
27 })()
28
29 export const urlParams = Object.fromEntries(new URLSearchParams(window.location.search).entries())
30
31 const HFS = getHFS()
32 Object.assign(HFS, {
33 getPluginKey: (quiet=false) =>
34 getScriptAttr('plugin') ?? detectPluginId() ?? (quiet ? undefined : console.error("this function must be called synchronously during initial script evaluation")),
35 getPluginPublic: () => getScriptAttr('src')?.match(/^.*\//)?.[0],
36 getPluginConfig: () => HFS.plugins[HFS.getPluginKey()] || {},
37 loadScript: (uri: string) => loadScript(uri.includes('//') || uri.startsWith('/') ? uri : HFS.getPluginPublic() + uri),
38 userBelongsTo: (username: string | string[]) => wantArray(username).some(x => HFS.state.expandedUsername?.includes(x)),
39 cpuSpeedIndex,
40 copyTextToClipboard,
41 urlParams,
42 })
43 formatBytes.k = HFS.kb
44
45 function detectPluginId() {
46 return stringBefore('/', Error().stack?.split(PLUGINS_PUB_URI)[1] || '') // generically search for the url – tested on recent versions of chrome, safari, firefox, edge
47 }
48
49 export const IMAGE_FILEMASK = '*.jpg|*.jpeg|*.png|*.gif|*.svg'
50
51 //@ts-ignore
52 if (import.meta.env.PROD) {
53 const was = console.debug
54 console.debug = (...args) => (window as any).DEV && was(...args)
55 }
56
57 function getScriptAttr(k: string) {
58 return document.currentScript?.getAttribute(k)
59 }
60
61 export function buildUrlQueryString(params: Dict) { // not using URLSearchParams.toString as it doesn't work on firefox50
62 return '?' + Object.entries(params).filter(pair => pair[1] !== undefined).map(pair => pair.map(x => encodeURIComponent(x).replaceAll('%2F','/')).join('=') ).join('&')
63 }
64
65 type DomOnEventMap<T> =
66 T extends Window ? WindowEventMap :
67 T extends Document ? DocumentEventMap :
68 T extends HTMLMediaElement ? HTMLMediaElementEventMap :
69 T extends HTMLElement ? HTMLElementEventMap :
70 T extends SVGElement ? SVGElementEventMap :
71 T extends Element ? ElementEventMap :
72 T extends MediaQueryList ? MediaQueryListEventMap :
73 T extends EventTarget ? Record<string, Event> :
74 never
75
76 type DomOnTarget<O> = O extends { target?: infer T } ? T : Window
77 type DomOnEventMapFor<O> = DomOnEventMap<DomOnTarget<O>>
78
79 // default target is `window`
80 export function domOn<
81 O extends boolean | undefined | { target?: EventTarget } & AddEventListenerOptions = undefined,
82 K extends keyof DomOnEventMapFor<O> & string = keyof DomOnEventMapFor<O> & string
83 >(
84 eventName: K,
85 cb: (ev: DomOnEventMapFor<O>[K]) => void,
86 options?: O
87 ) {
88 const target = options && 'target' in options ? options.target : window
89 if (!target) return () => {}
90 target.addEventListener(eventName, cb as EventListener, options)
91 return () => target.removeEventListener(eventName, cb as EventListener, options)
92 }
93
94 export function restartAnimation(e: HTMLElement | null | undefined, animation: string) {
95 if (!e) return
96 e.style.animation = ''
97 void e.offsetWidth
98 e.style.animation = animation
99 }
100
101 export function selectFiles(cb: (list: FileList | null)=>void, { accept='', multiple=true, folder=false }={}) {
102 const el = Object.assign(document.createElement('input'), {
103 type: 'file',
104 name: 'file',
105 accept,
106 multiple: multiple,
107 webkitdirectory: folder,
108 })
109 el.addEventListener('change', () =>
110 cb(el.files))
111 el.click()
112 }
113
114 export function readFile(f: File | Blob): Promise<string | undefined> {
115 return new Promise((resolve, reject) => {
116 const reader = new FileReader()
117 reader.addEventListener('load', (event) => {
118 if (!event.target || f.size && !event.target.result)
119 return reject('cannot read')
120 const {result} = event.target
121 resolve(result?.toString())
122 })
123 reader.addEventListener('error', () => {
124 reject(reader.error)
125 })
126 reader.readAsText(f)
127 })
128 }
129
130 export function isMobile() {
131 return window.innerWidth < 800
132 }
133
134 export function getHFS() {
135 return (window as any).HFS ||= {}
136 }
137
138 export function getPrefixUrl() {
139 return getHFS().prefixUrl || ''
140 }
141
142 export function makeSessionRefresher(state: any) {
143 let timeout: any
144 refreshSession(getHFS().session)
145 return refreshSession
146
147 function refreshSession(response?: any) {
148 clearTimeout(timeout)
149 const keys = ['username', 'isAdmin', 'adminUrl', 'canChangePassword', 'accountExp', 'expandedUsername', 'requireChangePassword']
150 response ??= objFromKeys(keys, () => undefined)
151 const { exp } = response
152 getHFS().session = Object.assign(state, _.pick(response, keys))
153 if (!response.username || !exp) return
154 const delta = new Date(exp).getTime() - Date.now()
155 const t = _.clamp(delta - 30_000, 4_000, 600_000)
156 console.debug('session refresh in', Math.round(t / 1000))
157 timeout = setTimeout(() => apiCall('refresh_session').then(refreshSession), t)
158 }
159 }
160
161 export function focusSelector(selector: string, root: HTMLElement | Document=document) {
162 const res = root.querySelector(selector)
163 if (res && res instanceof HTMLElement) {
164 res.focus()
165 return true
166 }
167 }
168
169 export function loadScript(url: string, more={}) {
170 return new Promise((resolve, reject) => {
171 const el = document.createElement('script')
172 el.type = 'text/javascript'
173 el.src = url
174 el.onload = resolve
175 el.onerror = reject
176 for (const [k,v] of Object.entries(more))
177 el.setAttribute(k, String(v))
178 document.head.appendChild(el)
179 })
180 }
181
182 export function fallbackToBasicAuth() {
183 // @ts-ignore this is a trick from polyfills.js
184 return BigInt === Number
185 }
186
187 export function basename(path: string) {
188 return path.match(/([^\\/]+)[\\/]*$/)?.[1] || ''
189 }
190
191 export function extname(path: string) {
192 const name = basename(path)
193 const i = name.lastIndexOf('.')
194 return i <= 0 ? '' : name.slice(i)
195 }
196
197 export function dirname(path: string) {
198 return path.slice(0, Math.max(0, path.lastIndexOf('/', path.length - 1)))
199 }
200
201 type DurationUnit = 'day' | 'hour' | 'minute' | 'second'
202 export function createDurationFormatter({ locale=undefined, unitDisplay='narrow', largest='day', smallest='second', maxTokens, skipZeroes }:
203 { skipZeroes?: boolean, largest?: DurationUnit, smallest?: DurationUnit, locale?: string, unitDisplay?: 'long' | 'short' | 'narrow', maxTokens?: 1 | 2 | 3 }={}) {
204 const multipliers: Record<DurationUnit, number> = { day: DAY, hour: HOUR, minute: MINUTE, second: 1000 }
205 const fmt = _.mapValues(multipliers, (v,k) => Intl.NumberFormat(locale, { style: 'unit', unit: k, unitDisplay }).format)
206 const fmtList = new Intl.ListFormat(locale, { style: 'narrow', type: 'unit' })
207 return (ms: number) => {
208 const a = []
209 let on = false
210 for (const [unit, mul] of typedEntries(multipliers)) {
211 if (unit === smallest && a.length)
212 break
213 if (unit === largest)
214 on = true
215 if (!on) continue
216 const v = Math.floor(ms / mul)
217 if (!v && skipZeroes) continue
218 a.push( fmt[unit]?.(v) ?? String(v) )
219 if (a.length === maxTokens) break
220 ms %= mul
221 }
222 return fmtList.format(a)
223 }
224 }
225
226 export async function copyTextToClipboard(text: string) {
227 text = String(text)
228 try {
229 await navigator.clipboard.writeText(text) // this method works only in https and localhost
230 }
231 catch {
232 console.debug('fallback clipboard method')
233 // with this handler we work around the focus-trap of MUI dialogs
234 const undo = domOn('copy', ev => {
235 ev.clipboardData?.setData('text/plain', text)
236 ev.preventDefault()
237 }, { capture: true, target: document })
238 try {
239 if (!document.execCommand('copy'))
240 throw Error('unknown')
241 }
242 finally { undo() }
243 }
244 }
245
246 export function downloadFileWithContent(name: string, content: Blob | string) {
247 const blob = content instanceof Blob ? content : new Blob([content], {type: 'text/plain'})
248 const a = document.createElement('a')
249 a.href = URL.createObjectURL(blob)
250 a.download = name
251 a.style.display = 'none'
252 document.body.append(a)
253 a.click()
254 setTimeout(() => a.remove(), 100) // Chrome needs this timeout
255 }
256
257 export function withSrpLib<Args extends unknown[], Res>(cb: (srp: typeof import('tssrp6a'), ...args: Args) => Res) {
258 return (...args: Args) => import('tssrp6a').then(srp => cb(srp, ...args))
259 }
260
261 // you can set password directly in add/set_account, but using this api instead will add extra security because it is not sent as clear-text, so it's especially good if you are not in localhost and not using https
262 export async function apiNewPassword(username: string, password: string) {
263 const { createVerifierAndSalt, SRPParameters, SRPRoutines } = await import('tssrp6a')
264 const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
265 const res = await createVerifierAndSalt(srp6aNimbusRoutines, username, password)
266 return apiCall('change_srp', { username, salt: String(res.s), verifier: String(res.v) })
267 }
268
269 Element.prototype.replaceChildren ||= function(this:Element, addNodes) { // polyfill
270 while (this.lastChild) this.removeChild(this.lastChild)
271 if (addNodes !== undefined) this.append(addNodes);
272 }