hfs 0.26.7 → 0.26.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/admin/.DS_Store +0 -0
  2. package/admin/.eslintrc +8 -0
  3. package/admin/.gitignore +23 -0
  4. package/admin/index.html +1 -3
  5. package/admin/package.json +67 -0
  6. package/admin/{logo.svg → public/logo.svg} +0 -0
  7. package/admin/src/AccountForm.ts +92 -0
  8. package/admin/src/AccountsPage.ts +143 -0
  9. package/admin/src/App.ts +83 -0
  10. package/admin/src/ArrayField.ts +84 -0
  11. package/admin/src/ConfigPage.ts +279 -0
  12. package/admin/src/FileField.ts +52 -0
  13. package/admin/src/FileForm.ts +148 -0
  14. package/admin/src/FilePicker.ts +166 -0
  15. package/admin/src/HomePage.ts +96 -0
  16. package/admin/src/InstalledPlugins.ts +158 -0
  17. package/admin/src/LoginRequired.ts +75 -0
  18. package/admin/src/LogoutPage.ts +27 -0
  19. package/admin/src/LogsPage.ts +75 -0
  20. package/admin/src/MainMenu.ts +74 -0
  21. package/admin/src/MenuButton.ts +38 -0
  22. package/admin/src/MonitorPage.ts +200 -0
  23. package/admin/src/OnlinePlugins.ts +101 -0
  24. package/admin/src/PermField.ts +80 -0
  25. package/admin/src/PluginsPage.ts +27 -0
  26. package/admin/src/VfsMenuBar.ts +58 -0
  27. package/admin/src/VfsPage.ts +124 -0
  28. package/admin/src/VfsTree.ts +95 -0
  29. package/admin/src/addFiles.ts +59 -0
  30. package/admin/src/api.ts +246 -0
  31. package/admin/src/dialog.ts +203 -0
  32. package/admin/src/index.css +21 -0
  33. package/admin/src/index.ts +10 -0
  34. package/admin/src/md.ts +31 -0
  35. package/admin/src/misc.ts +141 -0
  36. package/admin/src/react-app-env.d.ts +1 -0
  37. package/admin/src/reportWebVitals.ts +15 -0
  38. package/admin/src/setupTests.ts +5 -0
  39. package/admin/src/state.ts +40 -0
  40. package/admin/src/theme.ts +37 -0
  41. package/admin/tsconfig.json +26 -0
  42. package/admin/vite.config.ts +32 -0
  43. package/frontend/.DS_Store +0 -0
  44. package/frontend/.eslintrc +8 -0
  45. package/frontend/.gitignore +23 -0
  46. package/frontend/index.html +1 -3
  47. package/frontend/package.json +51 -0
  48. package/frontend/{fontello.css → public/fontello.css} +0 -0
  49. package/frontend/{fontello.woff2 → public/fontello.woff2} +0 -0
  50. package/frontend/src/App.ts +25 -0
  51. package/frontend/src/Breadcrumbs.ts +43 -0
  52. package/frontend/src/BrowseFiles.ts +141 -0
  53. package/frontend/src/Head.ts +45 -0
  54. package/frontend/src/UserPanel.ts +52 -0
  55. package/frontend/src/api.ts +78 -0
  56. package/frontend/src/components.ts +54 -0
  57. package/frontend/src/dialog.css +76 -0
  58. package/frontend/src/dialog.ts +105 -0
  59. package/frontend/src/icons.ts +46 -0
  60. package/frontend/src/index.scss +307 -0
  61. package/frontend/src/index.ts +10 -0
  62. package/frontend/src/login.ts +50 -0
  63. package/frontend/src/menu.ts +188 -0
  64. package/frontend/src/misc.ts +54 -0
  65. package/frontend/src/options.ts +52 -0
  66. package/frontend/src/react-app-env.d.ts +1 -0
  67. package/frontend/src/reportWebVitals.ts +15 -0
  68. package/frontend/src/setupTests.ts +5 -0
  69. package/frontend/src/state.ts +82 -0
  70. package/frontend/src/useAuthorized.ts +17 -0
  71. package/frontend/src/useFetchList.ts +144 -0
  72. package/frontend/src/useTheme.ts +23 -0
  73. package/frontend/tsconfig.json +26 -0
  74. package/frontend/vite.config.ts +21 -0
  75. package/package.json +2 -1
  76. package/plugins/vhosting/plugin.js +1 -1
  77. package/src/QuickZipStream.ts +279 -0
  78. package/src/ThrottledStream.ts +98 -0
  79. package/src/adminApis.ts +161 -0
  80. package/src/api.accounts.ts +78 -0
  81. package/src/api.auth.ts +131 -0
  82. package/src/api.file_list.ts +102 -0
  83. package/src/api.helpers.ts +30 -0
  84. package/src/api.monitor.ts +106 -0
  85. package/src/api.plugins.ts +139 -0
  86. package/src/api.vfs.ts +182 -0
  87. package/src/apiMiddleware.ts +124 -0
  88. package/src/block.ts +35 -0
  89. package/src/commands.ts +122 -0
  90. package/src/config.ts +166 -0
  91. package/src/connections.ts +60 -0
  92. package/src/const.ts +57 -0
  93. package/src/crypt.ts +16 -0
  94. package/src/debounceAsync.ts +51 -0
  95. package/src/events.ts +6 -0
  96. package/src/frontEndApis.ts +17 -0
  97. package/src/github.ts +102 -0
  98. package/src/index.ts +53 -0
  99. package/src/listen.ts +220 -0
  100. package/src/log.ts +128 -0
  101. package/src/middlewares.ts +176 -0
  102. package/src/misc.ts +149 -0
  103. package/src/pbkdf2.ts +83 -0
  104. package/src/perm.ts +194 -0
  105. package/src/plugins.ts +342 -0
  106. package/src/serveFile.ts +104 -0
  107. package/src/serveGuiFiles.ts +95 -0
  108. package/src/sse.ts +29 -0
  109. package/src/throttler.ts +106 -0
  110. package/src/update.ts +67 -0
  111. package/src/util-files.ts +137 -0
  112. package/src/util-generators.ts +29 -0
  113. package/src/util-http.ts +29 -0
  114. package/src/vfs.ts +258 -0
  115. package/src/watchLoad.ts +75 -0
  116. package/src/zip.ts +69 -0
  117. package/admin/assets/index.0f549e00.js +0 -281
  118. package/admin/assets/index.dcc78777.css +0 -1
  119. package/admin/assets/sha512.ea1121b3.js +0 -8
  120. package/frontend/assets/index.1151988f.js +0 -85
  121. package/frontend/assets/index.93366732.css +0 -1
  122. package/frontend/assets/sha512.bb881250.js +0 -8
  123. package/src/QuickZipStream.js +0 -285
  124. package/src/ThrottledStream.js +0 -93
  125. package/src/adminApis.js +0 -169
  126. package/src/api.accounts.js +0 -59
  127. package/src/api.auth.js +0 -130
  128. package/src/api.file_list.js +0 -103
  129. package/src/api.helpers.js +0 -32
  130. package/src/api.monitor.js +0 -102
  131. package/src/api.plugins.js +0 -127
  132. package/src/api.vfs.js +0 -164
  133. package/src/apiMiddleware.js +0 -136
  134. package/src/block.js +0 -33
  135. package/src/commands.js +0 -124
  136. package/src/config.js +0 -168
  137. package/src/connections.js +0 -57
  138. package/src/const.js +0 -83
  139. package/src/crypt.js +0 -21
  140. package/src/debounceAsync.js +0 -48
  141. package/src/events.js +0 -9
  142. package/src/frontEndApis.js +0 -38
  143. package/src/github.js +0 -102
  144. package/src/index.js +0 -55
  145. package/src/listen.js +0 -235
  146. package/src/log.js +0 -137
  147. package/src/middlewares.js +0 -154
  148. package/src/misc.js +0 -160
  149. package/src/pbkdf2.js +0 -74
  150. package/src/perm.js +0 -176
  151. package/src/plugins.js +0 -343
  152. package/src/serveFile.js +0 -104
  153. package/src/serveGuiFiles.js +0 -113
  154. package/src/sse.js +0 -29
  155. package/src/throttler.js +0 -91
  156. package/src/update.js +0 -69
  157. package/src/util-files.js +0 -148
  158. package/src/util-generators.js +0 -30
  159. package/src/util-http.js +0 -30
  160. package/src/vfs.js +0 -227
  161. package/src/watchLoad.js +0 -73
  162. package/src/zip.js +0 -69
