| 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 | } |