diff --git a/.gitignore b/.gitignore index f3acf2ae..8ea1a1b6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ self.* config.yaml* custom.html accounts.yaml -.DS_Store \ No newline at end of file +.DS_Store +storage +*.kv \ No newline at end of file diff --git a/README.md b/README.md index 2f6e0e57..1bbe7efe 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ ## Introduction -HFS is the best way via web to access or share files from your disk. +Access via web your files directly from your disk. - You be the server, share files **fresh from your disk**, with **unlimited** space and bandwidth. -- It's all very **fast**. Try download zipping 100GB, it starts immediately! -- **Easy to use**. HFS tries to detect problems and suggest solutions. +- **Fast!** Try zipping 100GB, download starts immediately! +- **Intelligent**. HFS tries to detect problems and suggest solutions. - Share **even a single file** with our *virtual file system*, even with a different name, all without touching the real file. Present things the way you want! - **Watch** all activities in real-time. - **Control bandwidth**, decide how much to give. @@ -34,7 +34,7 @@ This is a full rewrite of [the Delphi version](https://github.com/rejetto/hfs2). - accounts - resumable downloads & uploads - download folders as zip archive -- remote delete +- delete, move and rename files - simple website serving - plug-ins - real-time monitoring of connections @@ -43,10 +43,13 @@ This is a full rewrite of [the Delphi version](https://github.com/rejetto/hfs2). - geographic firewall - admin web interface - multi-language front-end -- virtual hosting (plug-in) +- virtual hosting - anti-brute-force (plug-in) - [reverse-proxy support](https://github.com/rejetto/hfs/wiki/Reverse-proxy) - comments in file descript.ion +- integrated media player +- [customizable with html, css and javascript](https://github.com/rejetto/hfs/wiki/Customization) +- dynamic-dns updater ## Installation @@ -132,9 +135,7 @@ Configuration is stored in the file `config.yaml`, exception made for custom HTM These files are kept in the Current Working Directory (cwd), which is by default the same folder of `hfs.exe` if you are using this kind of distribution on Windows, or `USER_FOLDER/.hfs` on other systems. -You can decide a different cwd passing `--cwd SOME_FOLDER` parameter at command line. - -You can decide also a different file for config by passing `--config SOME_FILE`, or inside an *env* called `HFS_CONFIG`. +You can decide a different folder passing `--cwd SOME_FOLDER` parameter at command line. Any relative path provided is relative to the *cwd*. [Check details about config file format](config.md). @@ -149,7 +150,9 @@ In the Languages section of the Admin-panel you can install additional language If your language is missing, please consider [translating yourself](https://github.com/rejetto/hfs/wiki/Translation). -## Why you should upgrade from HFS 2.x to 3 +## Why you should upgrade from HFS 2.x + +HFS 2.x is vulnerable to important attacks, and there is no known solution at the moment. As you can see from the list of features, we already have some goods that you cannot find in HFS 2. Other than that, you can also consider: @@ -159,24 +162,18 @@ Other than that, you can also consider: - faster search (up to 12x) - more flexible permissions -But you may still want to stay with HFS 2.x (so far) for the following reasons - -- smaller -- more tested -- classic window interface (can be easier for some people) - ## Security While this project focuses on ease of use, we care about security. - HTTPS support -- Passwords are not saved, and user password is safe even logging in without https thanks to [SRP](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol) +- Passwords are not saved, and not disclosed even without https thanks to [SRP](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol) - Automated tests ran on every release, including libraries audit - No default admin password Some actions you can take for improved security: - use https, better if using a proper certificate, even free with [Letsencrypt](https://letsencrypt.org/). - have a domain (ddns is ok too), configure it in "Internet" page, and enable "Accept requests only using domain" -- install rejetto/antidos plugin +- install "antidos" plugin - ensure "antibrute" plugin is running - disable "unprotected admin on localhost" diff --git a/admin/package.json b/admin/package.json index 98a11375..167d5746 100644 --- a/admin/package.json +++ b/admin/package.json @@ -12,17 +12,17 @@ "@hfs/mui-grid-form": "*", "@hfs/shared": "*", "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.5", - "@mui/lab": "^5.0.0-alpha.161", - "@mui/material": "^5.15.10", - "@mui/x-data-grid": "^6.19.1", - "@mui/x-date-pickers": "^6.19.0", + "@mui/icons-material": "^5.15.15", + "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.15", + "@mui/x-data-grid": "^6.19.11", + "@mui/x-date-pickers": "^6.19.9", "@mui/x-tree-view": "^6.17.0", "@gregoranders/csv": "^0.0.13", "dayjs": "^1.11.10", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.21.3", + "react-router-dom": "^6.23.1", "react-simple-code-editor": "^0.13.1", "react-window": "^1.8.10", "tssrp6a": "^3.0.0", @@ -37,8 +37,7 @@ "@types/react-dom": "^18.2.18", "@types/react-window": "^1.8.8", "@types/react-virtualized-auto-sizer": "^1.0.4", - "vite": "^5.0.12", - "vite-plugin-babel-import": "github:rejetto/vite-plugin-babel-import" + "vite": "^5.0.12" }, "eslintConfig": { "extends": [ diff --git a/admin/src/AccountForm.ts b/admin/src/AccountForm.ts index 165d25a4..c2f64009 100644 --- a/admin/src/AccountForm.ts +++ b/admin/src/AccountForm.ts @@ -4,8 +4,8 @@ import { createElement as h, ReactNode, useEffect, useRef, useState } from 'reac import { BoolField, Form, MultiSelectField, NumberField } from '@hfs/mui-grid-form' import { Alert } from '@mui/material' import { apiCall } from './api' -import { alertDialog, toast, useDialogBarColors } from './dialog' -import { isEqualLax, useIsMobile, wantArray } from './misc' +import { alertDialog, useDialogBarColors } from './dialog' +import { formatTimestamp, isEqualLax, prefix, useIsMobile, wantArray } from './misc' import { IconBtn, modifiedProps } from './mui' import { Account } from './AccountsPage' import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a' @@ -31,7 +31,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: const ref = useRef() const expired = Boolean(values.expire) return h(Form, { - formRef: ref, + formRef: ref, values, set(v, k) { setValues(values => ({ ...values, [k]: v })) @@ -42,15 +42,14 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: !add && h(IconBtn, { icon: Delete, title: "Delete", - confirm: "Delete?", + confirm: `Delete ${account.username}?`, ...username === account.username && { disabled: true, title: "Cannot delete current account" }, onClick: () => apiCall('del_account', { username: account.username }).then(reload) }), h(IconBtn, { icon: AutoDelete, - title: "Invalidate past sessions", + title: `Invalidate past sessions ${prefix('(', formatTimestamp(account.invalidated || 0), ')')}`, doneMessage: true, - disabled: account.invalidated, onClick: () => apiCall('invalidate_sessions', { username: account.username }).then(reload) }), ...wantArray(addToBar), @@ -101,7 +100,6 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: throw e } done(got?.username) - toast("Account created", 'success') return } const got = await apiCall('set_account', { @@ -112,7 +110,6 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: await apiNewPassword(values.username, password) if (account.username === username) state.username = values.username - setTimeout(() => toast("Account modified", 'success'), 1) // workaround: showing a dialog at this point is causing a crash if we are in a dialog done(got?.username) // username may have been changed, so we pass it back } } diff --git a/admin/src/AccountsPage.ts b/admin/src/AccountsPage.ts index 25f9cde2..153e5ec4 100644 --- a/admin/src/AccountsPage.ts +++ b/admin/src/AccountsPage.ts @@ -10,7 +10,7 @@ import { TreeItem, TreeView } from '@mui/x-tree-view' import MenuButton from './MenuButton' import AccountForm from './AccountForm' import _ from 'lodash' -import { alertDialog, confirmDialog } from './dialog' +import { alertDialog, confirmDialog, toast } from './dialog' import { useSnapState } from './state' import { importAccountsCsv } from './importAccountsCsv' import { AccountAdminSend } from '../../src/api.accounts' @@ -42,7 +42,7 @@ export default function AccountsPage() { h(ListItem, { key: username }, h(ListItemText, {}, username)))) ) - : with_(selectedAccount || { username: '', hasPassword: sel === 'new-user', adminActualAccess: false, invalidated: true }, a => + : with_(selectedAccount || { username: '', hasPassword: sel === 'new-user', adminActualAccess: false, invalidated: undefined }, a => h(AccountForm, { account: a, groups: list.filter(x => !x.hasPassword).map( x => x.username ), @@ -54,8 +54,9 @@ export default function AccountsPage() { ], reload, done(username) { - setSel([username]) + setSel(isSideBreakpoint ? [username] : []) reload() + toast("Account saved", 'success') } })) useEffect(() => { @@ -68,7 +69,7 @@ export default function AccountsPage() { Content: () => sideContent, onClose: selectNone, }) - return close + return () => void close() }, [isSideBreakpoint, sel, selectedAccount]) return element || h(Grid, { container: true, maxWidth: '80em' }, diff --git a/admin/src/App.ts b/admin/src/App.ts index b31231c3..247530e3 100644 --- a/admin/src/App.ts +++ b/admin/src/App.ts @@ -34,7 +34,8 @@ function App() { function ApplyTheme(props:any) { return h(Box, { - sx: { bgcolor: 'background.default', color: 'text.primary', flex: 1, }, + sx: { bgcolor: 'background.default', color: 'text.primary', flex: 1, + maxWidth: '100%' /*avoid horizontal overflow (eg: customHtml with long line) */ }, ...props }) } diff --git a/admin/src/ArrayField.ts b/admin/src/ArrayField.ts index 7c16f1e6..3e0415f8 100644 --- a/admin/src/ArrayField.ts +++ b/admin/src/ArrayField.ts @@ -9,10 +9,10 @@ import { BoolField, FieldDescriptor, FieldProps, labelFromKey } from '@hfs/mui-g import { Box, FormHelperText, FormLabel } from '@mui/material' import { DateTimeField } from './DateTimeField' import _ from 'lodash' -import { IconBtn } from './mui' +import { Center, IconBtn } from './mui' type ArrayFieldProps = FieldProps & { fields: FieldDescriptor[], height?: number, reorder?: boolean, prepend?: boolean } -export function ArrayField({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, ...rest }: ArrayFieldProps) { +export function ArrayField({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, ...rest }: ArrayFieldProps) { const rows = useMemo(() => (value||[]).map((x,$idx) => setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })), [JSON.stringify(value)]) //eslint-disable-line @@ -30,6 +30,9 @@ export function ArrayField({ label, helperText, fields, value, sx: { '.MuiDataGrid-virtualScroller': { minHeight: '3em' } }, hideFooterSelectedRowCount: true, hideFooter: true, + slots: { + noRowsOverlay: () => h(Center, {}, noRows || "No entries"), + }, slotProps: { pagination: { showFirstButton: true, @@ -64,7 +67,7 @@ export function ArrayField({ label, helperText, fields, value, title, size: 'small', onClick: ev => - formDialog({ form, title }).then(x => { + formDialog({ form, title, values: valuesForAdd }).then(x => { if (!x) return const newValue = value?.slice() || [] if (prepend) newValue.unshift(x) @@ -136,7 +139,7 @@ const byType: Dict<{ field?: Partial, column?: Partial value && new Date(value), renderCell: ({ value }) => value && h(Box, {}, value.toLocaleDateString(), h('br'), value.toLocaleTimeString()) } diff --git a/admin/src/ConfigForm.ts b/admin/src/ConfigForm.ts index 54ac1a9c..47351b43 100644 --- a/admin/src/ConfigForm.ts +++ b/admin/src/ConfigForm.ts @@ -4,25 +4,30 @@ import { createElement as h, useEffect, useState, Dispatch } from 'react' import _ from 'lodash' import { IconBtn, modifiedProps } from './mui' import { RestartAlt } from '@mui/icons-material' -import { Callback } from '../../src/cross' +import { Callback, onlyTruthy } from '../../src/cross' type FormRest = Omit, 'values' | 'set' | 'save'> & Partial, 'save'>> export function ConfigForm({ keys, form, saveOnChange, onSave, ...rest }: Partial> & { - keys: (keyof T)[], + keys?: (keyof T)[], form: FormRest | ((values: T, optional: { setValues: Dispatch }) => FormRest), onSave?: Callback, saveOnChange?: boolean }) { - const config = useApiEx('get_config', { only: keys }) + const [keys_, setKeys_] = useState(keys) + const config = useApiEx(keys_ && 'get_config', { only: keys_ }) const [values, setValues] = useState(config.data) useEffect(() => setValues((v: any) => config.data || v), [config.data]) const modified = values && !_.isEqual(values, config.data) useEffect(() => { if (modified && saveOnChange) save() }, [modified]) + const formProps = _.isFunction(form) ? form(values, { setValues }) : form + useEffect(() => { + if (!keys) // autodetect keys + setKeys_(onlyTruthy(formProps.fields.map(x => (x as any)?.k))) + }, [keys]) if (!values) return config.element - const formProps = _.isFunction(form) ? form(values, { setValues }) : form return h(Form, { values, set(v, k) { @@ -32,7 +37,7 @@ export function ConfigForm({ keys, form, saveOnChange, onSave, ...rest }: onClick: save, ...modifiedProps(modified), }, - ...Array.isArray(formProps) ? { fields: formProps } : formProps, + ...formProps, ...rest, barSx: { gap: 1, ...rest.barSx }, addToBar: [ diff --git a/admin/src/DataTable.ts b/admin/src/DataTable.ts index b88381cb..a944725d 100644 --- a/admin/src/DataTable.ts +++ b/admin/src/DataTable.ts @@ -6,15 +6,19 @@ import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useRef, us import { newDialog, onlyTruthy } from '@hfs/shared' import _ from 'lodash' import { Center, Flex, useBreakpoint } from './mui' +import { SxProps } from '@mui/system' const ACTIONS = 'Actions' +export type DataTableColumn = GridColDef & { + hidden?: boolean + hideUnder?: Breakpoint | number + dialogHidden?: boolean + sx?: SxProps + mergeRender?: { [other: string]: false | { override?: Partial> } & BoxProps } +} interface DataTableProps extends Omit, 'columns'> { - columns: Array & { - hidden?: boolean - hideUnder?: Breakpoint | number - mergeRender?: { other: string, override?: Partial> } & BoxProps - }> + columns: Array> actions?: ({ row, id }: any) => ReactNode[] actionsProps?: Partial> & { hideUnder?: Breakpoint | number } initializing?: boolean @@ -30,11 +34,11 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini const apiRef = useGridApiRef() const [actionsLength, setActionsLength] = useState(0) const manipulatedColumns = useMemo(() => { - const { localeText } = enUS.components.MuiDataGrid.defaultProps + const { localeText } = enUS.components.MuiDataGrid.defaultProps as any const ret = columns.map(col => { - const { type } = col + const { type, sx } = col if (!type || type === 'string') // offer negated version of default string operators - col.filterOperators ??= getGridStringOperators().map(op => op.value.includes('Empty') ? op : [ // isEmpty already has isNotEmpty + col.filterOperators ??= getGridStringOperators().flatMap(op => op.value.includes('Empty') ? op : [ // isEmpty already has isNotEmpty op, { ...op, @@ -47,27 +51,32 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini const res = op.getApplyFilterFnV7?.(item, col) return res ? _.negate(res) : null } }, - label: "(not) " + ((localeText as any)['filterOperator' + _.upperFirst(op.value)] || op.value) + label: "(not) " + (localeText['filterOperator' + _.upperFirst(op.value)] || op.value) } satisfies typeof op - ]).flat() - const { mergeRender } = col - if (!mergeRender) + ]) + if (!col.mergeRender) return col - const { other, override, ...props } = mergeRender return { ...col, originalRenderCell: col.renderCell || true, renderCell(params: any) { const { columns } = params.api.store.getSnapshot() - const showOther = columns.columnVisibilityModel[other] === false - return h(Box, {}, col.renderCell ? col.renderCell(params) : params.formattedValue, - showOther && h(Box, { ...compact && { lineHeight: '1em', fontSize: 'smaller' }, ...props }, - renderCell({ ...columns.lookup[other], ...override }, params.row) ) ) + return h(Box, { maxHeight: '100%', sx: { textWrap: 'wrap', ...sx } }, // wrap if necessary, but stay within the row + col.renderCell ? col.renderCell(params) : params.formattedValue, + h(Flex, { fontSize: 'smaller', flexWrap: 'wrap', mt: '2px' }, // wrap, normally causing overflow/hiding, if it doesn't fit + ...onlyTruthy(_.map(col.mergeRender, (props, other) => { + if (!props || columns.columnVisibilityModel[other] !== false) return null + const { override, ...rest } = props + const rendered = renderCell({ ...columns.lookup[other], ...override }, params.row) + return rendered && h(Box, { ...rest, ...compact && { lineHeight: '1em' } }, rendered) + })) + ) + ) } } }) if (actions) - ret.push({ + ret.unshift({ field: ACTIONS, width: 40 * actionsLength, headerName: '', @@ -118,8 +127,9 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini density: compact ? 'compact' : 'standard', columns: manipulatedColumns, apiRef, + ...rest, slots: { - ...(noRows || initializing) && { noRowsOverlay: () => initializing ? null : h(Center, {}, noRows) }, + noRowsOverlay: () => initializing ? null : h(Center, {}, noRows || "No entries"), footer: CustomFooter, }, slotProps: { @@ -134,7 +144,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini if (window.getSelection()?.type === 'Range') return // not a click but a drag const n = apiRef.current.getVisibleColumns().length const showCols = manipulatedColumns.filter(x => - x.renderCell || x.field === ACTIONS || row[x.field] !== undefined) + !x.dialogHidden && (x.renderCell || x.field === ACTIONS || row[x.field] !== undefined)) if (showCols.length <= n) return newDialog({ title: "Details", @@ -162,7 +172,6 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini } }) }, - ...rest, onColumnVisibilityModelChange: x => setVis(x), columnVisibilityModel: { ...Object.fromEntries(hideCols.map(x => [x, false])), diff --git a/admin/src/FileForm.ts b/admin/src/FileForm.ts index 1805e4f5..a8160ae3 100644 --- a/admin/src/FileForm.ts +++ b/admin/src/FileForm.ts @@ -9,7 +9,7 @@ import { apiCall, UseApi } from './api' import { basename, defaultPerms, formatBytes, formatTimestamp, isEqualLax, isWhoObject, newDialog, objSameKeys, onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, HTTP_MESSAGES, xlate, md, Callback, - useRequestRender + useRequestRender, splitAt } from './misc' import { Btn, IconBtn, LinkBtn, modifiedProps, useBreakpoint, wikiLink } from './mui' import { reloadVfs, VfsNode } from './VfsPage' @@ -63,14 +63,14 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: const barColors = useDialogBarColors() const { movingFile } = useSnapState() - const needSourceWarning = !hasSource && "Works only on folders with source! " + const needSourceWarning = !hasSource && h(Box, { color: 'warning.main', component: 'span' }, "Works only on folders with disk source! ") const show: Record = { can_read: !isLink, can_see: true, can_archive: !isLink, can_list: isDir, - can_upload: isDir && hasSource, - can_delete: isDir && hasSource, + can_upload: isDir, + can_delete: isDir, } return h(Form, { values, @@ -117,7 +117,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: h(IconBtn, { icon: Delete, title: "Delete", - confirm: "Delete?", + confirm: `Delete ${file.name}?`, disabled: isRoot, onClick: () => apiCall('del_vfs', { uris: [file.id] }).then(({ errors: [err] }) => { if (err) @@ -146,25 +146,33 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: : isDir && hasSource && h(Alert, { severity: 'info' }, `To set permissions on individual items in folder, add them by clicking Add button, and then "from disk"`), !isRoot && { k: 'name', required: true, xl: true, helperText: hasSource && "You can decide a name that's different from the one on your disk" }, isLink ? { k: 'url', label: "URL", lg: 12, required: true } - : { k: 'source', label: "Load content from disk", xl: true, comp: FileField, files: isUnknown || !isDir, folders: isUnknown || isDir, - placeholder: "no", - helperText: !values.source ? "This field is empty, and thus this element is a virtual-folder. You can set this field, pointing at any folder/file on disk." + : { k: 'source', label: "Disk source", xl: true, comp: FileField, files: isUnknown || !isDir, folders: isUnknown || isDir, + placeholder: "none", + helperText: !values.source ? "This field is currently empty, and thus this element is a virtual-folder, no upload or delete is possible here." : isDir ? "Content from this path on disk will be listed, but you can also add more" : undefined, }, - !isLink && { k: 'id', comp: LinkField, statusApi, xs: 12 }, + { k: 'id', comp: LinkField, statusApi, xs: 12 }, perm('can_read', "Who can see but not download will be asked to login"), - perm('can_see', "If you can't see, you may still download with a direct link"), - perm('can_archive', "Should this be included when user downloads as ZIP", { lg: isDir ? true : 12 }), - perm('can_list', "Permission to see content of folders", { contentText: "subfolders" }), + perm('can_see', ["Control what appears in the list.", wikiLink('Permissions', " More help.")]), + perm('can_archive', "Should this be included when user downloads as ZIP", { lg: isDir ? 6 : 12 }), perm('can_delete', [needSourceWarning, "Those who can delete can also rename and cut/move"]), + perm('can_list', "Permission to requests the list of a folder. The list will include only things you can see.", { contentText: "subfolders" }), perm('can_upload', needSourceWarning, { contentText: "subfolders" }), + isLink && { + k: 'target', + comp: BoolField, + sm: true, + label: "Open in new browser", + fromField: x => x ? '_blank' : null, + toField: x => x > '', + }, showSize && { k: 'size', comp: DisplayField, sm: 6, lg: 4, toField: formatBytes }, showTimestamps && { k: 'ctime', comp: DisplayField, sm: 6, lg: showSize && 4, label: "Created", toField: formatTimestamp }, showTimestamps && { k: 'mtime', comp: DisplayField, sm: 6, lg: showSize && 4, label: "Modified", toField: formatTimestamp }, showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", xl: showWebsite ? 4 : 12, helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip") }, showWebsite && { k: 'default', comp: BoolField, xl: true, - label: "Serve as website if index.html is found" + (inheritedDefault && values.default == null ? ' (inherited)' : ''), + label: "Serve as web-page if index.html is found" + (inheritedDefault && values.default == null ? ' (inherited)' : ''), value: values.default ?? inheritedDefault, toField: Boolean, fromField: (v:boolean) => v && !inheritedDefault ? 'index.html' : v ? null : false, helperText: md("...instead of showing list of files") @@ -200,7 +208,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: } function perm2word(perm: string) { - return xlate(perm.split('_')[1], { read: 'download', archive: 'zip' }) + return xlate(perm.split('_')[1], { read: 'download', archive: 'zip', list: 'access list' }) } interface WhoFieldProps extends FieldProps { @@ -293,7 +301,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) { const { reload, error } = statusApi // workaround to get fresh data and be rerendered even when mounted inside imperative dialog const requestRender = useRequestRender() - useEffect(() => statusApi.sub(requestRender), []) + useEffect(() => { statusApi.sub(requestRender) }, []) const data = statusApi.getData() const urls: string[] = data?.urls.https || data?.urls.http @@ -316,7 +324,7 @@ function LinkField({ value, statusApi }: LinkFieldProps) { !urls ? 'error' : // check data is ok h(DisplayField, { label: "Link", - value: link || `outside of configured base address (${baseHost})`, + value: link || `outside of configured main address (${baseHost})`, error, InputProps: link ? { inputComponent: RenderLink } : undefined, end: h(Box, {}, @@ -365,17 +373,20 @@ function LinkField({ value, statusApi }: LinkFieldProps) { export async function changeBaseUrl() { return new Promise(async resolve => { const res = await apiCall('get_status') - const { base_url } = await apiCall('get_config', { only: ['base_url'] }) + const { base_url, roots } = await apiCall('get_config', { only: ['base_url', 'roots'] }) const urls: string[] = res.urls.https || res.urls.http + const domainsFromRoots = Object.keys(roots).map(x => x.split('|')).flat().filter(x => !/[*?]/.test(x)) + const proto = splitAt('//', urls[0])[0] + '//' + urls.push(...domainsFromRoots.map(x => proto + x)) const { close } = newDialog({ - title: "Base address", + title: "Main address", Content() { const [v, setV] = useState(base_url || '') const proto = new URL(v || urls[0]).protocol + '//' const host = urls.includes(v) ? '' : v.slice(proto.length) const check = h(Check, { sx: { ml: 2 } }) return h(Box, { display: 'flex', flexDirection: 'column' }, - h(Box, { mb: 2 }, "Choose a base address for your links"), + h(Box, { mb: 2 }, "Choose a main address for your links"), h(MenuList, {}, h(MenuItem, { selected: !v, diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index 4b901bc8..7fe8568c 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -14,6 +14,9 @@ import { VfsNode } from './VfsPage' import { Account } from './AccountsPage' import _ from 'lodash' import { subscribeKey } from 'valtio/utils' +import { SwitchThemeBtn } from './theme' +import { BoolField } from '@hfs/mui-grid-form' +import { ConfigForm } from './ConfigForm' interface ServerStatus { listening: boolean, port: number, error?: string, busy?: string } @@ -32,7 +35,7 @@ export default function HomePage() { const { data: status, reload: reloadStatus, element: statusEl } = useApiEx('get_status') const { data: vfs } = useApiEx<{ root?: VfsNode }>('get_vfs') const { data: account } = useApiEx(username && 'get_account') - const cfg = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'update_to_beta'] }) + const cfg = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies'] }) const { list: plugins } = useApiList('get_plugins') const [checkPlugins, setCheckPlugins] = useState(false) const { list: pluginUpdates} = useApiList(checkPlugins && 'get_plugin_updates') @@ -94,23 +97,31 @@ export default function HomePage() { icon: UpdateIcon, onClick: () => update() }, "Update from local file") - : !updates ? h(Btn, { - variant: 'outlined', - icon: UpdateIcon, - onClick() { - setCheckPlugins(true) - return apiCall('check_update').then(x => setUpdates(x.options), alertDialog) - }, - async onContextMenu(ev) { - ev.preventDefault() - if (!status.updatePossible) - return alertDialog("Automatic update is only for binary versions", 'warning') - const res = await promptDialog("Enter a link to the zip to install") - if (res) - await update(res) - }, - title: status.updatePossible && "Right-click if you want to install a zip", - }, "Check for updates") + : !updates ? h(Flex, { flexWrap: 'wrap' }, + h(Btn, { + variant: 'outlined', + icon: UpdateIcon, + onClick() { + setCheckPlugins(true) + return apiCall('check_update').then(x => setUpdates(x.options), alertDialog) + }, + async onContextMenu(ev) { + ev.preventDefault() + if (!status.updatePossible) + return alertDialog("Automatic update is only for binary versions", 'warning') + const res = await promptDialog("Enter a link to the zip to install") + if (res) + await update(res) + }, + title: status.updatePossible && "Right-click if you want to install a zip", + }, "Check for updates"), + h(ConfigForm, { + saveOnChange: true, + form: { fields: [ + { k: 'update_to_beta', comp: BoolField, label: "Include beta versions" }, + ] } + }) + ) : with_(_.find(updates, 'isNewer'), newer => !updates.length || !status.updatePossible && !newer ? entry('', "No update available") : newer && !status.updatePossible ? entry('success', `Version ${newer.name} available`) @@ -126,21 +137,25 @@ export default function HomePage() { h(Box, { mt: 1 }, renderChangelog(x.body)) )), )), - )) + )), + h(SwitchThemeBtn, { variant: 'outlined' }), ) } function renderChangelog(s: string) { return md(s, { - onText: s => replaceStringToReact(s, /(?<=^|\W)#(\d+)\b/g, m => // link issues - h(Link, { href: REPO_URL + 'issues/' + m[1], target: '_blank' }, h(OpenInNew) )) + onText: s => replaceStringToReact(s, /(?<=^|\W)#(\d+)\b|(https:.*\S+)/g, m => // link issues and urls + m[1] ? h(Link, { href: REPO_URL + 'issues/' + m[1], target: '_blank' }, h(OpenInNew)) + : h(Link, { href: m[2], target: '_blank' }, m[2] ) + ) }) } async function update(tag?: string) { if (!await confirmDialog("Installation may take less than a minute, depending on the speed of your server")) return toast('Downloading') - const err = await apiCall('update', { tag }).then(() => 0, e => e) + const err = await apiCall('update', { tag }, { timeout: 600 /*download can be lengthy*/ }) + .then(() => 0, e => e) if (err) return alertDialog(err) toast("Restarting") diff --git a/admin/src/InstalledPlugins.ts b/admin/src/InstalledPlugins.ts index e8c89676..45cca986 100644 --- a/admin/src/InstalledPlugins.ts +++ b/admin/src/InstalledPlugins.ts @@ -1,18 +1,20 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { apiCall, useApiList } from './api' +import { apiCall, useApiEx, useApiList } from './api' import { createElement as h, Fragment, useEffect } from 'react' import { Box, Link } from '@mui/material' -import { DataTable } from './DataTable' -import { Delete, Error as ErrorIcon, PlayCircle, Settings, StopCircle, Upgrade } from '@mui/icons-material' -import { HTTP_FAILED_DEPENDENCY, prefix, with_, xlate } from './misc' -import { alertDialog, formDialog, toast } from './dialog' +import { DataTable, DataTableColumn } from './DataTable' +import { Delete, Error as ErrorIcon, FormatPaint as ThemeIcon, PlayCircle, Settings, StopCircle, Upgrade } from '@mui/icons-material' +import { HTTP_FAILED_DEPENDENCY, newObj, prefix, with_, xlate } from './misc' +import { alertDialog, confirmDialog, formDialog, toast } from './dialog' import _ from 'lodash' -import { BoolField, Field, MultiSelectField, NumberField, SelectField, StringField } from '@hfs/mui-grid-form' +import { Account } from './AccountsPage' +import { BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField +} from '@hfs/mui-grid-form' import { ArrayField } from './ArrayField' import FileField from './FileField' import { PLUGIN_ERRORS } from './PluginsPage' -import { Btn, hTooltip, IconBtn } from './mui' +import { Btn, hTooltip, IconBtn, iconTooltip } from './mui' export default function InstalledPlugins({ updates }: { updates?: true }) { const { list, updateEntry, error, updateList, initializing } = useApiList(updates ? 'get_plugin_updates' : 'get_plugins') @@ -27,8 +29,6 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used initializing, disableColumnSelector: true, - disableColumnMenu: true, - hideFooter: true, noRows: updates && `No updates available. Only plugins available on "search online" are checked.`, columns: [ { @@ -37,15 +37,17 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { flex: .3, minWidth: 150, renderCell: renderName, - mergeRender: { other: 'description', fontSize: 'x-small' } + valueGetter({ row }) { return row.repo || row.id }, + mergeRender: { description: { fontSize: 'x-small' } } }, { field: 'version', width: 70, hideUnder: 'sm', }, + themeField, { - field: 'description', + ...descriptionField, flex: 1, hideUnder: 'sm', }, @@ -90,8 +92,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { || !row.config && "No options available for this plugin", progress: false, async onClick() { - const pl = await apiCall('get_plugin', { id }) - let lastSaved = pl.config + const { config: lastSaved } = await apiCall('get_plugin', { id }) const values = await formDialog({ title: `Options for ${id}`, form: values => ({ @@ -101,7 +102,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { barSx: { gap: 1 }, addToBar: [h(Btn, { variant: 'outlined', onClick: () => save(values) }, "Save")], }), - values: pl.config, + values: lastSaved, dialogProps: _.merge({ sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen) row.configDialog), }) @@ -119,9 +120,14 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { icon: Delete, title: "Uninstall", size, - confirm: "Remove?", async onClick() { - await apiCall('uninstall_plugin', { id }) + const res = await confirmDialog(`${id}: delete configuration too?`, { + trueText: "Yes", + falseText: "No", + after: ({ onClick }) => h(Btn, { variant: 'outlined', onClick(){ onClick(undefined) } }, "Abort") + }) + if (res === undefined) return + await apiCall('uninstall_plugin', { id, deleteConfig: res }) toast("Plugin uninstalled") } }), @@ -132,12 +138,11 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { export function renderName({ row, value }: any) { const { repo } = row return h(Fragment, {}, - errorIcon(row.badApi, true), - errorIcon(row.error), + errorIcon(row.error || row.badApi, !row.error), repo?.includes('//') ? h(Link, { href: repo, target: 'plugin' }, value) : !repo ? value : with_(repo?.split('/'), arr => h(Fragment, {}, - h(Link, { href: 'https://github.com/' + repo, target: 'plugin' }, arr[1]), + h(Link, { href: 'https://github.com/' + repo, target: 'plugin', onClick(ev) { ev.stopPropagation() } }, arr[1].replace(/hfs-/, '')), '\xa0by ', arr[0] )) ) @@ -153,8 +158,10 @@ function makeFields(config: any) { return o let { type, defaultValue, fields, frontend, ...rest } = o const comp = (type2comp as any)[type] as Field | undefined - if (comp === ArrayField) + if (comp === ArrayField) { + rest.valuesForAdd = newObj(fields, x => x.defaultValue) fields = makeFields(fields) + } if (defaultValue !== undefined && type === 'boolean') rest.placeholder = `Default value is ${JSON.stringify(defaultValue)}` return { k, comp, fields, ...rest } @@ -169,6 +176,7 @@ const type2comp = { multiselect: MultiSelectField, array: ArrayField, real_path: FileField, + username: UsernameField, } export async function startPlugin(id: string) { @@ -181,3 +189,29 @@ export async function startPlugin(id: string) { alertDialog(`Plugin ${id} didn't start, with error: ${String(e?.message || e)}`, 'error') } } + +function UsernameField({ value, onChange, multiple, ...rest }: FieldProps) { + const { data, element, loading } = useApiEx<{ list: Account[] }>('get_accounts') + return !loading && element || h((multiple ? MultiSelectField : SelectField) as Field, { + value, onChange, + options: data?.list.map(x => x.username), + helperText: "Only users, no groups here", + ...rest, + }) +} + +export const descriptionField: DataTableColumn = { + field: 'description', + mergeRender: { isTheme: { sx: { float: 'left' } }} , +} + +export const themeField: DataTableColumn = { + field: 'isTheme', + headerName: "is theme", + hidden: true, + dialogHidden: true, + type: 'boolean', + renderCell({ value }) { + return value && iconTooltip(ThemeIcon, _.isString(value) ? `${value} theme` : "theme", { fontSize: '1.2rem', mr: '.3em' }) + } +} \ No newline at end of file diff --git a/admin/src/InternetPage.ts b/admin/src/InternetPage.ts index fe0028a1..c59e1f45 100644 --- a/admin/src/InternetPage.ts +++ b/admin/src/InternetPage.ts @@ -16,12 +16,14 @@ import _ from 'lodash' import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon' import { ConfigForm } from './ConfigForm' import { DynamicDnsResult } from '../../src/ddns' +import { ArrayField } from './ArrayField' +import VfsPathField from './VfsPathField' const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code)) const PORT_FORWARD_URL = 'https://portforward.com/' const HIGHER_PORT = 1080 -const MSG_ISP = `It is possible that your Internet Provider won't let you get incoming connections. Ask them if they sell "public IP" as an extra service.` +const MSG_ISP = `It's possible that don't have a public IP, so that HFS won't be reachable on the Internet. Ask your Internet Provider if they sell "public IP" as an extra service.` export default function InternetPage() { const [checkResult, setCheckResult] = useState() @@ -33,10 +35,11 @@ export default function InternetPage() { h && s ? 'error' : h || s ? 'warning' : 'success') type GetNat = Awaited> const nat = useApiEx('get_nat', {}, { timeout: 20 }) + const { data: publicIps } = useApiEx('get_public_ips') const { data } = nat const port = data?.internalPort const wrongMap = data?.mapped && data.mapped.private.port !== port && data.mapped.private.port - const doubleNat = data?.externalIp && data?.publicIps && !data.publicIps.includes(data.externalIp) + const doubleNat = data?.externalIp && publicIps && !publicIps.includes(data.externalIp) const verifyAgain = useRequestRender() useEffect(() => { if (verifyAgain.state) // skip first @@ -44,8 +47,8 @@ export default function InternetPage() { }, [verifyAgain.state]) return h(Flex, { vert: true, gap: '2em', maxWidth: '40em' }, h(Alert, { severity: 'info' }, "This page makes sure your site is working correctly on the Internet"), - baseUrlBox(), networkBox(), + baseUrlBox(), httpsBox(), geoBox(), ddnsBox(), @@ -64,13 +67,12 @@ export default function InternetPage() { data && h(Flex, {}, data.error ? h(ErrorIcon, { color: 'error', ref }) : h(Check, { color: 'success', ref }), formatTimestamp(data.ts), ' – ', - prefix("Error: ", stripTags(data.error)) || "Updated successfully", + prefix("Error: ", stripTags(data.error)).slice(0, 500) || "Updated successfully", ), "This tool can keep your domain updated with your latest IP address. Not every service is compatible, and most of them have their own software for the job, which is superior, but we offer this lightweight solution in case you are more keen to it.", h(ConfigForm<{ [CFG.dynamic_dns_url]: string, }>, { - keys: [CFG.dynamic_dns_url], form: (v, { setValues }) => ({ fields: [ h(Flex, {}, @@ -97,7 +99,7 @@ export default function InternetPage() { }, label + " wizard") ) ), - { k: CFG.dynamic_dns_url, label: "Updater URL", + { k: CFG.dynamic_dns_url, label: "Updater URL", multiline: true, helperText: "Refer to your DNS service provider to know what URL can automatically keep your domain updated. Supported symbols are $IP4, $IP6, $IPX. Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used." }, ] @@ -121,7 +123,7 @@ export default function InternetPage() { keys: [ CFG.geo_enable, CFG.geo_allow, CFG.geo_list, CFG.geo_allow_unknown ], form: values => ({ fields: [ { k: CFG.geo_enable, comp: BoolField, label: "Enable", helperText: md("Necessary database will be downloaded every month (2MB). Service is made possibly thanks to [IP2Location](https://www.ip2location.com).") }, - ...!values[CFG.geo_enable] ? [] : [ + ...!values?.[CFG.geo_enable] ? [] : [ { k: CFG.geo_allow, comp: SelectField, @@ -190,7 +192,10 @@ export default function InternetPage() { label: "Domain for certificate", sm: values.acme_domain?.length > 30 ? 12 : 6, required: true, - helperText: md("Example: your.domain.com\nMultiple domains separated by commas") + multiline: true, + fromField: x => x.replaceAll('\n', ','), + toField: x => x.replaceAll(',', '\n'), + helperText: md("Example: your.domain.com\nMultiple domains on separated lines") }, { k: 'acme_email', label: "E-mail for certificate", sm: true }, { @@ -206,11 +211,11 @@ export default function InternetPage() { async onClick() { const [domain, ...altNames] = values.acme_domain.split(',') const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY - if (fresh && !await confirmDialog("Your certificate is still good", { confirmText: "Make a new one anyway" })) + if (fresh && !await confirmDialog("Your certificate is still good", { trueText: "Make a new one anyway" })) return if (!await confirmDialog("HFS must temporarily serve HTTP on public port 80, and your router must be configured or this operation will fail")) return const res = await apiCall('check_domain', { domain }).catch(e => - confirmDialog(String(e), { confirmText: "Continue anyway" }) ) + confirmDialog(String(e), { trueText: "Continue anyway" }) ) if (res === false) return await apiCall('make_cert', { domain, altNames, email: values.acme_email }, { timeout: 20_000 }) .then(async () => { @@ -226,7 +231,7 @@ export default function InternetPage() { } async function notEnabled() { - if (!await confirmDialog("HTTPS is currently disabled.\nFull configuration is available in the Options page.", { confirmText: "Enable it"})) return + if (!await confirmDialog("HTTPS is currently disabled.\nFull configuration is available in the Options page.", { trueText: "Enable it"})) return const stop = waitDialog() try { await apiCall('set_config', { values: { https_port: 443 } }) @@ -238,34 +243,41 @@ export default function InternetPage() { function baseUrlBox() { const url = config.data?.base_url - const hostname = url && new URL(url).hostname - const domain = !isIP(hostname) && hostname - return config.element || h(TitleCard, { icon: Public, title: "Address / Domain" }, + return config.element || h(TitleCard, { icon: Public, title: "Address" }, h(Flex, { flexWrap: 'wrap' }, - url || "Automatic, not configured", - h(Flex, {}, // keep buttons together when wrapping - h(Btn, { - size: 'small', - variant: 'outlined', - 'aria-label': "Change address", - onClick: () => void changeBaseUrl().then(config.reload) - }, "Change"), - domain && h(Btn, { - size: 'small', - variant: 'outlined', - onClick: () => apiCall('check_domain', { domain }) - .then(() => alertDialog("Domain seems ok", 'success')) - }, "Check"), - ), + "Main address: ", + url ? h('tt', {}, url) : "automatic, not configured", + h(Btn, { + size: 'small', + variant: 'outlined', + 'aria-label': "Change address", + onClick: () => void changeBaseUrl().then(config.reload) + }, "Change"), ), - h(ConfigForm<{ force_base_url: boolean }>, { - keys: ['force_base_url'], + h(ConfigForm<{ roots: any, force_address: boolean }>, { saveOnChange: true, + onSave() { + status.reload() // this config is affecting status data + }, form: { fields: [ - { k: 'force_base_url', comp: BoolField, disabled: !url, - label: "Accept requests only using domain (and localhost)", - helperText: !url && "You must specify an address, for this option" + { + k: CFG.roots, + label: false, + helperText: "You can decide different home-folders (in the VFS) for different domains, a bit like virtual hosts. If none is matched, the default home will be used.", + comp: ArrayField, + fields: [ + { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: *.domain.com|other.com" }, + { k: 'root', label: "Home/Root", comp: VfsPathField, placeholder: "default", helperText: "Root path in VFS", + $column: { renderCell({ value }: any) { return value || h('i', {}, 'default') } } }, + ], + toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })), + fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root || ''])), + }, + { + k: CFG.force_address, + label: "Accept requests only using domains above (and localhost)", + comp: BoolField, } ] }, @@ -275,8 +287,8 @@ export default function InternetPage() { function networkBox() { if (nat.error) return nat.element - if (!data) return h(CircularProgress) - const direct = data?.publicIps.includes(data?.localIp!) + if (!data || !publicIps) return h(CircularProgress) + const direct = publicIps.includes(data?.localIp!) return h(Flex, { justifyContent: 'space-around' }, h(Device, { name: "Server", icon: direct ? Storage : HomeWorkTwoTone, color: localColor, ip: data?.localIp, below: port && h(Box, { fontSize: 'smaller' }, "port ", port), @@ -290,13 +302,13 @@ export default function InternetPage() { "port ", wrongMap ? 'is wrong' : data?.externalPort || "unknown"), }), h(DataLine), - h(Device, { name: "Internet", icon: PublicTwoTone, ip: data?.publicIps, + h(Device, { name: "Internet", icon: PublicTwoTone, ip: publicIps, color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined, below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { fontSize: 'smaller' }, doubleNat && h(LinkBtn, { display: 'block', onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"), checkResult ? "Working!" : checkResult === false ? "Failed!" : '', ' ', - data?.publicIps.length > 0 && data.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify") + publicIps.length > 0 && data.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify") ) }), ) @@ -311,6 +323,12 @@ export default function InternetPage() { setChecking(true) try { const url = config.data?.base_url + { + const hostname = url && new URL(url).hostname + const domain = !isIP(hostname) && hostname + if (domain && false === await apiCall('check_domain', { domain }).catch(e => + confirmDialog(String(e), { trueText: "Continue anyway" }) )) return + } const urlResult = url && await apiCall('self_check', { url }).catch(() => alertDialog(md(`Sorry, we couldn't verify your configured address ${url} 😰\nstill, we are going to test your IP address 🤞`), 'warning')) if (urlResult?.success) { @@ -334,7 +352,7 @@ export default function InternetPage() { return alertDialog(MSG_ISP, 'warning') const msg = "We couldn't reach your server from the Internet. " if (data.upnp && !data!.mapped) - return confirmDialog(msg + "Try port-forwarding on your router", { confirmText: "Fix it" }).then(async go => { + return confirmDialog(msg + "Try port-forwarding on your router", { trueText: "Fix it" }).then(async go => { if (!go) return try { await mapPort(data!.internalPort!, '', '') } catch { await mapPort(HIGHER_PORT, '') } @@ -364,7 +382,7 @@ export default function InternetPage() { async function configure() { if (!data) return // shut up ts if (wrongMap) - return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${wrongMap})`, { confirmText: "Fix it" }) + return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${wrongMap})`, { trueText: "Fix it" }) && fixPort() if (!data.upnp) return alertDialog(h(Box, { lineHeight: 1.5 }, md(`We cannot help you configuring your router because UPnP is not available.\nFind more help [on this website](${PORT_FORWARD_URL}).`)), 'info') diff --git a/admin/src/LangPage.ts b/admin/src/LangPage.ts index 1552e7ee..0b876602 100644 --- a/admin/src/LangPage.ts +++ b/admin/src/LangPage.ts @@ -55,7 +55,7 @@ export default function LangPage() { h(IconBtn, { icon: Delete, title: row.embedded ? "Cannot delete (embedded)" : "Delete", - confirm: "Delete?", + confirm: `Delete language code "${row.code}"?`, disabled: row.embedded, async onClick() { await apiCall('del_lang', _.pick(row, 'code')) diff --git a/admin/src/LogsPage.ts b/admin/src/LogsPage.ts index b3addc9e..9fe9b7bc 100644 --- a/admin/src/LogsPage.ts +++ b/admin/src/LogsPage.ts @@ -1,12 +1,11 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { createElement as h, Fragment, ReactNode, useMemo, useState } from 'react'; +import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useState } from 'react'; import { Box, Tab, Tabs } from '@mui/material' -import { API_URL, useApi, useApiList } from './api' +import { API_URL, apiCall, useApi, useApiList } from './api' import { DataTable } from './DataTable' import { CFG, Dict, formatBytes, HTTP_UNAUTHORIZED, newDialog, prefix, shortenAgent, splitAt, tryJson, md, typedKeys, NBSP, _dbg, mapFilter, safeDecodeURIComponent } from '@hfs/shared' -import { logLabels } from './OptionsPage' import { NetmaskField, Flex, IconBtn, useBreakpoint, usePauseButton, useToggleButton, WildcardsSupported, Country, hTooltip, Btn, wikiLink @@ -17,13 +16,24 @@ import { ClearAll, Download, Settings, SmartToy } from '@mui/icons-material' import { ConfigForm } from './ConfigForm' import { BoolField, SelectField } from '@hfs/mui-grid-form' import { toast, useDialogBarColors } from './dialog' +import { useBlockIp } from './useBlockIp' +import { ALL as COUNTRIES } from './countries' + +const logLabels = { + log: "Access", + error_log: "Access error", + console: "Console", + ips: "IP's", +} + +let reloadIps: any export default function LogsPage() { const [tab, setTab] = useState(0) const files = typedKeys(logLabels) const shorterLabels = !useBreakpoint('sm') && { error_log: "Errors" } const file = files[tab] - const fileAvailable = file !== 'console' + const fileAvailable = file.endsWith('log') return h(Fragment, {}, h(Flex, { gap: 0 }, h(Tabs, { value: tab, onChange(ev,i){ setTab(i) } }, @@ -50,16 +60,7 @@ export default function LogsPage() { title: "Log options", dialogProps: { sx: { maxWidth: '40em' } }, Content() { - return h(ConfigForm<{ - [CFG.log]: string - [CFG.error_log]: string - [CFG.log_rotation]: string - [CFG.dont_log_net]: string - [CFG.log_gui]: boolean - [CFG.log_api]: boolean - [CFG.log_ua]: boolean - }>, { - keys: [ CFG.log, CFG.error_log, CFG.log_rotation, CFG.dont_log_net, CFG.log_gui, CFG.log_api, CFG.log_ua ], + return h(ConfigForm, { barSx: { gap: 2, width: '100%', ...useDialogBarColors() }, form: { stickyBar: true, @@ -76,7 +77,16 @@ export default function LogsPage() { }, { k: CFG.log_gui, sm: 6, comp: BoolField, label: "Log interface loading", helperText: "Some requests are necessary to load the interface" }, { k: CFG.log_api, sm: 6, comp: BoolField, label: "Log API requests", helperText: "Requests for commands" }, - { k: CFG.log_ua, sm: 6, comp: BoolField, label: "Log User-Agent", helperText: "Contains browser and possibly OS information" }, + { k: CFG.log_ua, sm: 6, comp: BoolField, label: "Log User-Agent", helperText: "Contains browser and possibly OS information. Can double the size of your logs on disk." }, + { k: CFG.log_spam, sm: 6, comp: BoolField, label: "Log spam requests", helperText: md`Spam are *failed* requests that are considered attacks aimed *not* to HFS and therefore harmless` }, + { k: CFG.track_ips, sm: 6, comp: BoolField, label: "Keep track of IPs", + parentProps: { display: 'flex', gap: 1 }, + after: h(Btn, { + size: 'small', variant: 'outlined', color: 'warning', + confirm: true, doneMessage: true, + onClick: () => apiCall('reset_ips').then(reloadIps) + }, "Reset") + }, ] } }) @@ -91,7 +101,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string const { pause, pauseButton } = usePauseButton() const [showApi, showApiButton] = useToggleButton("Show APIs", "Hide APIs", v => ({ icon: SmartToy, - sx: { rotate: v ? '0deg' : '180deg' }, + sx: { rotate: v ? 0 : '180deg' }, disabled: file === 'console', }), true) const [totalSize, setTotalSize] = useState(NaN) @@ -99,7 +109,9 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string const [skipped, setSkipped] = useState(0) const MAX = 2**20 const invert = true - useApi('get_log_file', { file, range: limited || !skipped ? -MAX : `0-${skipped}` }, { + const [firstSight, setFirstSight] = useState(!hidden) + useEffect(() => setFirstSight(x => x || !hidden), [hidden]) + useApi(firstSight && 'get_log_file', { file, range: limited || !skipped ? -MAX : `0-${skipped}` }, { skipParse: true, skipLog: true, onResponse(res, body) { const lines = body.split('\n') @@ -120,21 +132,27 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string setList(x => [...x, ...treated]) } }) - const { list, setList, error, connecting } = useApiList('get_log', { file }, { invert, pause, map: enhanceLogLine }) + const { list, setList, error, connecting, reload } = useApiList(firstSight && 'get_log', { file }, { invert, pause, map: enhanceLogLine }) + if (file === 'ips') + reloadIps = reload const tsColumn: GridColDef = { field: 'ts', headerName: "Timestamp", type: 'dateTime', - width: 90, + width: 96, valueGetter: ({ value }) => new Date(value as string), renderCell: ({ value }) => h(Fragment, {}, value.toLocaleDateString(), h('br'), value.toLocaleTimeString()) } const rows = useMemo(() => showApi || list?.[0]?.uri === undefined ? list : list.filter(x => !x.uri.startsWith(API_URL)), [list, showApi]) //TODO TypeError: l.uri is undefined + const blockIp = useBlockIp() + const isConsole = file === 'console' return hidden ? null : h(DataTable, { error, loading: connecting, rows, compact: true, + actionsProps: { hideUnder: 'md' }, + actions: ({ row }) => [ !isConsole && blockIp.iconBtn(row.ip, "From log") ], addToFooter: h(Box, {}, // 4 icons don't fit the tabs row on mobile pauseButton, showApiButton, @@ -149,7 +167,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string }, "Load whole log"), addToFooter, ), - columns: file === 'console' ? [ + columns: isConsole ? [ tsColumn, { field: 'k', @@ -160,21 +178,34 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string field: 'msg', headerName: "Message", flex: 1, - mergeRender: { other: 'k', override: { valueFormatter: ({ value }) => value !== 'log' && value } } + mergeRender: { k: { override: { valueFormatter: ({ value }) => value !== 'log' && value } } } + } + ] : file === 'ips' ? [ + tsColumn, + { + field: 'ip', + headerName: "Address", + flex: 1, + }, + { + headerName: "Country", + field: 'country', + flex: 1, + hidden: !showCountry, + valueGetter: ({ value }) => _.find(COUNTRIES, { code: value })?.name || value, + renderCell: ({ row }) => h(Country, { code: row.country, long: true, def: '-' }), } ] : [ { field: 'ip', headerName: "Address", flex: .6, - minWidth: 100, + minWidth: 130, maxWidth: 230, mergeRender: { - other: 'user', - display: 'flex', - justifyContent: 'space-between', - gap: '.5em', - override: { renderCell: ({ value, row }) => h(Fragment, {}, h('span', {}, value), h(Country, { code: row.extra?.country })) } + user: { display: 'flex', justifyContent: 'space-between', gap: '.5em', }, + country: showCountry && {}, + ua: {}, }, }, { @@ -235,7 +266,8 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string headerName: "URI", flex: 2, minWidth: 100, - mergeRender: { other: 'method', fontSize: 'small' }, + sx: { wordBreak: 'break-all' }, // be flexible, uri can be a mess + mergeRender: { method: {}, status: {} }, renderCell: ({ value, row }) => { const [path, query] = splitAt('?', value).map(safeDecodeURIComponent) const ul = row.extra?.ul @@ -255,7 +287,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string function enhanceLogLine(x: any) { if (!x) return const { extra } = x - if (extra?.country && !showCountry) + if ((extra?.country || x.country) && !showCountry) setShowCountry(true) if (extra?.ua && !showAgent) setShowAgent(true) @@ -271,27 +303,34 @@ export function agentIcons(agent: string | undefined) { if (!agent) return const UW = 'https://upload.wikimedia.org/wikipedia/commons/' const short = shortenAgent(agent) - const browserIcon = icon(short, { + const browserIcon = h(AgentIcon, { k: short, altText: true, map: { Chrome: UW + 'e/e1/Google_Chrome_icon_%28February_2022%29.svg', Chromium: UW + 'f/fe/Chromium_Material_Icon.svg', Firefox: UW + 'a/a0/Firefox_logo%2C_2019.svg', Safari: UW + '5/52/Safari_browser_logo.svg', - Edge: UW + 'f/f6/Edge_Logo_2019.svg', + Edge: UW + '9/98/Microsoft_Edge_logo_%282019%29.svg', Opera: UW + '4/49/Opera_2015_icon.svg', - }) + } }) const os = _.findKey(OSS, re => re.test(agent)) - const osIcon = os && icon(os, { + const osIcon = os && h(AgentIcon, { k: os, map: { android: UW + 'd/d7/Android_robot.svg', linux: UW + '0/0a/Tux-shaded.svg', win: UW + '0/0a/Unofficial_Windows_logo_variant_-_2002%E2%80%932012_%28Multicolored%29.svg', apple: UW + '7/74/Apple_logo_dark_grey.svg', // grey works for both themes - }) - return hTooltip(agent, undefined, h('span', { fontSize: '18px' }, browserIcon || short, ' ', osIcon) ) + } }) + return hTooltip(agent, undefined, h('span', {}, browserIcon, ' ', osIcon) ) +} - function icon(k: string, map: Dict) { - const src = map[k] - return src && h('img', { src, style: { height: '1em', verticalAlign: 'bottom', marginRight: '.2em' } }) - } +const alreadyFailed: any = {} + +function AgentIcon({ k, map, altText }: { k: string, map: Dict, altText?: boolean }) { + const src = map[k] + const [err, setErr] = useState(alreadyFailed[k]) + return !src || err ? h(Fragment, {}, altText ? k : null) : h('img', { + src: err ? `/${k}.svg` : src, + style: { height: '1.3em', verticalAlign: 'bottom', marginRight: '.2em' }, + onError() { setErr(alreadyFailed[k] = true) } + }) } const OSS = { diff --git a/admin/src/MainMenu.ts b/admin/src/MainMenu.ts index 3eed0236..b0a93d0c 100644 --- a/admin/src/MainMenu.ts +++ b/admin/src/MainMenu.ts @@ -17,7 +17,7 @@ import LogoutPage from './LogoutPage'; import LangPage from './LangPage' import LogsPage from './LogsPage'; import PluginsPage from './PluginsPage'; -import { getHFS, replaceStringToReact } from '@hfs/shared' +import { getHFS, replaceStringToReact, REPO_URL } from '@hfs/shared' import CustomHtmlPage from './CustomHtmlPage'; import InternetPage from './InternetPage' import { useWindowSize } from 'usehooks-ts' @@ -60,8 +60,9 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item display: 'flex', flexDirection: 'column', '&>a': { flex: '0' }, } }, - h(Box, { display: 'flex', px: 2, py: .5, gap: 2, alignItems: 'center' }, - h(Box, { fontSize: 'min(3rem, max(5vw, 4vh))' }, 'HFS'), + h(Box, { id: 'hfs-name', display: 'flex', px: 2, py: .5, gap: 2, alignItems: 'center' }, + h('a', { href: REPO_URL, target: 'website', style: { textDecoration: 'none' } }, + h(Box, { color: 'primary.contrastText', fontSize: 'min(3rem, max(5vw, 4vh))' }, 'HFS')), h(Box, { fontSize: 'small' }, replaceStringToReact(VERSION||'', /-/, () => h('br'))), short && h('img', { src: logo, style: { height: '2.5em' } }), ), @@ -79,7 +80,7 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item ), { key: it.path, placement: 'right' } )), - !short && h(Box, { sx: { flex: 1, opacity: .7, background: `url(${logo}) no-repeat bottom`, backgroundSize: 'contain', margin: 2 } }), + !short && h(Box, { id: 'hfs-logo', sx: { flex: 1, opacity: .7, background: `url(${logo}) no-repeat bottom`, backgroundSize: 'contain', margin: 2 } }), ) ) } diff --git a/admin/src/MonitorPage.ts b/admin/src/MonitorPage.ts index 55cc8d6a..4fe60eeb 100644 --- a/admin/src/MonitorPage.ts +++ b/admin/src/MonitorPage.ts @@ -3,16 +3,20 @@ import _ from "lodash" import { createElement as h, useMemo, Fragment, useState } from "react" import { apiCall, useApiEvents, useApiEx, useApiList } from "./api" -import { LinkOff, Lock, Block, FolderZip, Upload, Download, ChevronRight, ChevronLeft } from '@mui/icons-material' +import { LinkOff, Lock, FolderZip, Upload, Download, ChevronRight, ChevronLeft } from '@mui/icons-material' import { Box, Chip, ChipProps } from '@mui/material' import { DataTable } from './DataTable' -import { formatBytes, ipForUrl, manipulateConfig, CFG, formatSpeed, with_, createDurationFormatter, formatTimestamp, - formatPerc, md } from "./misc" +import { + formatBytes, ipForUrl, CFG, formatSpeed, with_, createDurationFormatter, formatTimestamp, formatPerc, md, Callback, + reactJoin, +} from "./misc" import { IconBtn, IconProgress, iconTooltip, usePauseButton, useBreakpoint, Country, hTooltip } from './mui' import { Field, SelectField } from '@hfs/mui-grid-form' import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssProperties' import { agentIcons } from './LogsPage' import { state, useSnapState } from './state' +import { useBlockIp } from './useBlockIp' +import { alertDialog } from './dialog' export default function MonitorPage() { return h(Fragment, {}, @@ -27,36 +31,45 @@ function MoreInfo() { if (status && connections) Object.assign(status, connections) const [allInfo, setAllInfo] = useState(false) - const xl = useBreakpoint('xl') const md = useBreakpoint('md') const sm = useBreakpoint('sm') const formatDuration = createDurationFormatter({ maxTokens: 2, skipZeroes: true }) - return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: '1em', mb: 2 }, + return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: { xs: .5, md: 1 }, mb: { xs: 1, sm: 2 } }, (allInfo || md) && pair('started', { label: "Uptime", render: x => formatDuration(Date.now() - +new Date(x)), title: x => "Started: " + formatTimestamp(x), }), - (allInfo || xl) && pair('http', { label: "HTTP", render: port }), - (allInfo || xl) && pair('https', { label: "HTTPS", render: port }), - (allInfo || sm) && pair('connections', { title: () => `${status.ips} IP(s)` }), - pair('sent', { render: formatBytes, minWidth: '4em' }), - (allInfo || sm) && pair('got', { render: formatBytes, minWidth: '4em' }), - pair('outSpeed', { label: "Output speed", render: formatSpeedK }), - (allInfo || md) && pair('inSpeed', { label: "Input speed", render: formatSpeedK }), - !xl && h(IconBtn, { size: 'small', icon: allInfo ? ChevronLeft : ChevronRight, title: "Show more", onClick: () => setAllInfo(x => !x) }), + (allInfo || sm) && pair('sent_got', { + render: x => ({ Sent: formatBytes(x[0]), Got: formatBytes(x[1]) }), + title: x => "Since: " + formatTimestamp(x[2]), + onDelete: () => apiCall('clear_persistent', { k: ['totalSent', 'totalGot'] }) + .then(() => alertDialog("Done", 'success'), alertDialog) + }), + (allInfo || sm) && pair('ips', { label: "IPs" }), + pair('outSpeed', { label: "Output", render: formatSpeedK, minWidth: '8.5em' }), + pair('inSpeed', { label: "Input", render: formatSpeedK, minWidth: '8.5em' }), + (md || allInfo && md || status?.http?.error) && pair('http', { label: "HTTP", render: port }), + (md || allInfo && md || status?.https?.error) && pair('https', { label: "HTTPS", render: port }), + !md && h(IconBtn, { + size: 'small', + icon: allInfo ? ChevronLeft : ChevronRight, + title: "Show more", + onClick: () => setAllInfo(x => !x) + }), ) type Color = ChipProps['color'] - type Render = (v: any) => [string, Color?] | string + type Render = (v: any) => [string, Color?] | string | { [label: string]: string } interface PairOptions { label?: string render?: Render minWidth?: StandardCSSProperties['minWidth'] title?: (v: any) => string + onDelete?: Callback } - function pair(k: string, { label, minWidth, render, title }: PairOptions={}) { + function pair(k: string, { label, minWidth, render, title, onDelete }: PairOptions={}) { let v = _.get(status, k) if (v === undefined) return null @@ -72,11 +85,11 @@ function MoreInfo() { return hTooltip(renderedTitle, undefined, h(Chip, { variant: 'filled', color, - label: h(Fragment, {}, - h('b',{},label), - ': ', - h('span', { style:{ display: 'inline-block', minWidth } }, v), - ), + onDelete, + label: reactJoin(' – ', _.map(_.isPlainObject(v) ? v : { [label]: v }, (v,label) => + h('span', { style:{ display: 'inline-block', minWidth } }, + h('b',{}, label), ': ', v, + ))), }) ) } @@ -96,6 +109,7 @@ function Connections() { const rows = useMemo(() => list?.filter((x: any) => !monitorOnlyFiles || x.op).map((x: any, id: number) => ({ id, ...x })), [!pause && list, monitorOnlyFiles]) //eslint-disable-line + const blockIp = useBlockIp() return h(Fragment, {}, h(Box, { display: 'flex', alignItems: 'center' }, h(SelectField as Field, { @@ -117,7 +131,7 @@ function Connections() { flex: 1, maxWidth: 400, renderCell: ({ row, value }) => ipForUrl(value) + ' :' + row.port, - mergeRender: { other: 'user', fontSize: 'small' }, + mergeRender: { user: { fontSize: 'small' } }, }, { field: 'country', @@ -135,7 +149,7 @@ function Connections() { field: 'started', headerName: "Started", type: 'dateTime', - width: 100, + width: 96, hideUnder: 'lg', valueFormatter: ({ value }) => new Date(value as string).toLocaleTimeString() }, @@ -169,7 +183,7 @@ function Connections() { hideUnder: 'sm', type: 'number', renderCell: ({ value, row }) => formatSpeedK(Math.max(value||0, row.inSpeed||0) || undefined), - mergeRender: { other: 'sent', fontSize: 'small', textAlign: 'right' } + mergeRender: { sent: { fontSize: 'small', textAlign: 'right' }} }, { field: 'sent', @@ -203,22 +217,12 @@ function Connections() { doneMessage: true, onClick: () => apiCall('disconnect', _.pick(row, ['ip', 'port'])).then(x => x.result > 0) }), - h(IconBtn, { - icon: Block, - title: "Block IP", - confirm: "Block address " + row.ip, - disabled: row.ip === props?.you, - onClick: () => blockIp(row.ip), - }), + blockIp.iconBtn(row.ip, "From monitoring", { disabled: row.ip === props?.you }), ] }) ) } -function blockIp(ip: string) { - return manipulateConfig('block', data => [...data, { ip }]) -} - function formatSpeedK(value: number | undefined) { return value === undefined ? '' : formatSpeed(value * 1000, { digits: 1 }) } diff --git a/admin/src/OnlinePlugins.ts b/admin/src/OnlinePlugins.ts index e7fcfcff..3df9b0a3 100644 --- a/admin/src/OnlinePlugins.ts +++ b/admin/src/OnlinePlugins.ts @@ -3,16 +3,16 @@ import { apiCall, useApiList } from './api' import { Fragment, createElement as h, useState } from 'react' import { DataTable } from './DataTable' -import { HTTP_FAILED_DEPENDENCY, xlate } from './misc' -import { Download, Search } from '@mui/icons-material' +import { HTTP_FAILED_DEPENDENCY, newDialog, wantArray, xlate } from './misc' +import { ArrowBack, ArrowForward, Download, RemoveRedEye, Search, Warning } from '@mui/icons-material' import { StringField } from '@hfs/mui-grid-form' import { useDebounce } from 'usehooks-ts' -import { renderName, startPlugin } from './InstalledPlugins' +import { descriptionField, renderName, startPlugin, themeField } from './InstalledPlugins' import { state, useSnapState } from './state' import { alertDialog, confirmDialog, toast } from './dialog' import _ from 'lodash' import { PLUGIN_ERRORS } from './PluginsPage' -import { IconBtn } from './mui' +import { Flex, IconBtn } from './mui' export default function OnlinePlugins() { const [search, setSearch] = useState('') @@ -40,7 +40,7 @@ export default function OnlinePlugins() { headerName: "name", flex: 1, renderCell: renderName, - mergeRender: { other: 'description', fontSize: 'x-small' }, + mergeRender: { description: { fontSize: 'x-small' } }, }, { field: 'version', @@ -55,8 +55,9 @@ export default function OnlinePlugins() { field: 'license', width: 80, }, + themeField, { - field: 'description', + ...descriptionField, flex: 3, hideUnder: 'sm', }, @@ -75,7 +76,10 @@ export default function OnlinePlugins() { progress: row.downloading, disabled: row.installed && "Already installed", tooltipProps: { placement:'bottom-end' }, // workaround problem with horizontal scrolling by moving the tooltip leftward - confirm: "WARNING - Proceed only if you trust this author and this plugin", + confirm: h(Flex, { vert: true, alignItems: 'center' }, + h(Warning, { color: 'warning', fontSize: 'large' }), + "Proceed only if you trust this plugin", + ), async onClick() { if (row.missing && !await confirmDialog("This will also install: " + _.map(row.missing, 'repo').join(', '))) return const branch = row.branch || row.default_branch @@ -87,7 +91,16 @@ export default function OnlinePlugins() { return alertDialog(msg, 'error') }) } - }) + }), + h(IconBtn, { + icon: RemoveRedEye, + disabled: !row.preview, + onClick: () => newDialog({ + title: id, + dialogProps: { sx: { minHeight: '50vh', minWidth: '50vw' } }, // the image will use available space, so we must reserve it (while mobile is going full-screen) + Content: () => h(ShowImages, { imgs: wantArray(row.preview) }) + }) + }), ] }) ) @@ -95,7 +108,7 @@ export default function OnlinePlugins() { async function installPlugin(id: string, branch?: string): Promise { try { const res = await apiCall('download_plugin', { id, branch, stop: true }, { timeout: false }) - if (await confirmDialog(`Plugin ${id} downloaded`, { confirmText: "Start" })) + if (await confirmDialog(`Plugin ${id} downloaded`, { trueText: "Start" })) await startPlugin(res.id) } catch(e:any) { @@ -114,3 +127,15 @@ export default function OnlinePlugins() { } } +function ShowImages({ imgs }: { imgs: string[] }) { + const [cur, setCur] = useState(0) + return h(Flex, { vert: true, flex: 1 }, + h(Flex, { vert: true, center: true, height: 0, flex: 'auto' }, + h('img', { src: imgs[cur], style: { margin: 'auto', /*center*/ maxWidth: '100%', maxHeight: '100%' /*limit*/ } }), + ), + imgs.length > 1 && h(Flex, { center: true }, + h(IconBtn, { icon: ArrowBack, disabled: !cur, onClick: () => setCur(cur - 1) }), + h(IconBtn, { icon: ArrowForward, disabled: cur >= imgs.length - 1, onClick: () => setCur(cur + 1) }), + ), + ) +} \ No newline at end of file diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index d5a566a3..04434b29 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -30,12 +30,6 @@ subscribe(state, (ops) => { recalculateChanges() }) -export const logLabels = { - log: "Access", - error_log: "Access error", - console: "Console", -} - export default function OptionsPage() { const { data, reload: reloadConfig, element } = useApiEx('get_config', { omit: ['vfs'] }) const snap = useSnapState() @@ -100,79 +94,52 @@ export default function OptionsPage() { return { sm: 6 } }, fields: [ - { k: 'port', comp: PortField, md: 4, label:"HTTP port", status: status?.http||true, suggestedPort: 80 }, - { k: 'https_port', comp: PortField, md: 4, label: "HTTPS port", status: status?.https||true, suggestedPort: 443, + h(Section, { title: "Networking" }), + { k: 'port', comp: PortField, label:"HTTP port", status: status?.http||true, suggestedPort: 80 }, + { k: 'https_port', comp: PortField, label: "HTTPS port", status: status?.https||true, suggestedPort: 443, onChange(v: number) { if (v >= 0 && !httpsEnabled && !values.cert) void suggestMakingCert() return v } }, - status && { k: 'listen_interface', comp: SelectField, md: 4, options: [{ label: "any", value: '' }, '127.0.0.1', '::1', ...status?.ips] }, - httpsEnabled && values.port >= 0 && { k: 'force_https', comp: BoolField, md: 4, label: "Force HTTPS", - helperText: "Not applied to localhost" - }, - httpsEnabled && { k: 'cert', comp: FileField, md: 4, label: "HTTPS certificate file", + httpsEnabled && { k: 'cert', comp: FileField, sm: 4, label: "HTTPS certificate file", helperText: wikiLink('HTTPS#certificate', "What is this?"), error: with_(status?.https.error, e => isCertError(e) && ( status.https.listening ? e : [e, ' - ', h(LinkBtn, { key: 'fix', onClick: suggestMakingCert }, "make one")] )), }, - httpsEnabled && { k: 'private_key', comp: FileField, md: 4, label: "HTTPS private key file", + httpsEnabled && { k: 'private_key', comp: FileField, sm: 4, label: "HTTPS private key file", ...with_(status?.https.error, e => isKeyError(e) ? { error: true, helperText: e } : null) }, - { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.png|*.ico|*.jpg|*.jpeg|*.gif|*.svg', - helperText: "The icon associated to your website" }, - { k: 'allowed_referer', placeholder: "any", label: "Links from other websites", comp: AllowedReferer, sm: 12, md: 6 }, - { k: 'open_browser_at_start', comp: BoolField, label: "Open Admin-panel at start", - helperText: "Browser is automatically launched with HFS" - }, - { k: 'localhost_admin', comp: BoolField, label: "Unprotected admin on localhost", - getError: x => !x && admins?.length===0 && "First create at least one admin account", - helperText: "Access Admin-panel without entering credentials" + + httpsEnabled && { k: 'force_https', comp: BoolField, label: "Force HTTPS", sm: 4, disabled: !httpsEnabled || values.port < 0, + helperText: "Not applied to localhost. Doesn't work with proxies." }, - { k: 'max_kbps', ...maxSpeedDefaults, label: "Limit output", helperText: "Doesn't apply to localhost" }, - { k: 'max_kbps_per_ip', ...maxSpeedDefaults, label: "Limit output per-IP" }, + + { k: 'listen_interface', comp: SelectField, sm: 4, options: [{ label: "any", value: '' }, '127.0.0.1', '::1', ...status?.ips||[]] }, + { k: 'max_kbps', ...maxSpeedDefaults, sm: 4, label: "Limit output", helperText: "Doesn't apply to localhost" }, + { k: 'max_kbps_per_ip', ...maxSpeedDefaults, sm: 4, label: "Limit output per-IP" }, + { k : CFG.max_downloads, ...maxDownloadsDefaults, helperText: "Number of simultaneous downloads" }, { k : CFG.max_downloads_per_ip, ...maxDownloadsDefaults, label: "Max downloads per-IP" }, { k : CFG.max_downloads_per_account, ...maxDownloadsDefaults, label: "Max downloads per-account", helperText: "Overrides other limits" }, - { k: 'dont_overwrite_uploading', comp: BoolField, sm: 12, md: 6, label: "Don't overwrite uploading", - helperText: "Files will be numbered to avoid overwriting" }, - { k: 'delete_unfinished_uploads_after', comp: NumberField, md: 3, min : 0, unit: "seconds", placeholder: "Never", - helperText: "Leave empty to never delete" }, - { k: 'min_available_mb', comp: NumberField, md: 3, min : 0, unit: "MBytes", placeholder: "None", - label: "Min. available disk space", helperText: "Reject uploads that don't comply" }, - { k: 'keep_session_alive', comp: BoolField, helperText: "Keeps you logged in while the page is left open and the computer is on" }, - { k: 'session_duration', comp: NumberField, sm: 3, min: 5, unit: "seconds", required: true }, - { k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 3, label: "Calculate ZIP size for", unit: "seconds", - helperText: "If time is not enough, the browser will not show download percentage" }, - { k: 'update_to_beta', comp: BoolField, helperText: "Include betas searching updates" }, + { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address", helperText: h(Fragment, {}, "IP address of browser machine. ", h(WildcardsSupported)) }, - { k: 'descript_ion', comp: BoolField, label: "Support file DESCRIPT.ION", helperText: "Old file format, used for comments" }, - { k: 'descript_ion_encoding', label: "Encoding of file DESCRIPT.ION", comp: SelectField, disabled: !values.descript_ion, - options: ['utf8',720,775,819,850,852,862,869,874,808, ..._.range(1250,1257),10029,20866,21866] }, - { k: 'proxies', comp: NumberField, min: 0, max: 9, label: "How many HTTP proxies between this server and users?", + { k: 'localhost_admin', comp: BoolField, label: "Unprotected admin on localhost", + getError: x => !x && admins?.length===0 && "First create at least one admin account", + helperText: "Access Admin-panel without entering credentials" + }, + + { k: 'proxies', comp: NumberField, min: 0, max: 9, label: "Number of HTTP proxies", error: proxyWarning(values, status), helperText: "Wrong number will prevent detection of users' IP address" }, - { k: 'mime', comp: ArrayField, label: false, reorder: true, prepend: true, md: 6, - fields: [ - { k: 'k', label: "File mask", helperText: h(WildcardsSupported), $width: 1, $column: { - renderCell: ({ value, id }: any) => h('code', {}, - value, - value === '*' && id < _.size(values.mime) - 1 - && iconTooltip(Warning, md("Mime with `*` should be the last, because first matching row applies"), { - color: 'warning.main', ml: 1 - })) - } }, - { k: 'v', label: "Mime type", placeholder: "auto", $width: 2, helperText: "Leave empty to get automatic value" }, - ], - toField: x => Object.entries(x || {}).map(([k,v]) => ({ k, v })), - fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v || 'auto'])), - }, - { k: 'block', label: false, comp: ArrayField, prepend: true, sm: 12, + { k: 'allowed_referer', placeholder: "any", label: "Links from other websites", comp: AllowedReferer, }, + + { k: 'block', label: false, comp: ArrayField, prepend: true, sm: true, fields: [ { k: 'ip', label: "Blocked IP", sm: 6, required: true, helperText: h(WildcardsSupported) }, { k: 'expire', $type: 'dateTime', minDate: new Date(), sm: 6, helperText: "Leave empty for no expiration" }, @@ -187,22 +154,64 @@ export default function OptionsPage() { { k: 'comment' }, ], }, - { k: 'server_code', comp: TextEditorField, sm: 12, getError: v => try_(() => new Function(v) && null, e => e.message), - helperText: md(`This code works similarly to [a plugin](${REPO_URL}blob/main/dev-plugins.md) (with some limitations)`) - }, h(Section, { title: "Front-end", subtitle: "Following options affect only the front-end" }), { k: 'file_menu_on_link', comp: SelectField, label: "Access file menu", md: 4, options: { "by clicking on file name": true, "by dedicated button": false } }, { k: 'title', md: 8, helperText: "You can see this in the tab of your browser" }, - { k: 'auto_play_seconds', comp: NumberField, xs: 6, sm: 3, min: 1, max: 10000, label: "Auto-play seconds delay" }, - { k: 'tile_size', comp: NumberField, xs: 6, sm: 3, min: 0, max: MAX_TILE_SIZE, label: "Default tiles size", helperText: "Zero = list mode" }, + + { k: 'auto_play_seconds', comp: NumberField, xs: 6, sm: 3, min: 1, max: 10000, label: "Auto-play seconds delay", helperText: md(`Default value for the [Show interface](${REPO_URL}discussions/270)`) }, + { k: 'tile_size', comp: NumberField, xs: 6, sm: 3, min: 0, max: MAX_TILE_SIZE, label: "Default tiles size", helperText: wikiLink('Tiles', "To enable tiles-mode") }, { k: 'theme', comp: SelectField, xs: 6, sm: 3, options: THEME_OPTIONS }, { k: 'sort_by', comp: SelectField, xs: 6, sm: 3, options: SORT_BY_OPTIONS }, - { k: 'invert_order', comp: BoolField, xs: 6, sm: 4, md: 3, }, - { k: 'folders_first', comp: BoolField, xs: 6, sm: 4, md: 3, }, - { k: 'sort_numerics', comp: BoolField, xs: 6, sm: 4, md: 3, label: "Sort numeric names" }, + + { k: 'invert_order', comp: BoolField, xs: 6, sm: 4, md: 3, }, + { k: 'folders_first', comp: BoolField, xs: 6, sm: 4, md: 3, }, + { k: 'sort_numerics', comp: BoolField, xs: 6, sm: 4, md: true, label: "Sort numeric names" }, + { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.png|*.ico|*.jpg|*.jpeg|*.gif|*.svg', sm: 12, + helperText: "The icon associated to your website" }, + + h(Section, { title: "Uploads" }), + { k: 'dont_overwrite_uploading', comp: BoolField, sm: 4, md: 6, label: "Don't overwrite uploading", + helperText: "Files will be numbered to avoid overwriting" }, + { k: 'delete_unfinished_uploads_after', comp: NumberField, sm: 4, md: 3, min : 0, unit: "seconds", placeholder: "Never", + helperText: "Leave empty to never delete" }, + { k: 'min_available_mb', comp: NumberField, sm: 4, md: 3, min : 0, unit: "MBytes", placeholder: "None", + label: "Min. available disk space", helperText: "Reject uploads that don't comply" }, + + h(Section, { title: "Others" }), + { k: 'keep_session_alive', comp: BoolField, sm: true, helperText: "Keeps you logged in while the page is left open and the computer is on" }, + { k: 'session_duration', comp: NumberField, sm: 4, md: 3, min: 5, unit: "seconds", required: true }, + { k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 4, md: 3, label: "Calculate ZIP size for", unit: "seconds", + helperText: "If time is not enough, the browser will not show download percentage" }, + + { k: 'descript_ion', comp: BoolField, label: "Enable comments", helperText: "In file DESCRIPT.ION" }, + { k: 'descript_ion_encoding', label: "Encoding of file DESCRIPT.ION", comp: SelectField, disabled: !values.descript_ion, + options: ['utf8',720,775,819,850,852,862,869,874,808, ..._.range(1250,1257),10029,20866,21866] }, + + { k: 'open_browser_at_start', comp: BoolField, label: "Open Admin-panel at start", + helperText: "Browser is automatically launched with HFS" + }, + { k: 'mime', comp: ArrayField, label: false, reorder: true, prepend: true, md: 6, + fields: [ + { k: 'k', label: "File mask", helperText: h(WildcardsSupported), $width: 1, $column: { + renderCell: ({ value, id }: any) => h('code', {}, + value, + value === '*' && id < _.size(values.mime) - 1 + && iconTooltip(Warning, md("Mime with `*` should be the last, because first matching row applies"), { + color: 'warning.main', ml: 1 + })) + } }, + { k: 'v', label: "Mime type", placeholder: "auto", $width: 2, helperText: "Leave empty to get automatic value" }, + ], + toField: x => Object.entries(x || {}).map(([k,v]) => ({ k, v })), + fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v || 'auto'])), + }, + { k: 'server_code', comp: TextEditorField, sm: 12, getError: v => try_(() => new Function(v) && null, e => e.message), + helperText: md(`This code works similarly to [a plugin](${REPO_URL}blob/main/dev-plugins.md) (with some limitations)`) + }, + ] }) @@ -344,7 +353,7 @@ export async function suggestMakingCert() { onClose: resolve, Content: () => h(Box, { p: 1, lineHeight: 1.5, }, h(Box, {}, "HTTPS needs a certificate to work."), - h(Box, {}, "We suggest you to ", h(InLink, { to: 'internet', onClick: close }, "get a free but proper certificate"), '.'), + h(Box, {}, "We suggest you to ", h(InLink, { to: 'internet' }, "get a free but proper certificate"), '.'), h(Box, {}, "If you don't have a domain ", h(LinkBtn, { onClick: makeCertAndSave }, "make a self-signed certificate"), " but that ", wikiLink('HTTPS#certificate', " won't be perfect"), '.' ), ) diff --git a/admin/src/VfsMenuBar.ts b/admin/src/VfsMenuBar.ts index c07768fb..4f5cc23b 100644 --- a/admin/src/VfsMenuBar.ts +++ b/admin/src/VfsMenuBar.ts @@ -4,12 +4,9 @@ import { createElement as h } from 'react' import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material' import { Microsoft, Storage } from '@mui/icons-material' import { reloadVfs } from './VfsPage' -import { CFG, newDialog, prefix } from './misc' -import { Btn, Flex, IconBtn, reloadBtn, useBreakpoint } from './mui' +import { prefix } from './misc' +import { Btn, Flex, reloadBtn, useBreakpoint } from './mui' import { apiCall, ApiObject, useApi } from './api' -import { ConfigForm } from './ConfigForm' -import { ArrayField } from './ArrayField' -import { BoolField } from '@hfs/mui-grid-form' import VfsPathField from './VfsPathField' import { alertDialog, promptDialog } from './dialog' import { formatDiskSpace } from './FilePicker' @@ -17,16 +14,15 @@ import { getDiskSpaces } from '../../src/util-os' export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) { return h(Flex, { - mb: 2, zIndex: 2, backgroundColor: 'background.paper', width: 'fit-content', }, - h(Btn, { variant: 'outlined', onClick: roots }, "Roots"), - useBreakpoint('sm') && reloadBtn(() => reloadVfs()), - h(IconBtn, { + reloadBtn(() => reloadVfs()), + h(Btn, { icon: Storage, title: "Disk spaces", + progress: false, onClick: () => apiCall>>('get_disk_spaces').then(res => alertDialog(h(List, { dense: true }, res.map(x => h(ListItem, { key: x.name }, h(ListItemIcon, {}, h(Storage)), @@ -41,63 +37,27 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) { }), h(SystemIntegrationButton, statusApi.data) ) - - function roots() { - const { close } = newDialog({ - title: "Roots for different domains", - dialogProps: { maxWidth: 'sm' }, - Content: () => h(ConfigForm<{ roots: any, roots_mandatory: boolean }>, { - onSave() { - statusApi.reload() // this config is affecting status data - close() - }, - keys: [CFG.roots, CFG.roots_mandatory], - form: { - fields: [ - { - k: 'roots', - label: false, - helperText: "You can decide different home-folders (in the VFS) for different domains, a bit like virtual hosts. If none is matched, the default home will be used.", - comp: ArrayField, - fields: [ - { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: domain.*|other.*" }, - { k: 'root', label: "Home/Root", comp: VfsPathField, placeholder: "default", helperText: "Root path in VFS" }, - ], - toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })), - fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root || ''])), - }, - { - k: 'roots_mandatory', - label: "Accept requests only using domains above (and localhost)", - comp: BoolField, - } - ] - } - }) - }) - } } function SystemIntegrationButton({ platform }: { platform: string | undefined }) { const isWindows = platform === 'win32' const { data: integrated, reload } = useApi(isWindows && 'windows_integrated') - return !isWindows ? null : h(Btn, { + const sm = useBreakpoint('sm') + return h(Btn, { icon: Microsoft, variant: 'outlined', doneMessage: true, ...(!integrated?.is ? { children: "System integration", async onClick() { - const msg = h(Box, {}, "We are going to add a command in the right-click of Windows File Manager", - h('img', { src: 'win-shell.png', style: { - display: 'block', - width: 'min(30em, 80vw)', - marginTop: '1em', - } }), - h(Alert, { severity: 'info' }, "It will also automatically copy the URL, ready to paste!"), + const msg = h(Box, { width: { xs: '100%', sm: '34em' } }, + h('img', { src: 'win-shell.png', style: { display: 'block', width: '100%' } }), + h(Alert, { severity: 'info' }, "We are going to add a command in the right-click of Windows File Manager.", + h(Box, {}, "It will also automatically copy the URL, ready to paste!")), ) const parent = await promptDialog(msg, { - field: { comp: VfsPathField, label: "Add to this folder", placeholder: "home" }, + field: { comp: VfsPathField, label: "Add to this folder", placeholder: "home", + autoFocus: sm }, // this dialog is tall, and mobile keyboard will disrupt user's ability to view its content form: { saveOnEnter: false } }) return typeof parent === 'string' && apiCall('windows_integration', { parent }).then(reload) diff --git a/admin/src/VfsPage.ts b/admin/src/VfsPage.ts index e2d2a94a..1f7832c9 100644 --- a/admin/src/VfsPage.ts +++ b/admin/src/VfsPage.ts @@ -86,7 +86,7 @@ export default function VfsPage() { }, }) closeDialogRef.current = close - return close // auto-close dialog if we are switching to side-panel + return () => void close() // auto-close dialog if we are switching to side-panel }, [isSideBreakpoint, _.last(selectedFiles)?.id]) useEffect(() => { @@ -144,8 +144,10 @@ export default function VfsPage() { ), h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0 }, h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5 }, - h(Typography, { variant: 'h6', mb: 1, }, "Virtual File System"), - h(VfsMenuBar, { statusApi }), + h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] }, + h(Typography, { variant: 'h6' }, "Virtual File System"), + h(VfsMenuBar, { statusApi }), + ), vfs && h(VfsTree, { id2node, statusApi }) ), isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%' }, h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar diff --git a/admin/src/VfsTree.ts b/admin/src/VfsTree.ts index 4ec961ec..5c19e8ee 100644 --- a/admin/src/VfsTree.ts +++ b/admin/src/VfsTree.ts @@ -92,7 +92,7 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map root === id.slice(1)), host => @@ -105,7 +105,7 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map 45 ? name : h('span', {}, - h('span', { style: { opacity: .4 } }, rel.slice(0,-name.length)), + h('span', { style: { opacity: .4, fontSize: 'small' } }, rel.slice(0,-name.length)), rel.slice(-name.length), ) })() diff --git a/admin/src/addFiles.ts b/admin/src/addFiles.ts index 4a47d776..ceddb50c 100644 --- a/admin/src/addFiles.ts +++ b/admin/src/addFiles.ts @@ -63,7 +63,7 @@ export async function addVirtual() { export async function addLink() { try { const { id: parent } = getFolderFromSelected() - const res = await apiCall('add_vfs', { parent, name: 'new link', url: 'https://google.com' }) + const res = await apiCall('add_vfs', { parent, name: 'new link', url: 'https://example.com' }) reloadVfs([ parent + encodeURI(res.name) ]) toast("Link created", 'success', { onClose: () => focusSelector('input[name=url]') diff --git a/admin/src/dialog.ts b/admin/src/dialog.ts index 7c8ae112..b5c966c3 100644 --- a/admin/src/dialog.ts +++ b/admin/src/dialog.ts @@ -2,18 +2,20 @@ import { Box, Button, CircularProgress, Dialog as MuiDialog, DialogContent, DialogTitle, Modal } from '@mui/material' -import { createElement as h, Dispatch, Fragment, isValidElement, ReactElement, ReactNode, SetStateAction, +import { + createElement as h, Dispatch, FC, Fragment, isValidElement, ReactElement, ReactNode, SetStateAction, useEffect, useRef, useState } from 'react' import { Check, Close, Error as ErrorIcon, Forward, Info, Warning } from '@mui/icons-material' import { newDialog, closeDialog, dialogsDefaults, DialogOptions, componentOrNode, pendingPromise, - focusSelector, md } from '@hfs/shared' + focusSelector, md, focusableSelector } from '@hfs/shared' import { Form, FormProps } from '@hfs/mui-grid-form' import { IconBtn, Flex, Center } from './mui' import { useDark } from './theme' import { useWindowSize } from 'usehooks-ts' import _ from 'lodash' import { err2msg } from './misc' +import { useSnapState } from './state' export * from '@hfs/shared/dialogs' dialogsDefaults.Container = function Container(d: DialogOptions) { @@ -26,7 +28,7 @@ dialogsDefaults.Container = function Container(d: DialogOptions) { if (!el) return el.focus() if (mobile) return - focusSelector('[autofocus]', el) || focusSelector('input,textarea', el) + focusSelector('[autofocus]', el) || focusSelector(focusableSelector, el) }) return () => clearTimeout(h) }, [ref.current]) @@ -48,12 +50,11 @@ dialogsDefaults.Container = function Container(d: DialogOptions) { sx: { position: 'sticky', top: 0, p: 1, zIndex: 2, boxShadow: '0 0 8px #0004', display: 'flex', alignItems: 'center', - gap: 1, ...titleSx }, }, d.icon && componentOrNode(d.icon), - h(Box, { flex:1, minWidth: 40 }, componentOrNode(d.title)), + h(Box, { flex:1, minWidth: 40, ml: 1 }, componentOrNode(d.title)), d.closable && h(IconBtn, { icon: Close, title: "Close", onClick: () => closeDialog() }), ), h(DialogContent, { @@ -68,7 +69,8 @@ dialogsDefaults.Container = function Container(d: DialogOptions) { } export function useDialogBarColors() { - return useDark() ? { bgcolor: '#2d2d2d' } : { bgcolor:'#ccc', color: '#444', } + const { darkTheme } = useSnapState() + return darkTheme ?? useDark() ? { bgcolor: '#2d2d2d' } : { bgcolor:'#ccc', color: '#444', } } type AlertType = 'error' | 'warning' | 'info' | 'success' @@ -105,12 +107,20 @@ export function alertDialog(msg: ReactElement | string | Error, options?: AlertT return Object.assign(promise, dialog) } -interface ConfirmOptions extends Omit { href?: string, confirmText?: string, dontText?: string } -export function confirmDialog(msg: ReactNode, { href, confirmText="Go", dontText="Don't", ...rest }: ConfirmOptions={}) { +interface ConfirmOptions extends Omit { + href?: string, + trueText?: string, + falseText?: string, + before?: FC<{ onClick: (result: any) => unknown }> + after?: FC<{ onClick: (result: any) => unknown }> +} + +export function confirmDialog(msg: ReactNode, { href, trueText="Go", falseText="Don't", before, after, ...rest }: ConfirmOptions={}) { const promise = pendingPromise() const dialog = newDialog({ className: 'dialog-confirm', onClose: promise.resolve, + dialogProps: { sx: { alignItems: 'center' } }, ...rest, Content }) @@ -120,11 +130,13 @@ export function confirmDialog(msg: ReactNode, { href, confirmText="Go", dontText return h(Fragment, {}, h(Box, { mb: 2 }, typeof msg === 'string' ? md(msg) : msg), h(Flex, {}, + before?.({ onClick: (v: any) => dialog.close(v) }), h('a', { href, onClick: () => dialog.close(true), - }, h(Button, { variant: 'contained' }, confirmText)), - h(Button, { onClick: () => dialog.close(false) }, dontText), + }, h(Button, { variant: 'contained' }, trueText)), + h(Button, { onClick: () => dialog.close(false) }, falseText), + after?.({ onClick: (v: any) => dialog.close(v) }), ), ) } @@ -139,12 +151,12 @@ type FormDialog = Omit, 'values' | 'save' | 'set'> export async function formDialog( { form, values, ...options }: Omit & { values?: Partial, - form: FormDialog | ((values: Partial) => FormDialog), + form: FormDialog | ((values: Partial) => FormDialog), // allow callback form }, ) : Promise { return new Promise(resolve => { const dialog = newDialog({ - className: 'dialog-confirm', + className: 'dialog-form', onClose: resolve, ...options, Content() { @@ -162,11 +174,11 @@ export async function formDialog( return newV }) }, - save: { - ...props.save, + save: props.save !== false && { onClick() { dialog.close(curValues) - } + }, + ...props.save, } }) ) diff --git a/admin/src/index.scss b/admin/src/index.scss index 913dc532..34c38cfa 100644 --- a/admin/src/index.scss +++ b/admin/src/index.scss @@ -43,6 +43,7 @@ div.MuiTreeItem-content { padding: 0; } // remove wasting horizontal padding .MuiDataGrid-cell { line-height: 1.1em; } +.MuiDataGrid-filterForm { flex-wrap: wrap; justify-content: space-evenly; row-gap: 1em; } // fit on mobile .wrap[role=cell] { white-space: normal !important } diff --git a/admin/src/index.ts b/admin/src/index.ts index f12c8078..87b9779d 100644 --- a/admin/src/index.ts +++ b/admin/src/index.ts @@ -5,10 +5,6 @@ import { createRoot } from 'react-dom/client' import './index.scss' import '@hfs/shared/min-crypto-polyfill' import App from './App' -import { disableConsoleDebug } from '@hfs/shared' - -//@ts-ignore -if (import.meta.env.PROD) disableConsoleDebug() createRoot(document.getElementById('root')!) .render( h(StrictMode, {}, h(App)) ) diff --git a/admin/src/mui.ts b/admin/src/mui.ts index 8b685228..c541bb63 100644 --- a/admin/src/mui.ts +++ b/admin/src/mui.ts @@ -7,12 +7,12 @@ import { createElement as h, forwardRef, Fragment, ReactElement, ReactNode, useC ForwardedRef, useState, useMemo } from 'react' import { Box, BoxProps, Breakpoint, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps, Tooltip, TooltipProps, useMediaQuery } from '@mui/material' -import { formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_ } from './misc' +import { anyDialogOpen, closeDialog, formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_ } from './misc' import { dontBotherWithKeys, restartAnimation, useBatch, useStateMounted } from '@hfs/shared' import { Promisable, StringField } from '@hfs/mui-grid-form' import { alertDialog, confirmDialog, toast } from './dialog' import { LoadingButton } from '@mui/lab' -import { Link as RouterLink } from 'react-router-dom' +import { Link as RouterLink, LinkProps as RouterLinkProps, useNavigate } from 'react-router-dom' import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon' import _ from 'lodash' import { ALL as COUNTRIES } from './countries' @@ -117,11 +117,11 @@ function useRefPass(forwarded: ForwardedRef) { }) } -interface IconBtnProps extends Omit { icon: SvgIconComponent } +export interface IconBtnProps extends Omit { icon: SvgIconComponent } export const IconBtn = forwardRef((props: IconBtnProps, ref: ForwardedRef) => h(Btn, { ref, ...props })) -interface BtnProps extends Omit { +export interface BtnProps extends Omit { icon?: SvgIconComponent title?: ReactNode disabled?: boolean | string @@ -197,13 +197,23 @@ export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxP return hTooltip(tooltip, undefined, h(icon, { sx, ...props }) ) } -export function InLink(props:any) { +// link for internal navigation +export function InLink({ ...props }: LinkProps & RouterLinkProps) { + // make links inside dialogs work correctly + const nav = useNavigate() + props.onClickCapture = async ev => { + ev.preventDefault() + while (anyDialogOpen()) + await closeDialog()?.closed + nav(props.to) + } return h(Link, { component: RouterLink, ...props }) } export const Center = forwardRef((props: BoxProps, ref) => h(Box, { ref, display:'flex', height:'100%', width:'100%', justifyContent:'center', alignItems:'center', flexDirection: 'column', ...props })) +// looks like a link, but it's a button export function LinkBtn({ ...rest }: LinkProps) { return h(Link, { ...rest, @@ -271,6 +281,7 @@ async function ip2countryBatch(ips: string[]) { export function hTooltip(title: ReactNode, ariaLabel: string | undefined, children: ReactElement, props?: Omit & { key?: any }) { return h(Tooltip, { title, children, ...ariaLabel === '' ? { 'aria-hidden': true } : { 'aria-label': ariaLabel }, + componentsProps: { popper: { sx: { whiteSpace: 'pre-wrap', ...props?.sx } } }, ...props }) } \ No newline at end of file diff --git a/admin/src/state.ts b/admin/src/state.ts index 4723a89b..fd394a5f 100644 --- a/admin/src/state.ts +++ b/admin/src/state.ts @@ -7,35 +7,27 @@ import _ from 'lodash' import { subscribeKey } from 'valtio/utils' const STORAGE_KEY = 'admin_state' -export const state = proxy<{ - title: string - config: Dict - vfs: VfsNode | undefined - movingFile: string - selectedFiles: VfsNode[] - loginRequired: boolean | number - username: string - monitorOnlyFiles: boolean - customHtmlSection: string, - onlinePluginsColumns: Dict -}>(Object.assign({ +const INIT = { title: '', - config: {}, - selectedFiles: [], + config: {} as Dict, + selectedFiles: [] as VfsNode[], movingFile: '', - vfs: undefined, - loginRequired: false, + vfs: undefined as VfsNode | undefined, + loginRequired: false as boolean | number, username: '', monitorOnlyFiles: true, customHtmlSection: '', + darkTheme: undefined as undefined | boolean, onlinePluginsColumns: { version: false, pushed_at: false, license: false, - } -}, JSON.parse(localStorage[STORAGE_KEY]||null))) + } as Dict +} +Object.assign(INIT, JSON.parse(localStorage[STORAGE_KEY]||null)) +export const state = proxy(INIT) -const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'customHtmlSection'] +const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'customHtmlSection', 'darkTheme'] const storeSettings = _.debounce(() => localStorage[STORAGE_KEY] = JSON.stringify(_.pick(state, SETTINGS_TO_STORE)), 500, { maxWait: 1000 }) for (const k of SETTINGS_TO_STORE) diff --git a/admin/src/theme.ts b/admin/src/theme.ts index 7798009f..8e0dabfa 100644 --- a/admin/src/theme.ts +++ b/admin/src/theme.ts @@ -1,7 +1,10 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt import { createTheme, useMediaQuery } from '@mui/material' -import { useMemo } from 'react' +import { createElement as h, useMemo } from 'react' +import { state, useSnapState } from './state' +import { Btn, BtnProps } from './mui' +import { Brightness4, Brightness7 } from '@mui/icons-material' export function useDark() { return useMediaQuery('(prefers-color-scheme: dark)') @@ -9,7 +12,9 @@ export function useDark() { const EMPTY = {} export function useMyTheme() { - const lightMode = useDark() ? null : EMPTY + const { darkTheme } = useSnapState() + const detected = useDark() + const lightMode = (darkTheme ?? detected) ? null : EMPTY return useMemo(() => createTheme({ palette: lightMode || { mode: 'dark', @@ -43,3 +48,17 @@ export function useMyTheme() { } }), [lightMode]) } + +export function SwitchThemeBtn(props: BtnProps) { + const { darkTheme } = useSnapState() + const darkDetected = useDark() + const currentlyDark = darkTheme ?? darkDetected + return h(Btn, { + icon: currentlyDark ? Brightness7 : Brightness4, + onClick: () => { + const v = !currentlyDark + return state.darkTheme = darkDetected === v ? undefined : v + }, + ...props, + }, currentlyDark ? "Light theme" : "Dark theme") +} diff --git a/admin/src/useBlockIp.ts b/admin/src/useBlockIp.ts new file mode 100644 index 00000000..e478739e --- /dev/null +++ b/admin/src/useBlockIp.ts @@ -0,0 +1,26 @@ +import { apiCall, useApi } from '@hfs/shared/api' +import { createElement as h, useCallback } from 'react' +import { BlockingRule } from '../../src/block' +import { toast } from './dialog' +import _ from 'lodash' +import { IconBtn, IconBtnProps } from './mui' +import { Block } from '@mui/icons-material' + +export function useBlockIp() { + const { data, reload } = useApi('get_config', { only: ['block'] }) + const block = useCallback((ip: string, more: Partial={}, showResult=true) => + apiCall('set_config', { values: { block: [...data.block, { ip, ...more }] } }) + .then(reload).then(() => showResult && toast("Blocked", 'success')), + [data, reload]) + const isBlocked = useCallback((ip: string) => _.find(data?.block, { ip }), [data]) + return { + iconBtn: (ip: string, comment: string, options: Partial={}) => h(IconBtn, { + icon: Block, + title: "Block IP", + confirm: "Block address " + ip, + ...isBlocked(ip) && { disabled: true, title: "Blocked" }, + ...options, + onClick: () => block(ip, { comment }), + }), + } +} diff --git a/admin/vite.config.ts b/admin/vite.config.ts index c375cb5e..709e67db 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vite' -import vitePluginImport from 'vite-plugin-babel-import'; +//import vitePluginImport from 'vite-plugin-babel-import'; +// package.json/devDependencies: "vite-plugin-babel-import": "github:rejetto/vite-plugin-babel-import" // https://vitejs.dev/config/ export default defineConfig({ @@ -8,7 +9,7 @@ export default defineConfig({ emptyOutDir: true, target: "es2015", rollupOptions: { - plugins: [ + /* plugins: [ vitePluginImport([ { // speed up build process (~2s on my M1) by bringing "modules transformed" from 11k+ down to 1.7k+ libraryName: '@mui/icons-material', @@ -17,7 +18,7 @@ export default defineConfig({ ignoreStyles: [], }, ]) - ], + ],*/ onwarn(warning, warn) { if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes(`"use client"`)) return warn(warning) @@ -29,7 +30,8 @@ export default defineConfig({ host: '127.0.0.1', proxy: { '/~/': { - target: 'http://localhost', + target: + 'http://localhost', proxyTimeout: 2000, changeOrigin: true, ws: true, diff --git a/config.md b/config.md index ee2f0cfe..e766264c 100644 --- a/config.md +++ b/config.md @@ -27,13 +27,15 @@ config config.yaml Configuration can be done in several ways - accessing the Admin-panel with your browser - - it will automatically open when you start HFS. Bookmark it. if your port is 8000 the address will be http://localhost:8000/~/admin + - it will automatically open when you start HFS. Bookmark it. + If your port is 8000 the address will be http://localhost:8000/~/admin - passing via command line at start in the form `--NAME VALUE` -- using envs in the form `HFS_NAME` (eg: `HFS_PORT`) +- using envs in the form `HFS_`, like `HFS_PORT=80` if you want to change the config `port`, but same applies to any other config available, - directly editing the `config.yaml` file. As soon as you save it is reloaded and changes are applied - if you don't want to use an editor, consider typing this (example) command inside the folder where the config file is: `echo "port: 1080" >> config.yaml` - after HFS has started you can enter console command in the form `config NAME VALUE` +- setting special env `HFS_ENV_BOOTSTRAP=true` will disable other envs when file config.yaml already exists. `NAME` stands for the property name that you want to change. See the complete list below. @@ -43,7 +45,10 @@ Configuration can be done in several ways - `log` path of the log file. Default is `access.log`. - `log_rotation` frequency of log rotation. Accepted values are `daily`, `weekly`, `monthly`, or empty string to disable. Default is `weekly`. - `log_api` should api calls be logged? Default is `true`. -- `log_gui` should GUI files be logged? Default is `false`. +- `log_gui` should GUI files be logged? Default is `false`. +- `log_spam` log *failed* requests that are considered spam. Default is false. +- `log_ua` include user-agent in the logs. Default is false. +- `track_ips` keep track of all IP addresses seen. Default is true. - `error_log` path of the log file for errors. Default is `error.log`. - `errors_in_main_log` if you want to use a single file for both kind of entries. Default is false. - `dont_log_net` don't include in log entries if IP matches this network mask. Default is `127.0.0.1|::1`. @@ -83,15 +88,21 @@ Configuration can be done in several ways - `session_duration` after how many seconds should the login session expire. Default is a day. - `acme_domain` domain used for ACME certificate generation. Default is none. - `acme_email` email used for ACME certificate generation. Default is none. -- `force_base_url` disconnect any connection that's not using the domain used for ACME certificate generation. Default is none. - `acme_renew` automatically renew acme certificate close to expiration. Default is false. - `listen_interface` network interface to listen on, by specifying IP address. Default is any. - `base_url` URL to be used for links generation. Default is automatic. +- `force_address` disconnect any request not made with one of the hosts specified in `roots` or `base_url`. Default is false. - `ignore_proxies` stop warning about detected proxies. Default is false. - `descript_ion` enable reading and writing of comments in the old file format *DESCRIPT.ION*. Default is yes. - `descript_ion_encoding` text encoding to be used for file *DESCRIPT.ION*. [List of supported values](https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings). Default is `utf8`. - `server_code` javascript code that works similarly to [a plugin](dev-plugins.md). - `tiles_size` starting value for frontend's tiles size. Default is 0. +- `auto_play_seconds` starting value for frontend's auto_play_seconds (used in Show). Default is 5. +- `theme` starting value for theme. Default is "auto". +- `sort_by` starting value for sort-by. Values can be: name, extension, size, time. Default is "name". +- `sort_numerics` starting value for sort-numeric-names. Default is false. +- `folders_first` starting value for sort-folders-first. Default is true. +- `invert_order` starting value for invert-order. Default is false. - `update_to_beta` includes beta versions searching for updates. Default is false. - `roots` maps hosts (or mask of hosts) to a root different from the home folder. Default is none. E.g. ``` @@ -99,7 +110,6 @@ Configuration can be done in several ways music.domain.com: /music image.domain.com: /image ``` -- `roots_mandatory` disconnect any request not made with one of the hosts specified in `roots`. Default is false. - `max_downloads` limit the number of concurrent downloads on the whole server. Default is unlimited. - `max_downloads_per_ip` limit the number of concurrent downloads for the same IP address. Default is unlimited. - `max_downloads_per_account` limit the number of concurrent downloads for each account. This is enforced only for connections that are logged in, and will override other similar settings. Default is unlimited. @@ -107,7 +117,10 @@ Configuration can be done in several ways - `geo_allow` set true if `geo_list` should be treated as white-list, set false for black-list. Default will ignore the list. - `geo_list` list of country codes to be used as white-list or black-list. Default is empty. - `geo_allow_unknown` set false to disconnect connections for which country cannot be determined. Works only if `geo_allow` is set. Default is true. -- `dynamic_dns_url` URL to be requested to keep a domain updated with your latest IP address. Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used. +- `dynamic_dns_url` URL to be requested to keep a domain updated with your latest IP address. + Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used. + Multiple URLs are supported and you can specify one for each line. +- `auto_basic` automatically detect (based on user-agent) when the basic web inteface should be served, to support legacy browsers. Default true. - `create-admin` special entry to quickly create an admin account. The value will be set as password. As soon as the account is created, this entry is removed. #### Virtual File System (VFS) @@ -124,8 +137,10 @@ Valid keys in a node are: Value is a dictionary, where the key is the original name. - `mime`: specify what mime to use for this resource. Use "auto" for automatic detection. - `url`: when this value is present, the element is a link to the URL you specify. +- `target`: optional, for links only, used to [open the link in a new browser](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target). E.g. `_blank` - `accept`: valid only on upload folders, used to restrict the type of files you can upload. E.g. `.zip,.rar` - `default`: to be used with a folder where you want to serve a default html. E.g.: "index.html". Using this will make `mime` default to "auto". + The value must be an absolute or relative path in the VFS, not a path on disk. It works also with other type of files. - `can_read`: specify who can download this entry. Value is a `WhoCan` descriptor, which is one of these values - `true`: anyone can, even people who didn't log in. This is normally the default value. - `false`: no one can. @@ -193,6 +208,6 @@ For each account entries, this is the list of properties you can have: ### Specify another file -Do you need to load a different config file, even from a different folder? +Do you need to load a different config file that's not `config.yaml`? Use this parameter at command line `--config PATH` or similarly with an env `HFS_CONFIG`. The path you specify can be either a folder, or full-path to the file. diff --git a/dev-plugins.md b/dev-plugins.md index ac84fe2b..d9135a79 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -12,7 +12,7 @@ Normally you'll have a plug-in that's a theme, and another that's a firewall, but nothing is preventing a single plug-in from doing both tasks. ## Exported object -`plugin.js` is a javascript module, and its main way to communicate with HFS is by exporting things. +`plugin.js` is a javascript module (executed by Node.js), and its main way to communicate with HFS is by exporting things. For example, it can define its description like this ```js exports.description = "I'm a nice plugin" @@ -46,37 +46,41 @@ All the following properties are optional unless otherwise specified. - `description: string` try to explain what this plugin is for. (JSON syntax) - `version: number` use progressive numbers to distinguish each release - `apiRequired: number | [min:number,max:number]` declare version(s) for which the plugin is designed for. Mandatory. [Refer to API version history](#api-version-history) +- `isTheme: boolean | "light" | "dark"` set true if this is a theme that's not supposed to work together with other themes. + Running a theme will cause other themes to be stopped. Missing this, HFS will check if the name of the plugin ends with `-theme`. + Special values "light" and "dark" to declare the theme is (for example) dark and forces HFS to use dark-theme as a base. +- `preview: string | string[]` one or more URLs to images you want to show before your plugin is downloaded. (JSON syntax) - `depend: { repo: string, version: number }[]` declare what other plugins this depends on. (JSON syntax) - `repo: string | object` pointer to a GitHub repo where this plugin is hosted. (JSON syntax) - - the string form is for GitHub repos. Example: "rejetto/file-icons" - - the object form will point to other custom repo. Object properties: - - `web: string` link to a web page - - `main: string` link to the plugin.js (can be relative to `web`) - - `zip: string` link to the zip with the whole plugin (can be relative to `web`) - - `zipRoot: string` optional, in case the plugin in the zip is inside a folder - - Example: - ``` - { - "web": "https://github.com/rejetto/file-icons", - "zip": "/archive/refs/heads/main.zip", - "zipRoot: "file-icons-main/dist", - "main": "https://raw.githubusercontent.com/rejetto/file-icons/main/dist/plugin.js" - } - ``` - Note that in this example we are pointing to a github repo just for clarity. You are not supposed to use this - complicated object form to link github, use the string form. - Plugins with custom repos are not included in search results, but the update feature will still work. + - the string form is for GitHub repos. Example: "rejetto/file-icons" + - the object form will point to other custom repo. Object properties: + - `web: string` link to a web page + - `main: string` link to the plugin.js (can be relative to `web`) + - `zip: string` link to the zip with the whole plugin (can be relative to `web`) + - `zipRoot: string` optional, in case the plugin in the zip is inside a folder + + Example: + ``` + { + "web": "https://github.com/rejetto/file-icons", + "zip": "/archive/refs/heads/main.zip", + "zipRoot: "file-icons-main/dist", + "main": "https://raw.githubusercontent.com/rejetto/file-icons/main/dist/plugin.js" + } + ``` + Note that in this example we are pointing to a github repo just for clarity. You are not supposed to use this + complicated object form to link github, use the string form. + Plugins with custom repos are not included in search results, but the update feature will still work. WARNING: All the properties above are a bit special and must go in `exports` only (thus, not returned in `init`) and the syntax -used must be strictly JSON (thus, no single quotes, only double quotes for strings and objects). +used must be strictly JSON (thus, no single quotes, only double quotes for strings and objects), and must fit one line. - `init` described in the previous section. - `frontend_css: string | string[]` path to one or more css files that you want the frontend to load. These are to be placed in the `public` folder (refer below). You can also include external files, by entering a full URL. Multiple files can be specified as `['file1.css', 'file2.css']`. - `frontend_js: string | string[]` path to one or more js files that you want the frontend to load. These are to be placed in the `public` folder (refer below). You can also include external files, by entering a full URL. -- `middleware: (Context) => void | true | function` a function that will be used as a middleware: use this to interfere with http activity. +- `middleware: (Context) => Promisable` a function that will be used as a middleware: use this to interfere with http activity. ```js exports.middleware = ctx => { @@ -84,17 +88,18 @@ used must be strictly JSON (thus, no single quotes, only double quotes for strin ctx.status = 404 } ``` - You'll find more examples by studying plugins like `vhosting` or `antibrute`. - This API is based on [Koa](https://koajs.com), because that's what HFS is using. - To know what the Context object contains please refer to [Koa documentation](https://github.com/koajs/koa/blob/master/docs/api/context.md). - You don't get the `next` parameter as in standard Koa middlewares because this is different, but we are now explaining how to achieve the same results. - To interrupt other middlewares on this http request, return `true`. - If you want to execute something in the "upstream" of middlewares, return a function. + You'll find more examples by studying plugins like `antidos` or `antibrute`. + To interrupt other middlewares on this http request, call `ctx.stop()`. + In past versions stop() was not available, and to get the same effect you'd `return true`, therefore a possible way + to be compatible with older versions is to `return ctx.stop?.() || true`. + + If you want to execute something in the "upstream" of middlewares, return a function. This function can be async. + You can read more in [the ctx object](#the-ctx-object) section. - `unload: function` called when unloading a plugin. This is a good place for example to clearInterval(). -- `onDirEntry: ({ entry: DirEntry, listUri: string }) => Promisable` by providing this callback you can manipulate - the record that is sent to the frontend (`entry`), or you can return false to exclude this entry from the results. - Refer to source `frontend/src/stats.ts`. +- `onDirEntry: ({ entry: DirEntry, listUri: string, ctx, node: VfsNode }) => Promisable` + by providing this callback you can manipulate the record that is sent to the frontend (`entry`), + or you can return false to exclude this entry from the results. Refer to source `frontend/src/state.ts`. - `config: { [key]: FieldDescriptor }` declare a set of admin-configurable values owned by the plugin that will be displayed inside Admin-panel for change. Each property is identified by its key, and the descriptor is another object with options about the field. @@ -113,17 +118,18 @@ used must be strictly JSON (thus, no single quotes, only double quotes for strin When necessary your plugin will read its value using `api.getConfig('message')`. - `configDialog: DialogOptions` object to override dialog options. Please refer to sources for details. -- `onFrontendConfig: (config: object) => void | object` manipulate config values exposed to front-end +- `onFrontendConfig: (config: object) => void | object` manipulate config values exposed to front-end. +- `customHtml: object | () => object` return custom-html sections programmatically. ### FieldDescriptor Currently, these properties are supported: -- `type: 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'real_path' | 'array'` . Default is `string`. +- `type: 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'real_path' | 'array' | 'username'ì` . Default is `string`. - `label: string` what name to display next to the field. Default is based on `key`. - `defaultValue: any` value to be used when nothing is set. - `helperText: string` extra text printed next to the field. -- `frontend: boolean` expose this setting on the frontend, so that javascript can access it as - `HFS.getPluginConfig()[CONFIG_KEY]` but also css can access it as `var(--PLUGIN_NAME-CONFIG_KEY)` +- `frontend: boolean` expose this setting on the frontend, so that javascript can access it + using `HFS.getPluginConfig()[CONFIG_KEY]` but also css can access it as `var(--PLUGIN_NAME-CONFIG_KEY)` Based on `type`, other properties are supported: - `string` @@ -135,29 +141,31 @@ Based on `type`, other properties are supported: - `options: { [label]: AnyJsonValue }` - `multiselect` it's like `select` but its result is an array of values. - `array` list of objects - - `fields`: an object of `FieldDescriptor`s, i.e. same format as `config`. - This field will be use for both the configuration of the grid's column, and the form's field. - Other than properties of `FieldDescriptor` you get these extra properties: - - `$column`: where you can put all the properties you want specifically to be set on the [grid's column](https://mui.com/x/api/data-grid/grid-col-def/). - - `$width`: a shortcut property that can substitute `$column: { width }` or `$column: { flex }`. - By default, a column gets flex:1 unless you specify $width. A value of 8 and higher is considered width's pixels, - while lower are flex-values. + - `fields`: an object of `FieldDescriptor`s, i.e. same format as `config`. + This field will be use for both the configuration of the grid's column, and the form's field. + Other than properties of `FieldDescriptor` you get these extra properties: + - `$column`: where you can put all the properties you want specifically to be set on the [grid's column](https://mui.com/x/api/data-grid/grid-col-def/). + - `$width`: a shortcut property that can substitute `$column: { width }` or `$column: { flex }`. + By default, a column gets flex:1 unless you specify $width. A value of 8 and higher is considered width's pixels, + while lower are flex-values. - `real_path` path to server disk - - `files: boolean` allow to select a file. Default is `true`. - - `folders: boolean` allow to select a folder. Default is `false`. - - `defaultPath: string` what path to start from if no value is set. E.g. __dirname if you want to start with your plugin's folder. - - `fileMask: string` restrict files that are displayed. E.g. `*.jpg|*.png` + - `files: boolean` allow to select a file. Default is `true`. + - `folders: boolean` allow to select a folder. Default is `false`. + - `defaultPath: string` what path to start from if no value is set. E.g. __dirname if you want to start with your plugin's folder. + - `fileMask: string` restrict files that are displayed. E.g. `*.jpg|*.png` +- `username` ## api object The `api` object you get as parameter of the `init` contains the following: -- `getConfig(key: string): any` get config's value set up by using `exports.config`. - -- `setConfig(key: string, value: any)` set config's value set up by using `exports.config`. +- `getConfig(key?: string): any` get plugin's config value, described in `exports.config`. + If key is not provided, an object with all keys is returned. + +- `setConfig(key: string, value: any)` set plugin's config value. - `subscribeConfig(key: string, callback: (value: any) => void): Unsubscriber` - will call `callback` with initial value and then at each change. + will call `callback` with initial value and then at each change. - `getHfsConfig(key: string): any` similar to getConfig, but retrieves HFS' config instead. @@ -165,14 +173,25 @@ The `api` object you get as parameter of the `init` contains the following: - `Const: object` all constants of the `const.ts` file are exposed here. E.g. BUILD_TIMESTAMP, API_VERSION, etc. -- `getConnections: Connections[]` retrieve current list of active connections. +- `getConnections(): Connections[]` retrieve current list of active connections. - `storageDir: string` folder where a plugin is supposed to store run-time data. This folder is preserved during - an update of the plugin, while the rest could be deleted. + an update of the plugin, while the rest could be deleted. + +- `events` this is the main events emitter used by HFS. + These are backend side events, not to be confused with frontend ones. It's not the standard EventEmitter class, + and the API is slightly different. + + - `events.on(name: string, listener: Callback): Callback` + + call your listener every time the event is emitted. + The returned callback will unsubscribe the event. -- `events: EventEmitter` this is the main events emitter used by HFS. + - `events.once(name: string, listener?: Callback): Promise` -- `require: function` use this instead of standard `require` function to access modules already loaded by HFS. Example: + when the event is emitted, your (optional) listener is called, and the returned promise is resolved. + +- `require(module: string)` use this instead of standard `require` function to access modules already loaded by HFS. Example: ```js const { watchLoad } = api.require('./watchLoad') ``` @@ -181,8 +200,14 @@ The `api` object you get as parameter of the `init` contains the following: If you need something for your plugin that's not covered by `api`, you can test it with this method, but you should then discuss it on the forum because an addition to `api` is your best option for making a future-proof plugin. -- `customApiCall: (method: string, ...params) => any[]` this will invoke other plugins if they define `method` - exported inside `customApi: object` +- `customApiCall(method: string, ...params): any[]` this will invoke other plugins if they define `method` + exported inside `customApi: object` + +- `openDb(filename, options): Promise<{ get, put, del, close, unlink, sublevel }>` LevelDB-like class for storage. + The specified file name will be stored in the "storage" folder of the plugin, by default. + DB is automatically closed when the plugin is unloaded. Refer to [dedicated documentation](https://www.npmjs.com/package/@rejetto/kvstorage) for details. + +- `notifyClient(channel: string, eventName: string, data?: any)` send a message to those frontends that are on the same channel. ## Front-end specific @@ -196,26 +221,39 @@ The HFS objects contains many properties: - `useApi` - `reloadList` - `logout` -- `prefixUrl: string` normally an empty string, it will be set in case a [reverse-proxy wants to mount HFS on a path](https://github.com/rejetto/hfs/wiki/Reverse-proxy). -- `state` [object with many values in it](https://github.com/rejetto/hfs/blob/main/frontend/src/state.ts) -- `watchState: (key: string, callback)=>function` - - watch the `key` property of the state object above - - `callback(newValue)` will be called at each change - - use returned callback to stop watching +- `prefixUrl: string` normally an empty string, it will be set in case a [reverse-proxy wants to mount HFS on a path](https://github.com/rejetto/hfs/wiki/Reverse-proxy). +- `state: StateObject` [object with many values in it](https://github.com/rejetto/hfs/blob/main/frontend/src/state.ts) + - you'll find here some interesting values, like `username` and `loading`. +- `watchState(key: string, callback): function` + - watch the `key` property of the state object above + - `callback(newValue)` will be called at each change + - use returned callback to stop watching +- `useSnapState(): StateObject` React hook version of the `state` object above - `React` whole React object, as for `require('react')` (JSX syntax is not supported here) - `h` shortcut for React.createElement - `t` [translator function](https://github.com/rejetto/hfs/blob/main/frontend/src/i18n.ts) - `_` [lodash library](https://lodash.com/docs/) -- `toast: (message: string | ReactElement, type: ToastType='info')` - - show a brief message that doesn't steal focus - - `ToastType = 'error' | 'warning' | 'info' | 'success'` -- `dialogLib` this exposes all functions available in [dialog.ts](https://github.com/rejetto/hfs/blob/main/frontend/src/dialog.ts), for example alertDialog and newDialog. These are not documented yet, and subject to change without notification, but you can study the sources if you are interested in using them. +- `toast(message: string | ReactElement, type: ToastType='info')` + - show a brief message that doesn't steal focus + - `ToastType = 'error' | 'warning' | 'info' | 'success'` +- `dialogLib` this exposes all functions available in [dialog.ts](https://github.com/rejetto/hfs/blob/main/frontend/src/dialog.ts), + for example alertDialog and newDialog. These are not documented yet, and subject to change without notification, + but you can study the sources if you are interested in using them. - `misc` many functions and constants available in [cross.ts](https://github.com/rejetto/hfs/blob/main/src/cross.ts). These are not documented, probably never will, and are subject to change without notifications, but you can study the sources if you are interested in using them. -- `navigate: (uri: string): void` use this if you have to change the page address without causing reload -- `emit: (name: string, params?: object) => any[]` use this to emit a custom event. Prefix name with your plugin name to avoid conflicts. +- `navigate(uri: string)` use this if you have to change the page address without causing reload +- `emit(name: string, params?: object): any[]` use this to emit a custom event. Prefix name with your plugin name to avoid conflicts. - `Icon: ReactComponent` Properties: - - `name: string` refer to file `icons.ts` for names, but you can also enter an emoji instead. -- `useBatch: (worker, job) => any` + - `name: string` refer to file `icons.ts` for names, but you can also enter an emoji instead. +- `iconBtn(icon: string, onClick: function, props?: any)` render a React Icon Button. For icons, refer to `Icon` component. +- `domOn(eventName: string, cb: function, { target }?): function` convenient alternative to addEventListener/removeEventListener. + The default target is window. Returns a callback to remove the listener. +- `useBatch(worker, job): any` +- `getNotifications(channel: string, cb: (eventName: string, data:any) => void)` + receive messages when the backend uses `notifyClient` on the same channel. +- `html(html: string): ReactNode` convert html code to React +- `debounceAsync: function` like lodash.debounce, but also avoids async invocations to overlap. + For details please refer to `src/debounceAsync.ts`. +- `loadScript(uri: string): Promise` load a js file. If uri is relative, it is based on the plugin's public folder. The following properties are accessible only immediately at top-level; don't call it later in a callback. - `getPluginConfig()` returns object of all config keys that are declared frontend-accessible by this plugin. @@ -239,7 +277,12 @@ Some frontend-events can return Html, which can be expressed in several ways - as string, containing markup - as DOM Nodes, as for document.createElement() - as ReactElement -- null, undefined, false and empty-string will just be discarded +- as array of ReactNode +- null, undefined, false and empty-string will just be discarded + +These events will receive a `def` property, with the default content that will be displayed if no callback return +a valid output. You can decide to embed such default content inside your content. +You can produce output for such events also by adding sections (with same name as the event) to file `custom.html`. This is a list of available frontend-events, with respective object parameter and output. @@ -264,9 +307,9 @@ This is a list of available frontend-events, with respective object parameter an - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry - output `Html` - `entry` - - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot. - - parameter `{ entry: Entry }` (refer above for Entry object) - - output `Html` + - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot. + - parameter `{ entry: Entry }` (refer above for Entry object) + - output `Html | null` return null if you want to hide this entry - `afterEntryName` - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry. - parameter `{ entry: Entry }` (refer above for Entry object) @@ -283,7 +326,7 @@ This is a list of available frontend-events, with respective object parameter an - output `Html` - `fileMenu` - add or manipulate entries of the menu. If you return something, that will be added to the menu. - You can also delete or replace the content of the `menu` array. + You can also delete or replace the content of the `menu` array. - parameter `{ entry: Entry, menu: FileMenuEntry[], props: FileMenuProp[] }` - output `undefined | FileMenuEntry | FileMenuEntry[]` ```typescript @@ -296,7 +339,7 @@ This is a list of available frontend-events, with respective object parameter an onClick?: () => (Promisable) // return false to not close menu dialog //...rest is transfered to element, for example 'target', or 'title' } - type FileMenuProp = [ReactNode,ReactNode] | ReactElement + type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement ``` Example, if you want to remove the 'show' item of the menu: ```typescript @@ -308,15 +351,156 @@ This is a list of available frontend-events, with respective object parameter an ``` or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })` - `fileShow` - - you receive an entry of the list, and optionally produce React Component for visualization. - - parameter `{ entry: Entry }` (refer above for Entry object) - - output `ReactComponent` + - you receive an entry of the list, and optionally produce React Component for visualization. + - parameter `{ entry: Entry }` (refer above for Entry object) + - output `ReactComponent` +- `menuZip` + - parameter `{ def: ReactNode }` + - output `Html` - `userPanelAfterInfo` - - no parameter - - output `Html` + - no parameter + - output `Html` - `uriChanged` - - DEPRECATED: use `watchState('uri', callback)` instead. - - parameter `{ uri: string, previous: string }` + - DEPRECATED: use `watchState('uri', callback)` instead. + - parameter `{ uri: string, previous: string }` +- All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place: + - `afterMenuBar` between menu-bar and breadcrumbs + - `afterList` at the end of the files list + - `footer` at the bottom of the screen, even after the clipboard-bar (when visible) + - `unauthorized` displayed behind the login dialog accessing a protected folder + - `userPanelAfterInfo` visible to logged-in users, after the click on the button with their username, between user-info and buttons + +## Back-end events + +These events happen in the server, and not in the browser. +You can listen to these events accessing `api.events` in the `init` function of the plugin. +E.g.: +```js +exports.init = function(api) { + const cancelListening = api.events.on('spam', () => 'spam received!') + // pass the canceller callback to the 'unload', so the subscription will be correctly disposed when the plugin is stopped + return { unload: cancelListening } +} +``` + +Of course the example above can be written more shortly as follows, but they are equivalent. + +```js +exports.init = api => ({ + unload: api.events.on('spam', () => 'spam received!') +}) +``` + +### Async + +Only where specified, events support async listeners, like +```js +api.events.on('deleting', async () => your-code-here) +``` + +### Stop, the way you prevent default behavior + +Some events allow you to stop their default behavior, by returning `api.events.stop`. +This is reported in the list below with the word "stoppable". + +```js +api.events.on('deleting', ({ node }) => node.source.endsWith('.jpg')) +``` + +The example above will return false only when the file is NOT ending with .jpg, thus allowing only jpg files to be deleted. + +### Available events + +This section is still partially documented, and you may need to have a look at the sources for further details. + +- `deleting` + - parameters: { node, ctx } + - called just before trying to delete a file or folder (which still may not exist and fail) + - async supported + - stoppable +- `logout` +- `config ready` +- `config.KEY` where KEY is the key of a config that has changed +- `connectionClosed` +- `connection` +- `connectionUpdated` +- `console` +- `dynamicDnsError` +- `httpsReady` +- `spam` +- `log` +- `error_log` +- `failedLogin` +- `accountRenamed` +- `pluginDownload` +- `pluginUpdated` +- `pluginInstalled` +- `pluginUninstalled` +- `pluginStopped` +- `pluginStarted` +- `uploadStart` + - parameters: { ctx, writeStream } + - stoppable + - return: callback to call when upload is finished +- `uploadFinished` +- `publicIpsChanged` + - parameters: { IPs, IP4, IP6, IPX } + +# Notifications (backend-to-frontend events) + +You can send messages from the backend (plugin.js) using `api.notifyClient`, and receive on the frontend +using `HFS.getNotifications`. Find details in the reference above. + +Example: + +`plugin.js` +```js +exports.init = api => { + const t = setInterval(() => api.notifyClient('test', 'message', 'hello'), 5000) + return { + frontend_js: 'main.js', + unload() { + clearInterval(t) + } + } +} +``` +`public/main.js` +```js +HFS.getNotifications('test', console.log) +``` + +# The `ctx` object + +HFS is currently based on [Koa](https://koajs.com), so you'll see some things related to it in the backend API. +The most prominent is the `ctx` object, short for "context". +To know what the Context object contains please refer to [Koa documentation](https://github.com/koajs/koa/blob/master/docs/api/context.md). + +HFS adds a few useful properties in the `ctx.state` object. Some of it may turn to be useful, +so we prepared this list as a quick reference, but beware that it may become out of date and needs double check. +If so, please report, and we'll do our best to update it asap. +Where information is too little, you'll have to consult the source code, sorry. + + originalPath: string // before roots is applied + browsing?: string // for admin/monitoring + dontLog?: boolean // don't log this request + logExtra?: object + completed?: Promise + spam?: boolean // this request was marked as spam + params: Record + account?: Account // user logged in + revProxyPath: string + connection: Connection + skipFilters?: boolean + vfsNode?: VfsNode + includesLastByte?: boolean + serveApp?: boolean // please, serve the frontend app + uploadPath?: string // current one + uploads?: string[] // in case of request with potentially multiple uploads (POST), we register all filenames (no full path) + length?: number + originalStream?: typeof ctx.body + uploadDestinationPath?: string // this value is the temporary file in uploadStart and the final one in uploadFinished + archive?: string ## Other files @@ -327,7 +511,15 @@ These files have a special meaning: - `public` folder, and its files will be accessible at `/~/plugins/PLUGIN_NAME/FILENAME` - `custom.html` file, that works exactly like the main `custom.html`. Even when same section is specified - by 2 (or more) files, both contents are appended. + by 2 (or more) files, both contents are appended. + +## Storage + +Plugins that need to store generated data persistently should put all the files in the "storage" folder that is +automatically created for each plugin. In your plugin you can get this path by reading `api.storageDir`. + +There is a very powerful way to store data, that is by using `api.openDb`. This will automatically create the file +inside the storage folder. For further details please refer to the dedicated documentation. ## Dependencies @@ -346,6 +538,13 @@ Be sure to also fill the "description" field, especially with words that people The files intended to be installed must go in a folder named `dist`. You can keep other files outside. +Hint: if you go in your .hfs/plugins folder on linux and mac, and enter + + ln -s /PATH_TO_YOUR_REPO/dist MY_PLUGIN_NAME + +you'll install your repo, so that you can edit the sources and see effects in real-time, and still be editing your repo, +ready to commit. + If you have platform-dependent files, you can put those files in `dist-PLATFORM` or `dist-PLATFORM-ARCHITECTURE`. For example, if you want some files to be installed only on Windows with Intel CPUs, put them in `dist-win32-x64`. @@ -369,7 +568,7 @@ Most React developers are used to JSX, which is not (currently) supported here. If you want, you can try solutions to JSX support, like transpiling. Anyway, React is not JSX, and can be easily used without. -Any time in JSX you do +Any time in JSX you do ```jsx ``` @@ -395,67 +594,99 @@ is not used by another plugin, or even HFS in the future. If you need to pass variables in the text, introduce a third parameter in the middle. Eg: `HFS.t('myPlugin_filter_count', {n:filteredVariable}, "{n} filtered")` +### Language customization + +One can change a specific text by overriding existing translation. Example: you want to change the text for "Options" to "Settings". +If you want to override for a specific language, for example english with language-code `en`: + +```js +HFS._.set(HFS.lang, 'en.translate.Options', 'Settings') +``` + +This works because all translations are stored inside `HFS.lang`. +Using `HFS._.set` is not necessary, but in this case is convenient, because the language-code key may not exist. + +If you want to override a text regardless of the language, use the special language-code `all`. + ## API version history +- 8.891 (v0.53.0) + - api.openDb + - frontend event: menuZip + - config.type:username + - api.events class has changed + - frontend event "fileMenu": changed props format + - api.getConfig() without parameters + - api.notifyClient + HFS.getNotifications + - HFS.html + - HFS.useSnapState + - HFS.debounceAsync + - HFS.loadScript + - HFS.iconBtn + - middleware: ctx.stop() + - the old way of returning true is now deprecated + - exports.customHtml + - more functions in HFS.misc + - frontend event 'entry' can now ask to skip an entry - 8.72 (v0.52.0) - - HFS.toast - - HFS.misc functions - - HFS.state.uri - - ~~new event: uriChanged~~ + - HFS.toast + - HFS.misc functions + - HFS.state.uri + - ~~frontend event: uriChanged~~ - 8.65 (v0.51.0) - - plugin's own hfs-lang files - - HFS.state.props.can_overwrite - - ctx.state.considerAsGui - - new event: userPanelAfterInfo - - breaking: moved custom properties from ctx to ctx.state - - HFS.navigate - - internationalization + - plugin's own hfs-lang files + - HFS.state.props.can_overwrite + - ctx.state.considerAsGui + - frontend event: userPanelAfterInfo + - breaking: moved custom properties from ctx to ctx.state + - HFS.navigate + - internationalization - 8.5 (v0.49.0) - - new event: entry - - exports.onDirEntry: entry.icon - - customApiCall supports any number of parameters + - frontend event: entry + - exports.onDirEntry: entry.icon + - customApiCall supports any number of parameters - 8.4 (v0.48.2) - - HFS.fileShow - - api.Const (api.const is now deprecated) + - HFS.fileShow + - api.Const (api.const is now deprecated) - 8.3 (v0.47.0) - - HFS.useBatch - - FileMenuEntry.id, .subLabel + - HFS.useBatch + - FileMenuEntry.id, .subLabel - 8.23 (v0.46.0) - - entry.getNext, getPrevious, getNextFiltered, getPreviousFiltered, getDefaultIcon - - platform-dependent distribution - - HFS.watchState, emit, useApi - - api.storageDir, customApiCall - - exports.depend - - new event: fileShow + - entry.getNext, getPrevious, getNextFiltered, getPreviousFiltered, getDefaultIcon + - platform-dependent distribution + - HFS.watchState, emit, useApi + - api.storageDir, customApiCall + - exports.depend + - frontend event: fileShow - 8.1 (v0.45.0) should have been 0.44.0 but forgot to update number - - full URL support for frontend_js and frontend_css - - custom.html - - entry.cantOpen, ext, isFolder - - HFS.apiCall, reloadList, logout, h, React, state, t, _, dialogLib, Icon, getPluginPublic - - second parameter of onEvent is now deprecated - - renamed: additionalEntryProps > additionalEntryDetails & entry-props > entry-details - - new event: entryIcon + - full URL support for frontend_js and frontend_css + - custom.html + - entry.cantOpen, ext, isFolder + - HFS.apiCall, reloadList, logout, h, React, state, t, _, dialogLib, Icon, getPluginPublic + - second parameter of onEvent is now deprecated + - renamed: additionalEntryProps > additionalEntryDetails & entry-props > entry-details + - frontend event: entryIcon - 8 (v0.43.0) - - entry.name & .uri - - tools.dialogLib - - HFS.getPluginConfig() + - entry.name & .uri + - tools.dialogLib + - HFS.getPluginConfig() - 7 (v0.42.0) - - new event: fileMenu - - HFS.SPECIAL_URI, PLUGINS_PUB_URI, FRONTEND_URI, + - frontend event: fileMenu + - HFS.SPECIAL_URI, PLUGINS_PUB_URI, FRONTEND_URI, - 6 (v0.38.0) - - config.frontend + - config.frontend - 5 (v0.33.0) - - new event: afterEntryName + - frontend event: afterEntryName - 4.1 (v0.23.4) - - config.type:array added $width, $column and fixed height + - config.type:array added $width, $column and fixed height - 4 (v0.23.0) - - config.type:real_path - - api.subscribeConfig - - api.setConfig - - api.getHfsConfig + - config.type:real_path + - api.subscribeConfig + - api.setConfig + - api.getHfsConfig - 3 (v0.21.0) - - config.defaultValue - - async for init/unload - - api.log + - config.defaultValue + - async for init/unload + - api.log - 2 - - config.type:array + - config.type:array diff --git a/dev.md b/dev.md index ff5c33f7..622a698c 100644 --- a/dev.md +++ b/dev.md @@ -8,7 +8,12 @@ This file is mostly aimed to developers. At this stage you have a javascript output. For binary files you'll also have to `npm run dist-bin`. -You could instead run *dist* to run both *build-all* & *dist-bin*. +You could instead run *dist* to run both *build-all* & *dist-bin*. + +## Troubleshooting + +- If you get error "Rollup failed to resolve import "@mui/icons-material/..." + - edit `admin/src/vite.config.ts` and remove the `vitePluginImport` part # Dev environment diff --git a/frontend/index.html b/frontend/index.html index 6e515ff5..73c7cd02 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,8 +7,6 @@ - -
- +
diff --git a/frontend/package.json b/frontend/package.json index fbc69ae8..c65adb09 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,6 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.1.1", - "talkr": "^3.3.9", "tssrp6a": "^3.0.0", "usehooks-ts": "^2.6.0", "valtio": "^1.13.0", @@ -23,6 +22,7 @@ "@types/lodash": "^4.14.178", "@types/react": "^18.0.15", "@types/react-dom": "^18.0.6", + "@vitejs/plugin-legacy": "^5.3.2", "cross-env": "^7.0.3", "sass": "^1.54.5", "vite": "*" diff --git a/frontend/src/App.ts b/frontend/src/App.ts index 042fd785..d393eae9 100644 --- a/frontend/src/App.ts +++ b/frontend/src/App.ts @@ -9,7 +9,7 @@ import { useSnapState } from './state' import { I18Nprovider } from './i18n' import { proxy, useSnapshot } from "valtio" import { Spinner } from "./components" -import { enforceStarting, getHFS, getPrefixUrl } from '@hfs/shared' +import { enforceStarting, getHFS, getPrefixUrl, loadScript } from '@hfs/shared' import { Toasts } from './toasts' function App() { @@ -46,3 +46,8 @@ const pageState = proxy({ ready: document.readyState === 'complete' }) document.addEventListener('readystatechange', () => { pageState.ready = document.readyState === 'complete' }) + +// load plugins' now, as vite-legacy delayed app's loading +for (const [plugin, files] of Object.entries(getHFS().loadScripts)) + if (Array.isArray(files)) for (const f of files) + loadScript(f, { plugin }) \ No newline at end of file diff --git a/frontend/src/BrowseFiles.ts b/frontend/src/BrowseFiles.ts index d1d05286..d4c806b2 100644 --- a/frontend/src/BrowseFiles.ts +++ b/frontend/src/BrowseFiles.ts @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom' import { createElement as h, Fragment, memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState, useId} from 'react' import { useMediaQuery, useWindowSize } from 'usehooks-ts' -import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac } from './misc' +import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, getHFS } from './misc' import { Checkbox, CustomCode, iconBtn, Spinner } from './components' import { Head } from './Head' import { DirEntry, state, useSnapState } from './state' @@ -14,9 +14,9 @@ import { useAuthorized } from './login' import { acceptDropFiles, enqueue } from './upload' import _ from 'lodash' import { t, useI18N } from './i18n' -import { openFileMenu } from './fileMenu' +import { makeOnClickOpen, openFileMenu } from './fileMenu' import { ClipBar } from './clip' -import { fileShow } from './show' +import { fileShow, getShowType } from './show' export const MISSING_PERM = "Missing permission" @@ -26,8 +26,8 @@ export function BrowseFiles() { const { props, tile_size=0 } = useSnapState() const propsDropFiles = useMemo(() => ({ id: 'files-dropper', - ...acceptDropFiles(files => - props?.can_upload ? enqueue(files.map(file => ({ file }))) + ...acceptDropFiles((files, to) => + props?.can_upload ? enqueue(files.map(file => ({ file })), location.pathname + to) : alertDialog(t("Upload not available"), 'warning') ), }), [props]) @@ -44,7 +44,9 @@ export function BrowseFiles() { props?.comment && h('div', { className: 'entry-comment' }, props.comment), error ? h(ErrorMsg, { err: error }) : h(FilesList), h(CustomCode, { name: 'afterList' }), + h('div', { style: { flex: 1 }}), h(ClipBar), + h(CustomCode, { name: 'footer' }), ) ) } @@ -190,57 +192,55 @@ const PAGE_SEPARATOR_CLASS = 'page-separator' interface EntryProps { entry: DirEntry, midnight: Date, separator?: string } const Entry = ({ entry, midnight, separator }: EntryProps) => { - const { uri, isFolder, name } = entry + const { uri, isFolder, name, n } = entry const { showFilter, selected, file_menu_on_link } = useSnapState() const isLink = Boolean(entry.url) - const containerDir = isFolder || isLink ? '' : uri.substring(0, (uri.lastIndexOf('/') || -1) +1) - const containerName = containerDir && entry.n.slice(0, -name.length) + const containerName = n.slice(0, -name.length) let className = isFolder ? 'folder' : 'file' if (entry.cantOpen) className += ' cant-open' if (separator) className += ' ' + PAGE_SEPARATOR_CLASS const ico = getEntryIcon(entry) - const onClick = !isLink && !entry.web && file_menu_on_link && fileMenu || undefined + const onClick = !isFolder && !isLink && !entry.web && file_menu_on_link && fileMenu || makeOnClickOpen(entry) const hasHover = useMediaQuery('(hover: hover)') const showingButton = !file_menu_on_link || isFolder && !hasHover const ariaId = useId() const ariaProps = { id: ariaId, 'aria-label': prefix(name + ', ', isFolder ? t`Folder` : entry.web ? t`Web page` : isLink ? t`Link` : '') } - return h('li', { className, label: separator }, - h(CustomCode, { name: 'entry', entry }, - showFilter && h(Checkbox, { - disabled: isLink, - 'aria-labelledby': ariaId, - value: selected[uri], - onChange(v) { - if (v) - return state.selected[uri] = true - delete state.selected[uri] - }, - }), - h('span', { className: 'link-wrapper' }, // container to handle mouse over for both children - ...isFolder || entry.web ? [ // internal navigation, use Link component - h(Link, { to: uri, reloadDocument: entry.web, ...ariaProps }, // without reloadDocument, once you enter the web page, the back button won't bring you back to the frontend - ico, entry.n.slice(0, -1)), // don't use name, as we want to include whole path in case of search - // popup button is here to be able to detect link-wrapper:hover - file_menu_on_link && !showingButton && h('button', { - className: 'popup-menu-button', - onClick: fileMenu - }, hIcon('menu'), t`Menu`) - ] : containerName ? [ - h('a', { href: uri, onClick, tabIndex: -1, 'aria-hidden': true }, ico), - h(Link, { to: containerDir, className: 'container-folder', tabIndex: -1 }, containerName), - h('a', { href: uri, onClick, ...ariaProps }, name) - ] : [h('a', { href: uri, onClick, ...ariaProps }, ico, name)], - ), - h(CustomCode, { name: 'afterEntryName', entry }), - entry.comment && h('div', { className: 'entry-comment' }, entry.comment), - h('div', { className: 'entry-panel' }, - h(EntryDetails, { entry, midnight }), - showingButton && iconBtn('menu', fileMenu, { className: 'file-menu-button' }), - ), - h('div'), + return h(CustomCode, { + name: 'entry', + entry, + render: x => x ? h('li', { className, label: separator }, x) : _.remove(state.list, { n }) && null + }, showFilter && h(Checkbox, { + disabled: isLink, + 'aria-labelledby': ariaId, + value: selected[uri], + onChange(v) { + if (v) + return state.selected[uri] = true + delete state.selected[uri] + }, + }), + h('span', { className: 'link-wrapper' }, // container to handle mouse over for both children + // we treat webpages as folders, with menu to comment + isFolder ? h(Fragment, {}, // internal navigation, use Link component + h(Link, { to: uri, reloadDocument: entry.web, onClick, ...ariaProps }, // without reloadDocument, once you enter the web page, the back button won't bring you back to the frontend + ico, entry.n.slice(0, -1)), // don't use name, as we want to include whole path in case of search + // popup button is here to be able to detect link-wrapper:hover + file_menu_on_link && !showingButton && h('button', { + className: 'popup-menu-button', + onClick: fileMenu + }, hIcon('menu'), t`Menu`) + ) : h('a', { href: uri, onClick, target: entry.target, ...ariaProps }, + ico, h('span', { className: 'container-folder' }, containerName), name ), ), + h(CustomCode, { name: 'afterEntryName', entry }), + entry.comment && h('div', { className: 'entry-comment' }, entry.comment), + h('div', { className: 'entry-panel' }, + h(EntryDetails, { entry, midnight }), + showingButton && iconBtn('menu', fileMenu, { className: 'file-menu-button' }), + ), + h('div'), ) function fileMenu(ev: MouseEvent) { @@ -248,7 +248,7 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => { if (ev.altKey || ev.ctrlKey || isMac && ev.metaKey) return ev.preventDefault() const special = isMac ? ev.shiftKey : ev.metaKey - if (special) + if (special && getShowType(entry)) return fileShow(entry, { startPlaying: true }) openFileMenu(entry, ev, onlyTruthy([ file_menu_on_link && 'open', @@ -271,7 +271,7 @@ export const EntryDetails = memo(({ entry, midnight }: { entry: DirEntry, midnig const dd = '2-digit' return h('div', { className: 'entry-details' }, h(CustomCode, { name: 'additionalEntryDetails', entry }), - entry.p?.match(entry.isFolder ? /l/i : /r/i) && hIcon('password', { className: 'miss-perm', title: t(MISSING_PERM) }), + entry.cantOpen && hIcon(entry.cantOpen === DirEntry.FORBIDDEN ? 'lock' : 'password', { className: 'miss-perm', title: t(MISSING_PERM) }), h(EntrySize, { s }), time && h('span', { className: 'entry-ts', diff --git a/frontend/src/UserPanel.ts b/frontend/src/UserPanel.ts index 1c76cae1..7a4ffe85 100644 --- a/frontend/src/UserPanel.ts +++ b/frontend/src/UserPanel.ts @@ -7,7 +7,7 @@ import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a' import { apiCall } from '@hfs/shared/api' import { logout } from './login' import { Btn, CustomCode } from './components' -import { formatTimestamp, hIcon, working } from './misc' +import { formatTimestamp, hIcon, fallbackToBasicAuth, working } from './misc' import { t } from './i18n' export default function showUserPanel() { @@ -21,7 +21,7 @@ export default function showUserPanel() { h('div', {}, t`Username`, ': ', snap.username), snap.accountExp && h('div', {}, t`Account expiration`, ': ', formatTimestamp(snap.accountExp)), h(CustomCode, { name: 'userPanelAfterInfo' }), - snap.canChangePassword && h(Btn, { + snap.canChangePassword && !fallbackToBasicAuth() && h(Btn, { icon: 'password', label: t`Change password`, id: 'change-password', @@ -49,6 +49,8 @@ export default function showUserPanel() { label: t`Logout`, id: 'logout', onClick() { + if (fallbackToBasicAuth()) // this is effective on ff52, but not on chrome125 + return location.href = `//LOGOUT%00:@${location.host}/?get=logout` // redirect, to execute the body content logout().then(closeDialog, alertDialog) } }) diff --git a/frontend/src/clip.ts b/frontend/src/clip.ts index 1fe059cc..56213954 100644 --- a/frontend/src/clip.ts +++ b/frontend/src/clip.ts @@ -18,11 +18,13 @@ export function ClipBar() { return null const there = dirname(clip[0].uri) + '/' return h('div', { id: 'clipBar' }, - h(Btn, { label: t`Cancel clipboard`, icon: 'close', onClick: cancel }), - h(Btn, { label: t('to_clipboard_source', "Back to source folder"), icon: 'parent', onClick: goBack, disabled: here === there }), + h(Btn, { label: t('clipboard', { content: t('n_items', { n: clip.length }, "{n,plural, one{# item} other{# items}}"), }, `Clipboard ({content})`), + onClick: show, style: { flex: 1 } }), h(Btn, { label: t`Paste`, icon: 'paste', onClick: paste, disabled: here === there || !props?.can_upload }), - h('div', { onClick: show, style: { flex: 1 } }, - clip.length === 1 ? clip[0].name : t('n_items', { n: clip.length }, "{n,plural, one{# item} other{# items}}") ), + h(Btn, { label: t`Cancel clipboard`, icon: 'close', onClick: cancel }), + h(Btn, { label: t('to_clipboard_source', "Back to source folder"), icon: 'parent', onClick: goBack, disabled: here === there, + tooltip: t('to_clipboard_source_tooltip', "Go to the folder where the clipboard contents are located"), + }), ) function cancel() { @@ -34,7 +36,7 @@ export function ClipBar() { } function show() { - alertDialog(h('div', {}, + alertDialog(h('div', { id: 'clipboard-content' }, t('clipboard_list', "Items in clipboard:"), clip.map(x => h('li', {}, x.name)), )) diff --git a/frontend/src/components.ts b/frontend/src/components.ts index 894d64db..c7126fd5 100644 --- a/frontend/src/components.ts +++ b/frontend/src/components.ts @@ -1,6 +1,6 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix } from './misc' +import { Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix } from './misc' import { ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment, HTMLAttributes, InputHTMLAttributes, isValidElement, MouseEventHandler, ReactNode, SelectHTMLAttributes, useMemo, useState, ComponentPropsWithoutRef } from 'react' @@ -66,20 +66,30 @@ export function Select({ onChange, value, options, ...props }: }, options.map(({ value, label }) => h('option', { key: value, value }, label))) } -export function CustomCode({ name, children, ...props }: { name: string, children?: ReactNode } & any) { +export function CustomCode({ name, children, render, ...props }: { + name: string, + children?: ReactNode, + render?: Callback + [k :string]: any, +}) { const result = useMemo(() => { props.def = children // not using 'default' because user can have unexpected error destructuring object + let keep = true const ret = onlyTruthy(hfsEvent(name, props) .map((x, key) => isValidElement(x) ? h(Fragment, { key }, x) - : x === 0 || x && isPrimitive(x) ? h(Html, { key, code: String(x) }) + : x === 0 || x && isPrimitive(x) ? h(Html, { key }, String(x)) : _.isArray(x) ? h(Fragment, { key }, ...x) - : null)) + : x === null ? keep = false + : null)) + if (!keep) + return null const html = getHFS().customHtml?.[name] if (html?.trim?.()) - ret.push(h(Html, { key: 'x', code: html })) + ret.push(h(Html, { key: 'x' }, html)) return ret }, [name, children, ...props ? Object.values(props) : []]) - return result.length || !children ? h(Fragment, {}, result) : children + render ??= _.identity + return h(Fragment, {}, render(result && (result.length || !children ? result : children)) ) } interface IconBtnOptions extends ButtonHTMLAttributes { style?: any, title?: string } @@ -100,21 +110,25 @@ export interface BtnProps extends ComponentPropsWithoutRef<"button"> { className?: string, onClick?: () => unknown onClickAnimation?: boolean + asText?: boolean } -export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, ...rest }: BtnProps) { +export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, asText, ...rest }: BtnProps) { const [working, setWorking] = useState(false) - return h('button', { + return h(asText ? 'a' : 'button', { title: label + prefix(' - ', tooltip), 'aria-label': label, 'aria-pressed': toggled, - onClick() { + onClick(ev) { + if (asText) + ev.preventDefault() if (!onClick) return if (onClickAnimation !== false) setWorking(true) Promise.resolve(onClick()).finally(() => setWorking(false)) }, ...rest, + ...asText ? { role: 'button', style: { cursor: 'pointer', ...rest.style } } : undefined, className: [rest.className, toggled && 'toggled', working && 'ani-working'].filter(Boolean).join(' '), }, icon && hIcon(icon), h('span', { className: 'label' }, label) ) // don't use