@@ -0,0 +1,279 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import { Box, Button, FormHelperText, Link } from '@mui/material';
4
+ import { createElement as h, useEffect, useRef } from 'react';
5
+ import { apiCall, useApi, useApiEx } from './api'
6
+ import { state, useSnapState } from './state'
7
+ import { Info, Refresh } from '@mui/icons-material'
8
+ import { Dict, modifiedSx } from './misc'
9
+ import { subscribeKey } from 'valtio/utils'
10
+ import { Form, BoolField, NumberField, SelectField, StringStringField, FieldProps, Field } from '@hfs/mui-grid-form';
11
+ import FileField from './FileField'
12
+ import { alertDialog, closeDialog, confirmDialog, formDialog, newDialog, toast, waitDialog } from './dialog'
13
+ import { proxyWarning } from './HomePage'
14
+ import _ from 'lodash';
15
+ import { proxy, useSnapshot } from 'valtio'
16
+
17
+ let loaded: Dict | undefined
18
+ let exposedReloadStatus: undefined | (() => void)
19
+ const pageState = proxy({
20
+ changes: {} as Dict
21
+ })
22
+
23
+ subscribeKey(state, 'config', recalculateChanges)
24
+
25
+ export const logLabels = {
26
+ log: "Access log file",
27
+ error_log: "Access error log file"
28
+ }
29
+
30
+ export default function ConfigPage() {
31
+ const { data, reload: reloadConfig, element } = useApiEx('get_config', { omit: ['vfs'] })
32
+ let snap = useSnapState()
33
+ const { changes } = useSnapshot(pageState)
34
+ const statusApi = useApiEx(data && 'get_status')
35
+ const status = statusApi.data
36
+ const reloadStatus = exposedReloadStatus = statusApi.reload
37
+ useEffect(() => void(reloadStatus()), [data]) //eslint-disable-line
38
+ useEffect(() => () => exposedReloadStatus = undefined, []) // clear on unmount
39
+
40
+ const admins = useApi('get_admins')[0]?.list
41
+
42
+ if (element)
43
+ return element
44
+ if (statusApi.error)
45
+ return statusApi.element
46
+ const values = (loaded !== data) ? (state.config = loaded = data) : snap.config
47
+ const maxSpeedDefaults = {
48
+ comp: NumberField,
49
+ min: 1,
50
+ placeholder: "no limit",
51
+ }
52
+ return h(Form, {
53
+ sx: { maxWidth: '60em' },
54
+ values,
55
+ set(v, k) {
56
+ state.config[k] = v
57
+ },
58
+ stickyBar: true,
59
+ onError: alertDialog,
60
+ save: {
61
+ onClick: save,
62
+ sx: modifiedSx( Object.keys(changes).length>0),
63
+ },
64
+ barSx: { gap: 2 },
65
+ addToBar: [h(Button, {
66
+ onClick() {
67
+ reloadConfig()
68
+ reloadStatus()
69
+ },
70
+ startIcon: h(Refresh),
71
+ }, "Reload")],
72
+ defaults({ comp }) {
73
+ return comp === ServerPort ? { sm: 6, md: 3 }
74
+ : comp === NumberField ? { sm: 3 }
75
+ : { sm: 6 }
76
+ },
77
+ fields: [
78
+ { k: 'max_kbps', ...maxSpeedDefaults, label: "Limit output KB/s", helperText: "Doesn't apply to localhost" },
79
+ { k: 'max_kbps_per_ip', ...maxSpeedDefaults, label: "Limit output KB/s per-ip" },
80
+ { k: 'port', comp: ServerPort, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
81
+ { k: 'https_port', comp: ServerPort, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
82
+ onChange(v: number) {
83
+ if (v >= 0 && values.https_port < 0 && !values.cert)
84
+ suggestMakingCert()
85
+ return v
86
+ }
87
+ },
88
+ values.https_port >= 0 && { k: 'cert', comp: FileField, label: "HTTPS certificate file",
89
+ ...with_(status?.https.error, e => isCertError(e) ? {
90
+ error: true,
91
+ helperText: [e, ' - ', h(Link, { key: 'fix', sx: { cursor: 'pointer' }, onClick: makeCertAndSave }, "make one")]
92
+ } : null)
93
+ },
94
+ values.https_port >= 0 && { k: 'private_key', comp: FileField, label: "HTTPS private key file",
95
+ ...with_(status?.https.error, e => isKeyError(e) ? { error: true, helperText: e } : null)
96
+ },
97
+ { k: 'open_browser_at_start', comp: BoolField },
98
+ { k: 'localhost_admin', comp: BoolField, label: "Admin access for localhost connections",
99
+ getError: x => !x && admins?.length===0 && "First create at least one admin account",
100
+ helperText: "To access Admin without entering credentials"
101
+ },
102
+ { k: 'log', label: logLabels.log, lg: 3, helperText: "Requests are logged here" },
103
+ { k: 'error_log', label: logLabels.error_log, lg: 3, placeholder: "errors go to main log", helperText: "If you want errors in a different log" },
104
+ { k: 'log_rotation', comp: SelectField, options: [{ value:'', label:"disabled" }, 'daily', 'weekly', 'monthly' ],
105
+ helperText: "To avoid an endlessly-growing single log file, you can opt for rotation"
106
+ },
107
+ { k: 'proxies', comp: NumberField, min: 0, max: 9, sm: 6, lg: 6, label: "How many HTTP proxies between this server and users?",
108
+ error: proxyWarning(values, status),
109
+ helperText: "Wrong number will prevent detection of users' IP address"
110
+ },
111
+ { k: 'allowed_referer', placeholder: "any",
112
+ helperText: values.allowed_referer ? "Leave empty to allow any" : "Use this to avoid direct links from other websites", },
113
+ { k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 6, label: "Calculate ZIP size for seconds",
114
+ helperText: "If time is not enough, the browser will not show download percentage" },
115
+ { k: 'custom_header', multiline: true, sx: { '& textarea': { fontFamily: 'monospace' } },
116
+ helperText: "Any HTML code here will be used as header for the Frontend"
117
+ },
118
+ { k: 'mime', comp: StringStringField,
119
+ keyLabel: "Files", keyWidth: 7,
120
+ valueLabel: "Mime type", valueWidth: 4
121
+ },
122
+ { k: 'block', label: "Blocked IPs", multiline: true, minRows:3, helperText: "Enter an IP address for each line. CIDR and * are supported.",
123
+ fromField: (all:string) => all.split('\n').map(s => s.trim()).filter(Boolean).map(ip => ({ ip })),
124
+ toField: (all: any) => !Array.isArray(all) ? '' : all.map(x => x?.ip).filter(Boolean).join('\n')
125
+ },
126
+ ]
127
+ })
128
+
129
+ async function save() {
130
+ if (_.isEmpty(changes))
131
+ return toast("Nothing to save")
132
+ const loc = window.location
133
+ const newPort = loc.protocol === 'http:' ? changes.port : changes.https_port
134
+ if (newPort <= 0 && !await confirmDialog("You are switching off the server port and you will be disconnected"))
135
+ return
136
+ else if (newPort > 0 && !await confirmDialog("You are changing the port and you may be disconnected"))
137
+ return
138
+ if (loc.protocol === 'https:' && ('cert' in changes || 'private_key' in changes) && !await confirmDialog("You may disrupt https service, kicking you out"))
139
+ return
140
+ await apiCall('set_config', { values: changes })
141
+ if (newPort > 0) {
142
+ await alertDialog("You are being redirected but in some cases this may fail. Hold on tight!", 'warning')
143
+ return window.location.href = loc.protocol + '//' + loc.hostname + ':' + newPort + loc.pathname
144
+ }
145
+ setTimeout(reloadStatus, 'port' in changes || 'https_port' in changes ? 1000 : 0) // give some time to consider new ports
146
+ Object.assign(loaded!, changes) // since changes are recalculated subscribing state.config, but it depends on 'loaded' to (which cannot be subscribed), be sure to update loaded first
147
+ recalculateChanges()
148
+ toast("Changes applied", 'success')
149
+ }
150
+ }
151
+
152
+ function recalculateChanges() {
153
+ const o: Dict = {}
154
+ if (state.config)
155
+ for (const [k, v] of Object.entries(state.config))
156
+ if (JSON.stringify(v) !== JSON.stringify(loaded?.[k]))
157
+ o[k] = v
158
+ pageState.changes = o
159
+ }
160
+
161
+ export function isCertError(error: any) {
162
+ return /certificate/.test(error)
163
+ }
164
+
165
+ export function isKeyError(error: any) {
166
+ return /private key/.test(error)
167
+ }
168
+
169
+ function ServerPort({ label, value, onChange, getApi, status, suggestedPort=1, error, helperText }: FieldProps<number | null>) {
170
+ const lastCustom = useRef(suggestedPort)
171
+ if (value! > 0)
172
+ lastCustom.current = value!
173
+ const selectValue = Number(value! > 0 ? lastCustom.current : value) || 0
174
+ let errMsg = status?.error
175
+ if (errMsg)
176
+ if (isCertError(errMsg) || isKeyError(errMsg))
177
+ errMsg = undefined // never mind, we'll show this error elsewhere
178
+ else
179
+ error = true
180
+ return h(Box, {},
181
+ h(Box, { display: 'flex' },
182
+ h(SelectField as Field<number>, {
183
+ sx: { flexGrow: 1 },
184
+ label,
185
+ error,
186
+ value: selectValue,
187
+ options: [
188
+ { label: "off", value: -1 },
189
+ { label: "random", value: 0 },
190
+ { label: "choose", value: lastCustom.current },
191
+ ],
192
+ onChange,
193
+ }),
194
+ value! > 0 && h(NumberField, {
195
+ label: "Number",
196
+ fullWidth: false,
197
+ value,
198
+ onChange,
199
+ getApi,
200
+ error,
201
+ min: 1,
202
+ max: 65535,
203
+ helperText,
204
+ sx: { minWidth: '5.5em' }
205
+ }),
206
+ ),
207
+ status && h(FormHelperText, { error },
208
+ status === true ? '...'
209
+ : errMsg ?? (status?.listening && "Correctly working on port " + status.port) )
210
+ )
211
+ }
212
+
213
+ function suggestMakingCert() {
214
+ newDialog({
215
+ Content: () => h(Box, {},
216
+ h(Box, { display: 'flex', gap: 1 },
217
+ h(Info), "You are enabling HTTPs. It needs a valid certificate + private key to work."
218
+ ),
219
+ h(Box, { mt: 4, display: 'flex', gap: 1, justifyContent: 'space-around', },
220
+ h(Button, { variant: 'contained', onClick(){
221
+ closeDialog()
222
+ makeCertAndSave().then()
223
+ } }, "Help me!"),
224
+ h(Button, { onClick: closeDialog }, "I will handle the matter myself"),
225
+ ),
226
+ )
227
+ })
228
+ }
229
+
230
+ export async function makeCertAndSave() {
231
+ if (!window.crypto.subtle)
232
+ return alertDialog("Retry this procedure on localhost", 'warning')
233
+ const res = await formDialog<{ commonName: string }>({
234
+ fields: [
235
+ h(Box, { display: 'flex', gap: 1 }, h(Info), "We'll generate a basic certificate for you"),
236
+ { k: 'commonName', label: "Enter a domain, or leave empty" }
237
+ ],
238
+ save: { children: "Continue" },
239
+ })
240
+ if (!res) return
241
+ const close = waitDialog()
242
+ try {
243
+ const saved = await apiCall('save_pem', await makeCert(res))
244
+ await apiCall('set_config', { values: saved })
245
+ if (loaded) // when undefined we are not in this page
246
+ Object.assign(loaded, saved)
247
+ setTimeout(exposedReloadStatus!, 1000) // give some time for backend to apply
248
+ Object.assign(state.config, saved)
249
+ await alertDialog("Certificate saved", 'success')
250
+ }
251
+ finally { close() }
252
+ }
253
+
254
+ async function makeCert(attributes: Record<string, string>) {
255
+ // this relies on having loaded node-forge/dist/forge.min.js
256
+ const { pki } = (window as any).forge
257
+ const keys = pki.rsa.generateKeyPair(2048);
258
+ const cert = pki.createCertificate();
259
+ cert.publicKey = keys.publicKey
260
+ cert.serialNumber = '01'
261
+ cert.validity.notBefore = new Date()
262
+ cert.validity.notAfter = new Date()
263
+ cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1)
264
+
265
+ const attrs = Object.entries(attributes).map(x => ({ name: x[0], value: x[1] }))
266
+ cert.setSubject(attrs)
267
+ cert.setIssuer(attrs)
268
+ cert.sign(keys.privateKey)
269
+
270
+ return {
271
+ cert: pki.certificateToPem(cert),
272
+ private_key: pki.privateKeyToPem(keys.privateKey),
273
+ }
274
+ }
275
+
276
+ export function with_<T,RT>(par:T, cb: (par:T) => RT) {
277
+ return cb(par)
278
+ }
279
+
@@ -0,0 +1,52 @@
1
+ import { FieldProps, StringField } from '@hfs/mui-grid-form'
2
+ import { createElement as h } from 'react'
3
+ import { InputAdornment } from '@mui/material'
4
+ import { Eject } from '@mui/icons-material'
5
+ import { IconBtn, useBreakpoint } from './misc'
6
+ import { newDialog } from '@hfs/shared'
7
+ import FilePicker from './FilePicker'
8
+ import { apiCall } from './api'
9
+
10
+ export default function FileField({ value, onChange, files=true, folders=false, fileMask, defaultPath, title, ...props }: FieldProps<string>) {
11
+ const large = useBreakpoint('md')
12
+ return h(StringField, {
13
+ ...props,
14
+ value,
15
+ onChange,
16
+ InputProps: {
17
+ endAdornment: h(InputAdornment, { position: 'end' },
18
+ h(IconBtn, {
19
+ icon: Eject,
20
+ title: "Browse files...",
21
+ edge: 'end',
22
+ onClick() {
23
+ const close = newDialog({
24
+ title: title ?? (files ? "Pick a file" : "Pick a folder"),
25
+ dialogProps: {
26
+ fullScreen: !large,
27
+ sx: { minWidth: 'min(90vw, 40em)', minHeight: 'calc(100vh - 9em)' }
28
+ },
29
+ Content() {
30
+ return h(FilePicker, {
31
+ multiple: false,
32
+ folders,
33
+ files,
34
+ fileMask,
35
+ from: value || defaultPath,
36
+ async onSelect(sel) {
37
+ let one = sel?.[0]
38
+ if (!one) return
39
+ const cwd = (await apiCall('get_cwd'))?.path
40
+ if (one.startsWith(cwd))
41
+ one = one.slice(cwd.length+1)
42
+ onChange(one, { was: value, event: 'picker' })
43
+ close()
44
+ }
45
+ })
46
+ },
47
+ })
48
+ },
49
+ }))
50
+ }
51
+ })
52
+ }
@@ -0,0 +1,148 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import { state } from './state'
4
+ import { createElement as h, useEffect, useMemo, useState } from 'react'
5
+ import { Alert, Button } from '@mui/material'
6
+ import { BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, SelectField } from '@hfs/mui-grid-form'
7
+ import { apiCall, useApiEx } from './api'
8
+ import { formatBytes, isEqualLax, modifiedSx, onlyTruthy } from './misc'
9
+ import { reloadVfs, VfsNode, Who } from './VfsPage'
10
+ import md from './md'
11
+ import _ from 'lodash'
12
+ import FileField from './FileField'
13
+ import { alertDialog } from './dialog'
14
+
15
+ interface Account { username: string }
16
+
17
+ export default function FileForm({ file }: { file: VfsNode }) {
18
+ const { parent, children, isRoot, ...rest } = file
19
+ const [values, setValues] = useState(rest)
20
+ useEffect(() => {
21
+ setValues(Object.assign({ can_see: null, can_read: null }, rest))
22
+ }, [file]) //eslint-disable-line
23
+
24
+ const { source } = file
25
+ const isDir = file.type === 'folder'
26
+ const hasSource = source !== undefined // we need a boolean
27
+ const realFolder = hasSource && isDir
28
+ const inheritedPerms = useMemo(() => {
29
+ const ret = { can_read: true, can_see: true }
30
+ // reconstruct parents backward
31
+ const parents = []
32
+ let run = parent
33
+ while (run) {
34
+ parents.unshift(run)
35
+ run = run.parent
36
+ }
37
+ for (const node of parents)
38
+ Object.assign(ret, node)
39
+ return ret
40
+ }, [parent])
41
+ const showCanSee = (values.can_read ?? inheritedPerms.can_read) === true
42
+ const showTimestamps = hasSource && Boolean(values.ctime)
43
+
44
+ const { data, element } = useApiEx<{ list: Account[] }>('get_accounts')
45
+ if (element || !data)
46
+ return element
47
+ const accounts = data.list
48
+
49
+ return h(Form, {
50
+ values,
51
+ set(v, k) {
52
+ setValues({ ...values, [k]: v })
53
+ },
54
+ addToBar: [
55
+ h(Button, { // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
56
+ sx: { ml: 2 },
57
+ onClick(){
58
+ state.selectedFiles = []
59
+ }
60
+ }, "Close")
61
+ ],
62
+ onError: alertDialog,
63
+ save: {
64
+ sx: modifiedSx(!isEqualLax(values, file)),
65
+ async onClick() {
66
+ const props = { ...values }
67
+ if (!props.masks)
68
+ props.masks = null // undefined cannot be serialized
69
+ if (!isRoot)
70
+ delete props.source
71
+ await apiCall('set_vfs', {
72
+ uri: values.id,
73
+ props,
74
+ })
75
+ if (props.name !== file.name) // when the name changes, the id of the selected file is changing too, and we have to update it in the state if we want it to be correctly re-selected after reload
76
+ state.selectedFiles[0].id = file.id.slice(0, -file.name.length) + props.name
77
+ reloadVfs()
78
+ }
79
+ },
80
+ fields: [
81
+ isRoot ? h(Alert,{ severity: 'info' }, "This is Home, the root of your shared files. Options set here will be applied to all files.")
82
+ : { k: 'name', required: true, helperText: source && "You can decide a name that's different from the one on your disk" },
83
+ isRoot ? { k: 'source', comp: FileField, files: false, helperText: "If you specify a folder here, its files will be listed in the home" }
84
+ : (hasSource && { k: 'source', comp: DisplayField, multiline: true }),
85
+ { k: 'can_read', label:"Who can download", xl: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
86
+ helperText: "Note: who can't download won't see it in the list"
87
+ },
88
+ showCanSee && { k: 'can_see', label:"Who can see", xl: 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_see,
89
+ helperText: "If you hide this element it will not be listed, but will still be accessible if you have a direct link"
90
+ },
91
+ hasSource && !realFolder && { k: 'size', comp: DisplayField, toField: formatBytes },
92
+ showTimestamps && { k: 'ctime', comp: DisplayField, lg: 6, label: 'Created', toField: formatTimestamp },
93
+ showTimestamps && { k: 'mtime', comp: DisplayField, lg: 6, label: 'Modified', toField: formatTimestamp },
94
+ file.website && { k: 'default', comp: BoolField, label:"Serve index.html",
95
+ toField: Boolean, fromField: (v:boolean) => v ? 'index.html' : null,
96
+ helperText: md("This folder may be a website because contains `index.html`. Enabling this will show the website instead of the list of files.")
97
+ },
98
+ isDir && { k: 'masks', multiline: true, xl: true, toField: JSON.stringify, fromField: v => v ? JSON.parse(v) : undefined,
99
+ helperText: "This is a special field. Leave it empty unless you know what you are doing." }
100
+ ]
101
+ })
102
+ }
103
+
104
+ function formatTimestamp(x: string) {
105
+ return x ? new Date(x).toLocaleString() : '-'
106
+ }
107
+
108
+ interface WhoFieldProps extends FieldProps<Who> { accounts: Account[] }
109
+ function WhoField({ value, onChange, parent, inherit, accounts, helperText, ...rest }: WhoFieldProps) {
110
+ const options = useMemo(() =>
111
+ onlyTruthy([
112
+ { value: null, label: (parent ? "Same as parent: " : "Default: " ) + who2desc(inherit === 0 ? true : inherit) },
113
+ { value: true },
114
+ { value: false },
115
+ { value: '*' },
116
+ { value: [], label: "Select accounts" },
117
+ ].map(x => x && x.value !== inherit // don't offer inherited value twice
118
+ && { label: _.capitalize(who2desc(x.value)), ...x })), // default label
119
+ [inherit, parent])
120
+
121
+ const arrayMode = Array.isArray(value)
122
+ return h('div', {},
123
+ h(SelectField as Field<Who>, {
124
+ ...rest,
125
+ helperText: !arrayMode && helperText,
126
+ value: arrayMode ? [] : value,
127
+ onChange(v, { was, event }) {
128
+ onChange(v, { was , event })
129
+ },
130
+ options
131
+ }),
132
+ arrayMode && h(MultiSelectField as Field<string[]>, {
133
+ label: accounts?.length ? "Choose accounts for " + rest.label : "You didn't create any account yet",
134
+ value,
135
+ onChange,
136
+ helperText,
137
+ options: accounts?.map(a => ({ value: a.username, label: a.username })) || [],
138
+ })
139
+ )
140
+ }
141
+
142
+ function who2desc(who: any) {
143
+ return who === false ? "no one"
144
+ : who === true ? "anyone"
145
+ : who === '*' ? "any account (login required)"
146
+ : Array.isArray(who) ? who.join(', ')
147
+ : "*UNKNOWN*" + JSON.stringify(who)
148
+ }
@@ -0,0 +1,166 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import { createElement as h, Fragment, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { apiCall, useApiList } from './api'
5
+ import _ from 'lodash'
6
+ import {
7
+ Alert,
8
+ Box,
9
+ Button,
10
+ Checkbox,
11
+ ListItemIcon,
12
+ ListItemText,
13
+ MenuItem,
14
+ TextField,
15
+ Typography
16
+ } from '@mui/material'
17
+ import { enforceFinal, formatBytes, isWindowsDrive, spinner, Center, err2msg } from './misc'
18
+ import { ArrowUpward, VerticalAlignTop } from '@mui/icons-material'
19
+ import { StringField } from '@hfs/mui-grid-form'
20
+ import { FileIcon, FolderIcon } from './VfsTree'
21
+ import { FixedSizeList } from 'react-window'
22
+
23
+ export interface DirEntry { n:string, s?:number, m?:string, c?:string, k?:'d' }
24
+
25
+ interface FilePickerProps {
26
+ onSelect:(v:string[])=>void
27
+ multiple?: boolean
28
+ from?: string
29
+ folders?: boolean
30
+ files?: boolean
31
+ fileMask?: string
32
+ }
33
+ export default function FilePicker({ onSelect, multiple=true, files=true, folders=true, fileMask, from='' }: FilePickerProps) {
34
+ const [cwd, setCwd] = useState(from)
35
+ const [ready, setReady] = useState(false)
36
+ const isWindows = useRef(false)
37
+ useEffect(() => {
38
+ apiCall('resolve_path', { path: from, closestFolder: true }).then(res => {
39
+ if (typeof res.path === 'string') {
40
+ setCwd(res.path)
41
+ isWindows.current = res.path[1] === ':'
42
+ }
43
+ }).finally(() => setReady(true))
44
+ }, [from])
45
+ const { list, error, loading } = useApiList<DirEntry>(ready && 'ls', { path: cwd, files, fileMask })
46
+ useEffect(() => {
47
+ setSel([])
48
+ setFilter('')
49
+ }, [cwd])
50
+
51
+ const [sel, setSel] = useState<string[]>([])
52
+ const [filter, setFilter] = useState('')
53
+ const setFilterBounced = useMemo(() => _.debounce((x:string) => setFilter(x)), [])
54
+ const filterMatch = useMemo(() => {
55
+ const re = new RegExp(_.escapeRegExp(filter), 'i')
56
+ return (v:string) => re.test(v)
57
+ }, [filter])
58
+
59
+ const [listHeight, setListHeight] = useState(0)
60
+ const filteredList = useMemo(() => list.filter(it => filterMatch(it.n)), [list,filterMatch])
61
+ if (loading)
62
+ return spinner()
63
+ const root = isWindows.current ? '' : '/'
64
+ const pathDelimiter = isWindows.current ? '\\' : '/'
65
+ const cwdDelimiter = enforceFinal(pathDelimiter, cwd)
66
+ const isRoot = cwd.length < 2
67
+ return h(Fragment, {},
68
+ h(Box, { display: 'flex', gap: 1 },
69
+ h(Button, {
70
+ title: "root",
71
+ disabled: isRoot,
72
+ onClick() {
73
+ setCwd(root)
74
+ }
75
+ }, h(VerticalAlignTop)),
76
+ h(Button, {
77
+ disabled: isRoot,
78
+ title: "parent folder",
79
+ onClick() {
80
+ const cwdND = /[\\/]$/.test(cwd) ? cwd.slice(0,-1) : cwd // exclude final delimiter, if any
81
+ const parent = isWindowsDrive(cwdND) ? root : cwdND.slice(0, cwdND.lastIndexOf(pathDelimiter) || 1)
82
+ setCwd(parent)
83
+ }
84
+ }, h(ArrowUpward)),
85
+ h(StringField, {
86
+ label: "Current path",
87
+ value: cwd,
88
+ InputLabelProps: { shrink: true },
89
+ async onChange(v) {
90
+ if (!v)
91
+ return setCwd(root)
92
+ const res = await apiCall('resolve_path', { path: v })
93
+ setCwd(res.path)
94
+ },
95
+ }),
96
+ ),
97
+ error ? h(Alert, { severity: 'error' }, err2msg(error))
98
+ : h(Fragment, {},
99
+ h(Box, {
100
+ ref(x?: HTMLElement){
101
+ if (!x) return
102
+ const h = x?.clientHeight - 1
103
+ if (h - listHeight > 1)
104
+ setListHeight(h)
105
+ },
106
+ sx: { flex: 1, display: 'flex', flexDirection: 'column' }
107
+ },
108
+ !list.length ? h(Center, { flex: 1, mt: '4em' }, "No elements in this folder")
109
+ : h(FixedSizeList, {
110
+ width: '100%', height: listHeight,
111
+ itemSize: 46, itemCount: filteredList.length, overscanCount: 5,
112
+ children({ index, style }) {
113
+ const it: DirEntry = filteredList[index]
114
+ const isFolder = it.k === 'd'
115
+ return h(MenuItem, {
116
+ style: { ...style, padding: 0 },
117
+ key: it.n,
118
+ onClick() {
119
+ if (isFolder)
120
+ setCwd(cwdDelimiter + it.n)
121
+ else
122
+ onSelect([cwdDelimiter + it.n])
123
+ }
124
+ },
125
+ multiple && h(Checkbox, {
126
+ checked: sel.includes(it.n),
127
+ disabled: !folders && isFolder,
128
+ onClick(ev) {
129
+ const id = it.n
130
+ const removed = sel.filter(x => x !== id)
131
+ setSel(removed.length < sel.length ? removed : [...sel, id])
132
+ ev.stopPropagation()
133
+ },
134
+ }),
135
+ h(ListItemIcon, {}, h(it.k ? FolderIcon : FileIcon)),
136
+ h(ListItemText, { sx: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, it.n),
137
+ !isFolder && it.s !== undefined && h(Typography, {
138
+ variant: 'body2',
139
+ color: 'text.secondary',
140
+ ml: 4, mr: 1,
141
+ }, formatBytes(it.s))
142
+ )
143
+ }
144
+ })
145
+ ),
146
+ h(Box, { display:'flex', gap: 1 },
147
+ (multiple || folders || !files) && h(Button, {
148
+ variant: 'contained',
149
+ disabled: !cwd || !folders && !sel.length && files,
150
+ sx: { minWidth: 'max-content' },
151
+ onClick() {
152
+ onSelect(sel.length ? sel.map(x => cwdDelimiter + x) : [cwd])
153
+ }
154
+ }, files && (sel.length || !folders) ? `Select (${sel.length})` : `Select this folder`),
155
+ h(TextField, {
156
+ value: filter,
157
+ label: `Filter results (${filteredList.length}${filteredList.length < list.length ? '/'+list.length : ''})`,
158
+ onChange(ev) {
159
+ setFilterBounced(ev.target.value)
160
+ },
161
+ sx: { flex: 1 },
162
+ }),
163
+ ),
164
+ )
165
+ )
166
+ }