better code: share api module

Massimo Melina committed Apr 15, 2023 at 17:03 UTC f171990cd5cd22554a2437db042ec1fd5c2af651
11 files changed +203 -263
admin/src/AccountForm.ts
+3 -3
@@ -46,7 +46,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
46 fields: [
47 { k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off', required: true, xl: group ? 12 : 4,
48 getError: v => v !== account.username && apiCall('get_account', { username: v })
49 - .then(got => got.username === account.username ? "usernames are case-insensitive" : "already used", () => false),
49 + .then(got => got?.username === account.username ? "usernames are case-insensitive" : "already used", () => false),
50 },
51 !group && { k: 'password', md: 6, xl: 4, type: 'password', autoComplete: 'new-password', required: add,
52 label: add ? "Password" : "Change password"
@@ -81,7 +81,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
81 apiCall('del_account', { username }).then() // best effort, don't wait
82 throw e
83 }
84 - done(got.username)
84 + done(got?.username)
85 toast("Account created", 'success')
86 return
87 }
@@ -92,7 +92,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
92 if (password)
93 await apiNewPassword(username, password)
94 setTimeout(() => toast("Account modified", 'success'), 1) // workaround: showing a dialog at this point is causing a crash if we are in a dialog
95 - done(got.username) // username may have been changed, so we pass it back
95 + done(got?.username) // username may have been changed, so we pass it back
96 }
97 }
98 })
admin/src/api.ts
+13 -144
@@ -1,13 +1,23 @@
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 { createElement as h, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4 -import { Dict, err2msg, Falsy, getCookie, IconBtn, pendingPromise, spinner, useStateMounted, wantArray } from './misc'
3 +import { createElement as h, useEffect, useMemo, useRef, useState } from 'react'
4 +import { Dict, err2msg, Falsy, IconBtn, spinner, useStateMounted, wantArray } from './misc'
5 import { Alert } from '@mui/material'
6 import _ from 'lodash'
7 import { state } from './state'
8 import { Refresh } from '@mui/icons-material'
9 import produce, { Draft } from 'immer'
10 -import { try_ } from './misc'
10 +import { ApiError, apiEvents, setDefaultApiCallOptions, useApi } from '@hfs/shared/api'
11 +export * from '@hfs/shared/api'
12 +
13 +setDefaultApiCallOptions({
14 + async onResponse(res: Response, body: any) {
15 + if (res.status === 401) {
16 + state.loginRequired = body?.any !== false || 403
17 + throw new ApiError(res.status, "Unauthorized")
18 + }
19 + }
20 +})
21
22 export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
23 const [data, error, reload] = useApi<T>(...args)
@@ -22,147 +32,6 @@ export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
32 return { data, error, reload, loading, element }
33 }
34
25 -const PREFIX = (window as any).HFS?.prefixUrl + '/~/api/'
26 -
27 -const timeoutByApi: Dict = {
28 - loginSrp1: 90, // support antibrute
29 - get_status: 20 // can be lengthy on slow machines because of the find-process-on-busy-port feature
30 -}
31 -export function apiCall(cmd: string, params?: Dict, { timeout=undefined }={}) {
32 - const csrf = getCsrf()
33 - if (csrf)
34 - params = { csrf, ...params }
35 -
36 - const controller = new AbortController()
37 - if (timeout !== false)
38 - setTimeout(() => controller.abort('timeout'), 1000*(timeoutByApi[cmd] ?? timeout ?? 10))
39 - return Object.assign(fetch(PREFIX+cmd, {
40 - method: 'POST',
41 - headers: { 'content-type': 'application/json' },
42 - signal: controller.signal,
43 - body: params && JSON.stringify(params),
44 - }).then(async res => {
45 - if (res.ok)
46 - return res.json().then(json => {
47 - console.debug('API', cmd, params, '>>', json)
48 - return json
49 - })
50 - let msg = await res.text() || 'Failed API ' + cmd
51 - console.warn(msg + (params ? ' ' + JSON.stringify(params) : ''))
52 - if (res.status === 401) {
53 - state.loginRequired = try_(() => JSON.parse(msg)?.any) !== false || 403
54 - msg = "Unauthorized"
55 - }
56 - throw new ApiError(res.status, msg)
57 - }, err => {
58 - if (err?.message?.includes('fetch'))
59 - throw Error("Network error")
60 - throw err
61 - }), {
62 - abort() {
63 - controller.abort('cancel')
64 - }
65 - })
66 -}
67 -
68 -export class ApiError extends Error {
69 - constructor(readonly code:number, message: string) {
70 - super(message);
71 - }
72 -}
73 -
74 -export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undefined, undefined | Error, ()=>void] {
75 - const [ret, setRet] = useStateMounted<T | undefined>(undefined)
76 - const [err, setErr] = useStateMounted<Error | undefined>(undefined)
77 - const [forcer, setForcer] = useStateMounted(0)
78 - const loadingRef = useRef<ReturnType<typeof apiCall>>()
79 - const reloadingRef = useRef<any>()
80 - useEffect(()=>{
81 - loadingRef.current?.abort()
82 - setRet(undefined)
83 - setErr(undefined)
84 - if (!cmd) return
85 - let aborted = false
86 - const req = apiCall(cmd, params)
87 - const wholePromise = req.then(x => aborted || setRet(x), x => aborted || setErr(x))
88 - .finally(()=> loadingRef.current = undefined)
89 - loadingRef.current = Object.assign(wholePromise, {
90 - abort() {
91 - aborted = true
92 - req.abort()
93 - }
94 - })
95 - reloadingRef.current?.resolve(wholePromise)
96 - }, [cmd, JSON.stringify(params), forcer]) //eslint-disable-line -- json-ize to detect deep changes
97 - const reload = useCallback(() => loadingRef.current
98 - || setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
99 - [setForcer])
100 - return [ret, err, reload]
101 -}
102 -
103 -type EventHandler = (type:string, data?:any) => void
104 -
105 -export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
106 - console.debug('API EVENTS', cmd, params)
107 - const csrf = getCsrf()
108 - const processed: Record<string,string> = { csrf: csrf && JSON.stringify(csrf) }
109 - for (const k in params) {
110 - const v = params[k]
111 - if (v === undefined) continue
112 - processed[k] = JSON.stringify(v)
113 - }
114 - const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
115 - source.onopen = () => cb('connected')
116 - source.onerror = err => cb('error', err)
117 - source.onmessage = ({ data }) => {
118 - if (!data) {
119 - cb('closed')
120 - return source.close()
121 - }
122 - try { data = JSON.parse(data) }
123 - catch {
124 - return cb('string', data)
125 - }
126 - console.debug('SSE msg', data)
127 - cb('msg', data)
128 - }
129 - return source
130 -}
131 -
132 -function getCsrf() {
133 - return getCookie('csrf')
134 -}
135 -
136 -export function useApiEvents(cmd: string, params: Dict={}) {
137 - const [data, setData] = useStateMounted<any>(undefined)
138 - const [error, setError] = useStateMounted<any>(undefined)
139 - const [loading, setLoading] = useStateMounted(false)
140 - useEffect(() => {
141 - const src = apiEvents(cmd, params, (type, data) => {
142 - switch (type) {
143 - case 'error':
144 - setError("Connection error")
145 - return stop()
146 - case 'closed':
147 - return stop()
148 - case 'msg':
149 - if (src?.readyState === src?.CLOSED)
150 - return stop()
151 - return setData(data)
152 - }
153 - })
154 - return () => {
155 - src.close()
156 - stop()
157 - }
158 -
159 - function stop() {
160 - setLoading(false)
161 - }
162 - }, [cmd, JSON.stringify(params)]) //eslint-disable-line
163 - return { data, loading, error }
164 -}
165 -
35 export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
36 const [list, setList] = useStateMounted<T[]>([])
37 const [props, setProps] = useStateMounted<any>(undefined)
frontend/src/UserPanel.ts
+1 -1
@@ -4,7 +4,7 @@ import { useSnapState } from './state'
4 import { createElement as h } from 'react'
5 import { alertDialog, closeDialog, newDialog, promptDialog } from './dialog'
6 import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
7 -import { apiCall } from './api'
7 +import { apiCall } from '@hfs/shared/api'
8 import { logout } from './login'
9 import { MenuButton } from './menu'
10 import { hIcon } from './misc'
frontend/src/api.ts deleted
-102
@@ -1,102 +0,0 @@
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 { useEffect, useRef, useState } from 'react';
4 -import { Dict, Falsy, getCookie, getPrefixUrl, working } from './misc'
5 -
6 -const PREFIX = getPrefixUrl() + '/~/api/'
7 -
8 -interface ApiCallOptions { noModal?:true }
9 -export function apiCall(cmd: string, params?: Dict, options: ApiCallOptions={}) {
10 - const stop = options.noModal ? undefined : working()
11 - const csrf = getCsrf()
12 - if (csrf)
13 - params = { csrf, ...params }
14 - const controller = new AbortController()
15 - return Object.assign(fetch(PREFIX + cmd, {
16 - method: 'POST',
17 - headers: { 'content-type': 'application/json' },
18 - signal: controller.signal,
19 - body: params && JSON.stringify(params),
20 - }).then(async res => {
21 - stop?.()
22 - if (res.ok)
23 - return res.json()
24 - const msg = await res.text() || `Failed API ${cmd}: ${res.statusText}`
25 - console.warn(msg, params ? ' ' + JSON.stringify(params) : '')
26 - throw new ApiError(res.status, msg)
27 - }, err => {
28 - stop?.()
29 - if (err?.message?.includes('fetch'))
30 - throw Error("Network error")
31 - throw err
32 - }), {
33 - abort() {
34 - controller.abort('cancel')
35 - }
36 - })
37 -}
38 -
39 -export class ApiError extends Error {
40 - constructor(readonly code:number, message: string) {
41 - super(message);
42 - }
43 -}
44 -
45 -export function useApi(cmd: string | Falsy, params?: Dict, options: ApiCallOptions={}) : any {
46 - const [ret, setRet] = useState()
47 - const loadingRef = useRef<ReturnType<typeof apiCall>>()
48 - useEffect(()=>{
49 - loadingRef.current?.abort()
50 - setRet(undefined)
51 - if (!cmd) return
52 - const p = loadingRef.current = apiCall(cmd, params, options)
53 - p.then(setRet, setRet)
54 - }, [cmd, JSON.stringify(params)]) //eslint-disable-line
55 - return ret
56 -}
57 -
58 -type EventHandler = (type:string, data?:any) => void
59 -
60 -export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
61 - const processed: Record<string,string> = {}
62 - for (const k in params) {
63 - const v = params[k]
64 - if (v !== undefined)
65 - processed[k] = JSON.stringify(v)
66 - }
67 - const csrf = getCsrf()
68 - if (csrf)
69 - processed.csrf = JSON.stringify(csrf)
70 - const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
71 - source.onopen = () => cb('connected')
72 - source.onerror = err => cb('error', err)
73 - source.onmessage = ({ data }) => {
74 - if (!data) {
75 - cb('closed')
76 - return source.close()
77 - }
78 - try { data = JSON.parse(data) }
79 - catch {
80 - return cb('string', data)
81 - }
82 - cb('msg', data)
83 - }
84 - return source
85 -}
86 -
87 -function getCsrf() {
88 - return getCookie('csrf')
89 -}
90 -
91 -export async function getNotification(channel: string, cb: (name: string, data:any) => void): Promise<EventSource> {
92 - return new Promise(resolve => {
93 - const ret = apiEvents('get_notifications', { channel }, (type, entries) => {
94 - if (type === 'connected')
95 - return resolve(ret)
96 - if (type !== 'msg') return
97 - for (const { name, data } of entries)
98 - if (name)
99 - cb(name, data)
100 - })
101 - })
102 -}
\ No newline at end of file
frontend/src/login.ts
+1 -1
@@ -1,6 +1,6 @@
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 { apiCall } from './api'
3 +import { apiCall } from '@hfs/shared/api'
4 import { state, useSnapState } from './state'
5 import { alertDialog, newDialog } from './dialog'
6 import { getPrefixUrl, hIcon, srpSequence, working } from './misc'
frontend/src/menu.ts
+1 -1
@@ -12,7 +12,7 @@ import _ from 'lodash'
12 import { closeDialog } from '@hfs/shared/dialogs'
13 import { showUpload, uploadState } from './upload'
14 import { useSnapshot } from 'valtio'
15 -import { apiCall } from './api'
15 +import { apiCall } from '@hfs/shared/api'
16 import { reloadList, usePath } from './useFetchList'
17 import { t, useI18N } from './i18n'
18
frontend/src/misc.ts
+2 -9
@@ -9,7 +9,7 @@ import { state } from './state'
9 import { t } from './i18n'
10 import * as dialogLib from './dialog'
11 import _ from 'lodash'
12 -import { apiCall } from './api'
12 +import { apiCall, setDefaultApiCallOptions } from '@hfs/shared/api'
13 import { reloadList } from './useFetchList'
14 import { logout } from './login'
15 export * from '@hfs/shared'
@@ -75,11 +75,4 @@ Object.assign((window as any).HFS ||= {}, {
75 }
76 })
77
78 -export function getHFS() {
79 - return (window as any).HFS
80 -}
81 -
82 -export function getPrefixUrl() {
83 - return getHFS().prefixUrl
84 -}
85 -
78 +setDefaultApiCallOptions({ modal: working })
frontend/src/upload.ts
+1 -1
@@ -7,7 +7,7 @@ import _ from 'lodash'
7 import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8 import { alertDialog, confirmDialog, promptDialog } from './dialog'
9 import { reloadList } from './useFetchList'
10 -import { apiCall, getNotification } from './api'
10 +import { apiCall, getNotification } from '@hfs/shared/api'
11 import { state, useSnapState } from './state'
12 import { Link } from 'react-router-dom'
13 import { t } from './i18n'
frontend/src/useFetchList.ts
+1 -1
@@ -2,7 +2,7 @@
2
3 import { state, useSnapState } from './state'
4 import { useEffect, useRef } from 'react'
5 -import { apiEvents } from './api'
5 +import { apiEvents } from '@hfs/shared/api'
6 import { DirList } from './BrowseFiles'
7 import _ from 'lodash'
8 import { subscribeKey } from 'valtio/utils'
shared/api.ts new
+172
@@ -0,0 +1,172 @@
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 { useCallback, useEffect, useRef } from 'react';
5 +import { Dict, Falsy, getCookie, getPrefixUrl, pendingPromise, useStateMounted } from '.'
6 +
7 +const PREFIX = getPrefixUrl() + '/~/api/'
8 +
9 +const timeoutByApi: Dict = {
10 + loginSrp1: 90, // support antibrute
11 + get_status: 20 // can be lengthy on slow machines because of the find-process-on-busy-port feature
12 +}
13 +
14 +interface ApiCallOptions {
15 + timeout?: number | false
16 + modal?: undefined | ((cmd: string, params?: Dict) => (() => unknown))
17 + onResponse?: (res: Response, body: any) => any
18 +}
19 +
20 +const defaultApiCallOptions: ApiCallOptions = {}
21 +export function setDefaultApiCallOptions(options: Partial<ApiCallOptions>) {
22 + Object.assign(defaultApiCallOptions, options)
23 +}
24 +
25 +export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptions={}) {
26 + _.defaults(options, defaultApiCallOptions)
27 + const stop = options.modal?.(cmd, params)
28 + const csrf = getCsrf()
29 + if (csrf)
30 + params = { csrf, ...params }
31 + const controller = new AbortController()
32 + if (options.timeout !== false)
33 + setTimeout(() => controller.abort('timeout'), 1000*(timeoutByApi[cmd] ?? options.timeout ?? 10))
34 + return Object.assign(fetch(PREFIX + cmd, {
35 + method: 'POST',
36 + headers: { 'content-type': 'application/json' },
37 + signal: controller.signal,
38 + body: params && JSON.stringify(params),
39 + }).then(async res => {
40 + stop?.()
41 + let body: any = await res.text()
42 + try { body = JSON.parse(body) }
43 + catch {}
44 + console.debug(res.ok ? 'API' : 'API FAILED', cmd, params, '>>', body)
45 + await options.onResponse?.(res, body)
46 + if (!res.ok)
47 + throw new ApiError(res.status, body || `Failed API ${cmd}: ${res.statusText}`)
48 + return body as T
49 + }, err => {
50 + stop?.()
51 + if (err?.message?.includes('fetch'))
52 + throw Error("Network error")
53 + throw err
54 + }), {
55 + abort() {
56 + controller.abort('cancel')
57 + }
58 + })
59 +}
60 +
61 +export class ApiError extends Error {
62 + constructor(readonly code:number, message: string) {
63 + super(message);
64 + }
65 +}
66 +
67 +export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undefined, undefined | Error, ()=>void] {
68 + const [ret, setRet] = useStateMounted<T | undefined>(undefined)
69 + const [err, setErr] = useStateMounted<Error | undefined>(undefined)
70 + const [forcer, setForcer] = useStateMounted(0)
71 + const loadingRef = useRef<ReturnType<typeof apiCall>>()
72 + const reloadingRef = useRef<any>()
73 + useEffect(()=>{
74 + loadingRef.current?.abort()
75 + setRet(undefined)
76 + setErr(undefined)
77 + if (!cmd) return
78 + let aborted = false
79 + const req = apiCall<T>(cmd, params)
80 + const wholePromise = req.then(x => aborted || setRet(x), x => aborted || setErr(x))
81 + .finally(()=> loadingRef.current = undefined)
82 + loadingRef.current = Object.assign(wholePromise, {
83 + abort() {
84 + aborted = true
85 + req.abort()
86 + }
87 + })
88 + reloadingRef.current?.resolve(wholePromise)
89 + }, [cmd, JSON.stringify(params), forcer]) //eslint-disable-line -- json-ize to detect deep changes
90 + const reload = useCallback(() => loadingRef.current
91 + || setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
92 + [setForcer])
93 + return [ret, err, reload]
94 +}
95 +
96 +type EventHandler = (type:string, data?:any) => void
97 +
98 +export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
99 + console.debug('API EVENTS', cmd, params)
100 + const processed: Record<string,string> = {}
101 + for (const k in params) {
102 + const v = params[k]
103 + if (v !== undefined)
104 + processed[k] = JSON.stringify(v)
105 + }
106 + const csrf = getCsrf()
107 + if (csrf)
108 + processed.csrf = JSON.stringify(csrf)
109 + const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
110 + source.onopen = () => cb('connected')
111 + source.onerror = err => cb('error', err)
112 + source.onmessage = ({ data }) => {
113 + if (!data) {
114 + cb('closed')
115 + return source.close()
116 + }
117 + try { data = JSON.parse(data) }
118 + catch {
119 + return cb('string', data)
120 + }
121 + console.debug('SSE msg', data)
122 + cb('msg', data)
123 + }
124 + return source
125 +}
126 +
127 +function getCsrf() {
128 + return getCookie('csrf')
129 +}
130 +
131 +export function useApiEvents(cmd: string, params: Dict={}) {
132 + const [data, setData] = useStateMounted<any>(undefined)
133 + const [error, setError] = useStateMounted<any>(undefined)
134 + const [loading, setLoading] = useStateMounted(false)
135 + useEffect(() => {
136 + const src = apiEvents(cmd, params, (type, data) => {
137 + switch (type) {
138 + case 'error':
139 + setError("Connection error")
140 + return stop()
141 + case 'closed':
142 + return stop()
143 + case 'msg':
144 + if (src?.readyState === src?.CLOSED)
145 + return stop()
146 + return setData(data)
147 + }
148 + })
149 + return () => {
150 + src.close()
151 + stop()
152 + }
153 +
154 + function stop() {
155 + setLoading(false)
156 + }
157 + }, [cmd, JSON.stringify(params)]) //eslint-disable-line
158 + return { data, loading, error }
159 +}
160 +
161 +export async function getNotification(channel: string, cb: (name: string, data:any) => void): Promise<EventSource> {
162 + return new Promise(resolve => {
163 + const ret = apiEvents('get_notifications', { channel }, (type, entries) => {
164 + if (type === 'connected')
165 + return resolve(ret)
166 + if (type !== 'msg') return
167 + for (const { name, data } of entries)
168 + if (name)
169 + cb(name, data)
170 + })
171 + })
172 +}
shared/index.ts
+8
@@ -156,3 +156,11 @@ export function pendingPromise<T>() {
156 export function isMobile() {
157 return window.innerWidth < 800
158 }
159 +
160 +export function getHFS() {
161 + return (window as any).HFS
162 +}
163 +
164 +export function getPrefixUrl() {
165 + return getHFS().prefixUrl
166 +}