main
ts 462 lines 23.3 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 { Box, Button, Divider, FormHelperText } from '@mui/material';
4 import { createElement as h, useEffect, useId, useRef, useState } from 'react'
5 import { apiCall, useApiEx } from './api'
6 import { state, useSnapState } from './state'
7 import { Link as RouterLink } from 'wouter'
8 import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material'
9 import { adminApis } from '../../src/adminApis'
10 import {
11 MAX_TILE_SIZE, REPO_URL, SORT_BY_OPTIONS, THEME_OPTIONS, CFG, IMAGE_FILEMASK,
12 Dict, md, with_, try_, ipForUrl,
13 } from './misc'
14 import {
15 iconTooltip, InLink, LinkBtn, propsForModifiedValues, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported,
16 execDoneMessage,
17 } from './mui'
18 import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
19 import { ArrayField } from './ArrayField'
20 import FileField from './FileField'
21 import { alertDialog, confirmDialog, newDialog, toast } from './dialog'
22 import { proxyWarning } from './HomePage'
23 import _ from 'lodash';
24 import { proxy, subscribe, useSnapshot } from 'valtio'
25 import { TextEditorField } from './TextEditor'
26 import { WhoField } from './FileForm';
27
28 let loaded: Dict | undefined
29 let exposedReloadStatus: undefined | (() => void)
30 const pageState = proxy({
31 changes: {} as Dict
32 })
33
34 //subscribeKey is not working (anymore) on nested changes
35 subscribe(state, (ops) => {
36 if (ops.some(op => op[1][0] === 'config'))
37 recalculateChanges()
38 })
39
40 export default function OptionsPage() {
41 const { data, reload: reloadConfig, element } = useApiEx('get_config', { omit: ['vfs'] })
42 const snap = useSnapState()
43 const { changes } = useSnapshot(pageState)
44 const statusApi = useApiEx<typeof adminApis.get_status>(data && 'get_status')
45 const status = statusApi.data
46 const reloadStatus = exposedReloadStatus = statusApi.reload
47 useEffect(() => void reloadStatus(), [data]) //eslint-disable-line
48 useEffect(() => () => exposedReloadStatus = undefined, []) // clear this on unmount
49 const sm = useBreakpoint('sm')
50 const saveBtnRef = useRef<HTMLButtonElement>(null)
51
52 const admins = useApiEx('get_admins').data?.list
53
54 const hn = window.location.hostname
55 const isLH = hn === 'localhost'
56 const isV6 = hn.includes(':')
57 const listenInterfaceOptions = [
58 { label: "any", value: '', disabled: false },
59 { label: "any IPv4", value: '0.0.0.0', disabled: !isLH && isV6 },
60 { label: "any IPv6", value: '::', disabled: !isLH && !isV6 },
61 ...['127.0.0.1', '::1'].map(x => ({ label: x, value: x, disabled: !isLH && hn !== x })),
62 ...status?.ips?.map(x => ({ value: x, disabled: hn !== x })) || [],
63 ]
64
65 if (element)
66 return element
67 if (statusApi.error)
68 return statusApi.element
69 const values = (loaded !== data) ? (state.config = loaded = data) : snap.config
70 const maxSpeedDefaults = {
71 comp: NumberField,
72 min: 1,
73 unit: "KB/s",
74 placeholder: "no limit",
75 sm: 6,
76 }
77 const maxDownloadsDefaults = {
78 comp: NumberField,
79 placeholder: "no limit",
80 toField: (x: any) => x || '',
81 sm: 4,
82 }
83 const httpsEnabled = values.https_port >= 0
84 return h(Form, {
85 sx: { maxWidth: '60em' },
86 values,
87 set(v, k) {
88 state.config[k] = v
89 },
90 stickyBar: true,
91 onError: alertDialog,
92 save: {
93 ref: saveBtnRef,
94 onClick: save,
95 ...propsForModifiedValues( Object.keys(changes).length>0),
96 },
97 barSx: { gap: 2 },
98 addToBar: [
99 h(Button, {
100 onClick() {
101 reloadConfig()
102 reloadStatus()
103 },
104 startIcon: h(Refresh),
105 }, "Reload"),
106 h(Button, { // @ts-ignore
107 component: RouterLink,
108 href: "/config",
109 startIcon: h(EditNote),
110 }, sm ? "Config file" : "File"),
111 ],
112 defaults() {
113 return { xs: 6 }
114 },
115 fields: [
116 h(Section, { title: "Networking" }),
117 { k: 'port', comp: PortField, xs: 12, sm: 4, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
118 { k: 'https_port', comp: PortField, xs: 12, sm: 4, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
119 onChange(v: number) {
120 if (v >= 0 && !httpsEnabled && !values.cert)
121 void suggestMakingCert()
122 return v
123 }
124 },
125 { k: CFG.upnp_enabled, comp: BoolField, xs: 12, sm: 4, label: "UPnP/SSDP",
126 helperText: "Port forwarding and double-NAT detection" },
127
128 httpsEnabled && { k: 'cert', comp: FileField, sm: 4, label: "HTTPS certificate file",
129 helperText: wikiLink('HTTPS#certificate', "What is this?"),
130 error: with_(status?.https.error, e => isCertError(e) && (
131 status!.https.listening ? e
132 : [e, ' - ', h(LinkBtn, { key: 'fix', onClick: suggestMakingCert }, "make one")] )),
133 },
134 httpsEnabled && { k: 'private_key', comp: FileField, sm: 4, label: "HTTPS private key file",
135 ...with_(status?.https.error, e => isKeyError(e) ? { error: true, helperText: e } : null)
136 },
137 httpsEnabled && { k: 'force_https', comp: BoolField, label: "Force HTTPS", sm: 4, disabled: !httpsEnabled || values.port < 0,
138 helperText: "Not applied to localhost. Doesn't work with proxies."
139 },
140
141 {
142 k: 'listen_interface',
143 comp: SelectField,
144 sm: 4,
145 afterList: listenInterfaceOptions.some(x => x.disabled)
146 && h(Box, { sx: { p: '8px 16px 0', borderTop: '1px solid', fontSize: 'small' } }, "Disabled addresses depend on the address you used to connect"),
147 options: listenInterfaceOptions,
148 },
149 { k: 'max_kbps', ...maxSpeedDefaults, sm: 4, label: "Limit output", helperText: "Doesn't apply to localhost" },
150 { k: 'max_kbps_per_ip', ...maxSpeedDefaults, sm: 4, label: "Limit output per-IP" },
151
152 { k : CFG.max_downloads, ...maxDownloadsDefaults, helperText: "Number of simultaneous downloads" },
153 { k : CFG.max_downloads_per_ip, ...maxDownloadsDefaults, label: "Max downloads per-IP" },
154 { k : CFG.max_downloads_per_account, ...maxDownloadsDefaults, label: "Max downloads per-account", helperText: "Overrides other limits" },
155
156 { k: 'admin_net', comp: NetmaskField, xs: 12, sm: 6, label: "Admin-panel accessible from", placeholder: "any address",
157 helperText: "IP address of browser machine – localhost is an exception"
158 },
159 { k: 'localhost_admin', comp: BoolField, xs: 12, sm: 6, label: "Consider localhost access as Admin",
160 getError: x => !x && admins?.length===0 && "First create at least one admin account",
161 helperText: "Access admin-panel without entering credentials"
162 },
163
164 { k: 'proxies', comp: NumberField, xs: 12, sm: 4, md: 4, max: 9, label: "Number of incoming HTTP proxies", placeholder: "none",
165 error: proxyWarning(values, status),
166 helperText: "Wrong number will prevent detection of users' IP"
167 },
168 { k: CFG.outbound_proxy, xs: 12, sm: 5, md: 4, placeholder: "none", helperText: "URL form",
169 getError: x => try_(() => x && new URL(x) && '', () => "Invalid URL") },
170 { k: 'allowed_referer', comp: AllowedReferer, sm: 3, md: 4, placeholder: "any", label: "Links from other websites",
171 helperText: "In case another website is linking your files" },
172
173 { k: 'block', label: false, comp: ArrayField, xs: 12, prepend: true, sm: true, autoRowHeight: true,
174 form: { sx: { maxWidth: '40em' } },
175 fields: [
176 { k: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: true, $width: 2, comp: NetmaskField,
177 $column: { mergeRender: { comment: {}, expire: {} } },
178 helperText: "Be careful to not kick yourself out, by blocking also your IP",
179 },
180 { k: 'expire', $type: 'dateTime', minDate: new Date(), sm: 6, $hideUnder: 'sm',
181 helperText: "Leave empty for no expiration" },
182 {
183 k: 'disabled',
184 $type: 'boolean',
185 label: "Enabled",
186 helperText: "In case you want to not block without deleting the rule",
187 toField: (x: any) => !x,
188 fromField: (x: any) => x ? undefined : true,
189 sm: 6,
190 $width: 80,
191 },
192 { k: 'comment', $hideUnder: 'sm' },
193 ],
194 },
195
196 h(Section, { title: "Front-end", subtitle: "Following options affect only the front-end" }),
197 { k: 'file_menu_on_link', comp: SelectField, label: "Access file menu", md: 4,
198 options: { "by clicking on file name": true, "by dedicated button": false }
199 },
200 { k: 'title', md: 8, helperText: "You can see this in the tab of your browser" },
201
202 { k: 'auto_play_seconds', comp: NumberField, xs: 6, sm: 3, min: 1, max: 10000, required: true,
203 label: "Auto-play seconds delay", helperText: md(`Default value for the [Show interface](${REPO_URL}discussions/270)`) },
204 { k: 'tile_size', comp: NumberField, xs: 6, sm: 3, max: MAX_TILE_SIZE, required: true,
205 label: "Default tiles size", helperText: wikiLink('Tiles', "To enable tiles-mode") },
206 { k: 'theme', comp: SelectField, xs: 6, sm: 3, options: THEME_OPTIONS },
207 { k: 'sort_by', comp: SelectField, xs: 6, sm: 3, options: SORT_BY_OPTIONS },
208
209 { k: 'invert_order', comp: BoolField, xs: 6, md: 3 },
210 { k: 'folders_first', comp: BoolField, xs: 6, md: 3 },
211 { k: 'sort_numerics', comp: BoolField, xs: 6, md: 3, label: "Sort numeric names" },
212 { k: 'title_with_path', comp: BoolField, xs: 6, md: 3 },
213 { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, xs: 12, sm: 6,
214 helperText: "The icon associated to your website" },
215 { k: CFG.show_uploader, label: "Show uploader to", comp: WhoField, xs: true },
216 { k: 'page_size', comp: NumberField, xs: true, min: 1, required: true, helperText: "Entries per page" },
217
218 h(Section, { title: "Uploads" }),
219 { k: 'dont_overwrite_uploading', comp: BoolField, md: 4, label: "Uploads don't overwrite",
220 helperText: "Files are automatically numbered (frontend only)" },
221 { k : CFG.split_uploads, comp: NumberField, unit: 'MB', md: 2, step: .1,
222 fromField: x => x * 1E6, toField: x => x ? x / 1E6 : null,
223 placeholder: "disabled", label: "Split uploads in chunks", helperText: "Overcome proxy limits (frontend only)" },
224 { k: 'delete_unfinished_uploads_after', comp: NumberField, md: 3, min : 0, unit: "seconds", required: true },
225 { k: 'min_available_mb', comp: NumberField, md: 3, min : 0, unit: "MBytes", placeholder: "None",
226 label: "Min. available disk space", helperText: "Reject uploads that don't comply" },
227
228 h(Section, { title: "Others" }),
229 { k: 'show_hidden_files', comp: BoolField, sm: 3 },
230 { k: 'descript_ion_encoding', sm: 3, label: "Encoding of file DESCRIPT.ION", comp: SelectField, disabled: !values.descript_ion,
231 options: ['utf8',720,775,819,850,852,862,869,874,808, ..._.range(1250,1257),10029,20866,21866] },
232 { k: CFG.comments_storage, comp: SelectField, xs: 12, sm: 6, options: {
233 "in file DESCRIPT.ION": '',
234 "in file attributes": 'attr',
235 "in file attributes + load DESCRIPT.ION": 'attr+ion',
236 } },
237
238 { k: 'keep_session_alive', comp: BoolField, sm: 6, md: 6, helperText: "Keeps you logged in while the page is left open and the computer is on" },
239 { k: 'session_duration', comp: NumberField, sm: 3, md: 3, min: 5, unit: "seconds", required: true },
240 { k: CFG.size_1024, label: "KB size", comp: SelectField, sm: 3, options: { 1000: false, 1024: true } },
241
242 { k: 'open_browser_at_start', comp: BoolField, label: "Open Admin-panel at start", xs: 12, sm: 6, md: 3,
243 helperText: "Browser is automatically launched with HFS"
244 },
245 { k: 'zip_calculate_size_for_seconds', comp: NumberField, xs: 12, sm: 6, md: 3, unit: "seconds", required: true,
246 label: "Calculate ZIP size for", helperText: "If time is not enough, the browser will not show download percentage" },
247 { k: 'mime', comp: ArrayField, label: "Custom MIME types", reorder: true, prepend: true, xs: 12, sm: 12, md: 6,
248 fields: [
249 { k: 'v', label: "Mime type", placeholder: "auto", $width: 2, helperText: "Leave empty to get automatic value" },
250 { k: 'k', label: "File mask", helperText: h(WildcardsSupported), $width: 1, $column: {
251 renderCell: ({ value, id }: any) => h('code', {},
252 value,
253 value === '*' && id < _.size(values.mime) - 1
254 && iconTooltip(Warning, md("Mime with `*` should be the last, because first matching row applies"), {
255 color: 'warning.main', ml: 1
256 }))
257 } },
258 ],
259 toField: x => Object.entries(x || {}).map(([k,v]) => ({ k, v })),
260 fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v || 'auto'])),
261 helperText: "Most MIME types are detected automatically",
262 },
263
264 { k: CFG.force_webdav_login, comp: WebdavAgentAuthField, sm: true, label: "WebDAV force login",
265 fallbackRE: 'Microsoft-WebDAV', // ms-webdav won't send credentials even with the initial_auth – it must be forced, so we offer it as preset regex if you don't like the *always* value
266 helperText: ["Force login for clients that mishandle mixed anonymous/protected access. ", wikiLink('webdav', "Why?") ],
267 },
268 values[CFG.force_webdav_login] !== true && { k: CFG.webdav_initial_auth, comp: WebdavAgentAuthField, sm: 6, label: "WebDAV initial auth",
269 helperText: "Force login only once. Used only when previous option does not match",
270 },
271
272 { k: 'server_code', comp: TextEditorField, lang: 'js', xs: 12,
273 helperText: md(`This code works similarly to [a plugin](${REPO_URL}blob/main/dev-plugins.md) (with some limitations)`)
274 },
275
276 ]
277 })
278
279 async function save() {
280 if (_.isEmpty(changes))
281 return toast("Nothing to save")
282 const loc = window.location
283 const keys = ['port','https_port']
284 if (keys.every(k => changes[k] !== undefined))
285 return alertDialog("You cannot change both http and https port at once. Please, do one, save, and then do the other.", 'warning')
286 const working = [status?.http?.listening, status?.https?.listening]
287 const onHttps = location.protocol === 'https:'
288 if (onHttps) {
289 keys.reverse()
290 working.reverse()
291 }
292 const newPort = changes[keys[0]]
293 const otherPort = values[keys[1]]
294 const otherIsReliable = otherPort > 0 && working[1]
295 const otherProtocol = onHttps ? 'http' : 'https'
296 if (newPort < 0 && !otherIsReliable)
297 return alertDialog("You cannot switch off this port unless you have a working fixed port for " + otherProtocol, 'warning')
298 if (newPort === 0 && !otherIsReliable)
299 return alertDialog("You cannot randomize this port unless you have a working fixed port for " + otherProtocol, 'warning')
300 const goingNewPort = newPort > 0 && newPort != loc.port // == loc.port can happen when listening on a temporary port, and the user just set the same port as new config
301 if (goingNewPort && !await confirmDialog("You are changing the port and you may be disconnected"))
302 return
303 const certChange = 'cert' in changes || 'private_key' in changes
304 if (onHttps && certChange && !await confirmDialog("You may disrupt https service, kicking you out"))
305 return
306 await apiCall('set_config', { values: changes })
307 if ('split_uploads' in changes)
308 await alertDialog("Users need to reload for the \"split uploads\" option to take effect", 'warning')
309 const ip = ipForUrl(loc.hostname)
310 const path = loc.pathname + loc.hash
311 const redirect = newPort <= 0 ? `${onHttps ? 'http:' : 'https:'}//${ip}:${otherPort}${path}` // jump protocol also in case of random port, because people must know their port while using GUI
312 : goingNewPort ? `${loc.protocol}//${ip}:${newPort || values[keys[0]]}${path}`
313 : await with_(`https://${ip}:${loc.port}${path}`, httpsUrl => // could we be kicked out because of force_https?
314 !onHttps && (changes.force_https ?? data.force_https) && fetch(httpsUrl).then(() => httpsUrl, () => 0)) // only happens if https is working
315 if (redirect) {
316 await alertDialog("You are being redirected but in some cases this may fail. Hold on tight!", 'warning')
317 return window.location.href = redirect
318 }
319 const portChange = 'port' in changes || 'https_port' in changes
320 setTimeout(reloadStatus, portChange || certChange ? 1000 : 0) // give some time to apply news
321 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
322 recalculateChanges()
323 execDoneMessage(false, saveBtnRef.current)
324 }
325 }
326
327 function Section({ title, subtitle }: { title: string, subtitle?: string }) {
328 return h(Divider, { role: 'heading', sx: { fontSize: 'larger', fontWeight: 'bold' } }, title,
329 h(Box, { sx: { fontSize: 'small', fontWeight: 'normal' } }, subtitle))
330 }
331
332 function recalculateChanges() {
333 const o: Dict = {}
334 if (state.config)
335 for (const [k, v] of Object.entries(state.config))
336 if (JSON.stringify(v) !== JSON.stringify(loaded?.[k]))
337 o[k] = v
338 pageState.changes = o
339 }
340
341 export function isCertError(error: any) {
342 return /certificate/.test(error)
343 }
344
345 export function isKeyError(error: any) {
346 return /private key/.test(error)
347 }
348
349 function PortField({ label, value, onChange, setApi, status, suggestedPort=1, error, helperText }: FieldProps<number | null>) {
350 const lastCustom = useRef(suggestedPort)
351 if (value! > 0)
352 lastCustom.current = value!
353 const selectValue = Number(value! > 0 ? lastCustom.current : value) || 0
354 let errMsg = status?.error
355 if (errMsg)
356 if (isCertError(errMsg) || isKeyError(errMsg))
357 errMsg = undefined // never mind, we'll show this error elsewhere
358 else
359 error = true
360 return h(Box, {},
361 h(Box, { sx: { display: 'flex' } },
362 h(SelectField as Field<number>, {
363 sx: { flexGrow: 1 },
364 label,
365 error,
366 value: selectValue,
367 options: [
368 { label: "off", value: -1 },
369 { label: "random", value: 0 },
370 { label: "choose", value: lastCustom.current },
371 ],
372 onChange,
373 }),
374 value! > 0 && h(NumberField, {
375 label: "Number",
376 fullWidth: false,
377 value,
378 onChange,
379 setApi,
380 error,
381 min: 1,
382 max: 65535,
383 helperText,
384 sx: { minWidth: '5.5em' }
385 }),
386 ),
387 status && h(FormHelperText, { error },
388 status === true ? '...'
389 : errMsg ?? (status?.listening && "Correctly working on port " + status.port) )
390 )
391 }
392
393 function AllowedReferer({ label, value, onChange, error }: FieldProps<string>) {
394 const yesNo = !value || value==='-'
395 const example = 'example.com'
396 return h(Box, { sx: { display: 'flex' } },
397 h(SelectField as Field<string>, {
398 label,
399 value: yesNo ? value : example,
400 options: { "allow all": '', "forbid all": '-', "allow some": example, },
401 onChange,
402 error,
403 sx: yesNo ? undefined : { maxWidth: '11em' },
404 }),
405 !yesNo && h(StringField, {
406 label: "Domain to allow",
407 value,
408 placeholder: 'example.com',
409 onChange,
410 error,
411 helperText: h(WildcardsSupported)
412 })
413 )
414 }
415
416 function WebdavAgentAuthField({ label, value, onChange, error, helperText, fallbackRE='.*' }: FieldProps<boolean | string>) {
417 const [lastRegex, setLastRegex] = useState('')
418 const isRE = typeof value === 'string'
419 useEffect(() => setLastRegex(isRE ? value : fallbackRE), [value])
420 const helperId = useId()
421 return h(Box, {},
422 h(Box, { sx: { display: 'flex' } },
423 h(SelectField as Field<boolean | string>, {
424 label, value, onChange, error,
425 'aria-describedby': helperId,
426 options: { "Off": false, "Always": true, "RegEx": lastRegex },
427 sx: isRE ? { maxWidth: '9em' } : undefined,
428 }),
429 isRE && h(StringField, { label: "User-Agent regex", value, onChange, error }),
430 ),
431 h(FormHelperText, { id: helperId }, helperText),
432 )
433 }
434
435 export async function suggestMakingCert() {
436 return new Promise(resolve => {
437 const { close } = newDialog({
438 icon: CardMembership,
439 title: "Get a certificate",
440 onClose: resolve,
441 Content: () => h(Box, { sx: { p: 1, lineHeight: 1.5 } },
442 h(Box, {}, "HTTPS needs a certificate to work."),
443 h(Box, {}, "We suggest you to ", h(InLink, { to: '/internet' }, "get a free but proper certificate"), '.'),
444 h(Box, {}, "If you don't have a domain ", h(LinkBtn, { onClick: makeCertAndSave }, "make a self-signed certificate"),
445 " but that ", wikiLink('HTTPS#certificate', " won't be perfect"), '.' ),
446 )
447 })
448
449 async function makeCertAndSave() {
450 if (!window.crypto.subtle)
451 return alertDialog("Retry this procedure on localhost", 'warning')
452 const saved = await apiCall('make_self_signed_cert', { fileName: 'self' })
453 if (loaded) // when undefined we are not in this page
454 Object.assign(loaded, saved)
455 setTimeout(exposedReloadStatus!, 1000) // give some time for backend to apply
456 setTimeout(exposedReloadStatus!, 2000) // try again in case it's very slow
457 Object.assign(state.config, saved)
458 await alertDialog("Certificate saved", 'success')
459 close()
460 }
461 })
462 }