diff --git a/README.md b/README.md index 1bbe7efe8..a21eee07f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ This is a full rewrite of [the Delphi version](https://github.com/rejetto/hfs2). ## Installation +For service installation instructions, [see our wiki](https://github.com/rejetto/hfs/wiki/Service-installation). + +For Docker installation, [see dedicated repo](https://github.com/damienzonly/hfs-docker). + NB: minimum Windows version required is 8.1 , Windows Server 2012 R2 (because of Node.js 18) 1. go to https://github.com/rejetto/hfs/releases @@ -86,41 +90,6 @@ If this procedure fails, it may be that you are missing one of [these requiremen Configuration and other files will be stored in `%HOME%/.vfs` -### Service - -If you want to run HFS at boot (as a service), we suggest the following methods - -#### On Linux -1. [install node.js](https://nodejs.org) -2. create a file `/etc/systemd/system/hfs.service` with this content - ``` - [Unit] - Description=HFS - After=network.target - - [Service] - Type=simple - Restart=always - ExecStart=/usr/bin/npx -y hfs@latest - - [Install] - WantedBy=multi-user.target - ``` -3. run `sudo systemctl daemon-reload && sudo systemctl enable hfs && sudo systemctl start hfs && sudo systemctl status hfs` - -NB: update will be attempted at each restart - -#### On Windows - -1. [install node.js](https://nodejs.org) -2. run `npm -g i hfs` -3. run `npx qckwinsvc2 install name="HFS" description="HFS" path="%APPDATA%\npm\node_modules\hfs\src\index.js" args="--cwd %HOMEPATH%\.hfs" now` - -To update -- run `npx qckwinsvc2 uninstall name="HFS"` -- run `npm -g update hfs` -- run `npx qckwinsvc2 install name="HFS" description="HFS" path="%APPDATA%\npm\node_modules\hfs\src\index.js" args="--cwd %HOMEPATH%\.hfs" now` - ## Console commands If you have full access to HFS' console, you can enter commands. Start with `help` to have a full list. @@ -150,43 +119,17 @@ 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 - -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: - -- it's more robust: it was designed to be an always-running server, while HFS 1-2 was designed for occasional usage (transfer and quit) -- passwords are never really stored, just a non-reversible hash is -- faster search (up to 12x) -- more flexible permissions - -## Security - -While this project focuses on ease of use, we care about security. -- HTTPS support -- 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 "antidos" plugin -- ensure "antibrute" plugin is running -- disable "unprotected admin on localhost" - ## Hidden features - Appending `#LOGIN` to address will bring up the login dialog - Appending ?lang=CODE to address will force a specific language -- right/ctrl/command click on toggle-all checkbox will invert each checkbox state +- Right-click on toggle-all checkbox will invert each checkbox state - Appending `?login=USER:PASSWORD` will automatically log in the browser - Appending `?overwrite` on uploads, will override the dont_overwrite_uploading configuration, provided you also have delete permission - Appending `?search=PATTERN` will trigger search at start - Right-click on "check for updates" will let you input a URL of a version to install -- shift+click on a file will show & play +- Shift+click on a file will show & play +- Type the name of a file/folder to focus it, and ctrl+backspace to go to parent folder ## Contribute @@ -227,6 +170,4 @@ There are several ways to contribute - [License](https://github.com/rejetto/hfs/blob/master/LICENSE.txt) -- [To-do list](todo.md) - - Flag images are public-domain, downloaded from https://flagpedia.net \ No newline at end of file diff --git a/admin/index.html b/admin/index.html index 17e80b6b1..0acfd19c9 100644 --- a/admin/index.html +++ b/admin/index.html @@ -7,8 +7,11 @@ HFS Admin-panel + + +
diff --git a/admin/package.json b/admin/package.json index 167d5746a..6f5af0c4c 100644 --- a/admin/package.json +++ b/admin/package.json @@ -37,7 +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": "^5.4.8" }, "eslintConfig": { "extends": [ diff --git a/admin/src/AccountForm.ts b/admin/src/AccountForm.ts index c2f64009e..aa0c235af 100644 --- a/admin/src/AccountForm.ts +++ b/admin/src/AccountForm.ts @@ -6,7 +6,7 @@ import { Alert } from '@mui/material' import { apiCall } from './api' import { alertDialog, useDialogBarColors } from './dialog' import { formatTimestamp, isEqualLax, prefix, useIsMobile, wantArray } from './misc' -import { IconBtn, modifiedProps } from './mui' +import { IconBtn, propsForModifiedValues } from './mui' import { Account } from './AccountsPage' import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a' import { AutoDelete, Delete } from '@mui/icons-material' @@ -70,7 +70,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" }, { k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Admin-panel access", xs: 12, sm: 6, xl: 8, helperText: "To access THIS interface you are using right now", - ...!account.admin && account.adminActualAccess && { value: true, helperText: "This permission is inherited" }, + ...!account.admin && account.adminActualAccess && { value: true, disabled: true, helperText: "This permission is inherited. To disable it, act on the groups." }, }, { k: 'disable_password_change', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Allow password change", xs: 'auto' }, group && h(Alert, { severity: 'info' }, `To add users to this group, select the user and then click "Inherit"`), @@ -88,7 +88,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: ], onError: alertDialog, save: { - ...modifiedProps( !isEqualLax(values, account)), + ...propsForModifiedValues(isModifiedConfig(values, account)), async onClick() { const { password='', password2, adminActualAccess, hasPassword, invalidated, ...withoutPassword } = values if (add) { @@ -116,6 +116,10 @@ export default function AccountForm({ account, done, groups, addToBar, reload }: }) } +export function isModifiedConfig(a: any, b: any) { + return !isEqualLax(a, b, (a,b) => !a && !b || undefined) +} + // you can set password directly in add/set_account, but using this api instead will add extra security because it is not sent as clear-text, so it's especially good if you are not in localhost and not using https export async function apiNewPassword(username: string, password: string) { const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters()) diff --git a/admin/src/AccountsPage.ts b/admin/src/AccountsPage.ts index 153e5ec43..7dc359f97 100644 --- a/admin/src/AccountsPage.ts +++ b/admin/src/AccountsPage.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 { createElement as h, useState, useEffect, Fragment } from "react" +import { createElement as h, useState, useEffect, Fragment, useMemo } from "react" import { apiCall, useApiEx } from './api' import { Alert, Box, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material' import { Close, Delete, DoNotDisturb, Group, MilitaryTech, Person, PersonAdd, Schedule } from '@mui/icons-material' @@ -13,20 +13,20 @@ import _ from 'lodash' import { alertDialog, confirmDialog, toast } from './dialog' import { useSnapState } from './state' import { importAccountsCsv } from './importAccountsCsv' -import { AccountAdminSend } from '../../src/api.accounts' +import apiAccounts from '../../src/api.accounts' -export type Account = AccountAdminSend +export type Account = ReturnType['list'][0] export default function AccountsPage() { const { username } = useSnapState() - const { data, reload, element } = useApiEx('get_accounts') + const { data, reload, element } = useApiEx('get_accounts') const [sel, setSel] = useState([]) const selectionMode = Array.isArray(sel) useEffect(() => { // if accounts are reloaded, review the selection to remove elements that don't exist anymore if (Array.isArray(data?.list) && selectionMode) - setSel( sel.filter(u => data.list.find((e:any) => e?.username === u)) ) // remove elements that don't exist anymore + setSel( sel.filter(u => data!.list.find((e:any) => e?.username === u)) ) // remove elements that don't exist anymore }, [data]) //eslint-disable-line -- Don't fall for its suggestion to add `sel` here: we modify it and declaring it as a dependency would cause a logical loop - const list: Account[] | undefined = data?.list + const list = useMemo(() => data && _.sortBy(data.list, [x => !x.adminActualAccess, 'username']), [data]) const selectedAccount = selectionMode && _.find(list, { username: sel[0] }) const sideBreakpoint = 'md' const isSideBreakpoint = useBreakpoint(sideBreakpoint) @@ -109,7 +109,7 @@ export default function AccountsPage() { setSel(ids) } }, - list?.map((ac: Account) => + list?.map(ac => h(TreeItem, { key: ac.username, nodeId: ac.username, @@ -155,8 +155,8 @@ export default function AccountsPage() { if (errors.length) return alertDialog("Following elements couldn't be deleted: " + errors.join(', '), 'error') } -} -export function account2icon(ac: Account, props={}) { - return h(ac.hasPassword ? Person : Group, props) -} + function account2icon(ac: Account, props={}) { + return h(ac.hasPassword ? Person : Group, props) + } +} \ No newline at end of file diff --git a/admin/src/App.ts b/admin/src/App.ts index 247530e38..53ad3b425 100644 --- a/admin/src/App.ts +++ b/admin/src/App.ts @@ -35,7 +35,9 @@ function App() { function ApplyTheme(props:any) { return h(Box, { sx: { bgcolor: 'background.default', color: 'text.primary', flex: 1, - maxWidth: '100%' /*avoid horizontal overflow (eg: customHtml with long line) */ }, + transition: 'background-color .4s', + maxWidth: '100%' /*avoid horizontal overflow (eg: customHtml with long line) */ + }, ...props }) } diff --git a/admin/src/ArrayField.ts b/admin/src/ArrayField.ts index 3e0415f83..eae48769d 100644 --- a/admin/src/ArrayField.ts +++ b/admin/src/ArrayField.ts @@ -4,20 +4,23 @@ import { createElement as h, Fragment, useMemo, useState } from 'react' import { Dict, isOrderedEqual, setHidden, swap } from './misc' import { Add, Edit, Delete, ArrowUpward, ArrowDownward, Undo, Check } from '@mui/icons-material' import { formDialog } from './dialog' -import { DataGrid, GridActionsCellItem, GridAlignment, GridColDef } from '@mui/x-data-grid' +import { GridActionsCellItem, GridAlignment, GridColDef } from '@mui/x-data-grid' import { BoolField, FieldDescriptor, FieldProps, labelFromKey } from '@hfs/mui-grid-form' import { Box, FormHelperText, FormLabel } from '@mui/material' import { DateTimeField } from './DateTimeField' import _ from 'lodash' import { Center, IconBtn } from './mui' +import { DataTable } from './DataTable' -type ArrayFieldProps = FieldProps & { fields: FieldDescriptor[], height?: number, reorder?: boolean, prepend?: boolean } -export function ArrayField({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, ...rest }: ArrayFieldProps) { - const rows = useMemo(() => (value||[]).map((x,$idx) => +type ArrayFieldProps = FieldProps & { fields: FieldDescriptor[], height?: number, reorder?: boolean, prepend?: boolean, autoRowHeight?: boolean } +export function ArrayField({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, autoRowHeight, ...rest }: ArrayFieldProps) { + if (!Array.isArray(value)) // avoid crash if non-array values are passed, especially developing plugins + value = [] + const rows = useMemo(() => value!.map((x,$idx) => setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })), [JSON.stringify(value)]) //eslint-disable-line const form = { - fields: fields.map(({ $width, $column, $type, ...rest }) => _.defaults(rest, byType[$type]?.field)) + fields: fields.map(({ $width, $column, $type, $hideUnder, ...rest }) => _.defaults(rest, byType[$type]?.field)) } setApi?.({ isEqual: isOrderedEqual }) // don't rely on stringify, as it wouldn't work with non-json values const [undo, setUndo] = useState() @@ -25,9 +28,14 @@ export function ArrayField({ label, helperText, fields, value, label && h(FormLabel, { sx: { ml: 1 } }, label), helperText && h(FormHelperText, {}, helperText), h(Box, { ...rest }, - h(DataGrid, { + h(DataTable, { rows, - sx: { '.MuiDataGrid-virtualScroller': { minHeight: '3em' } }, + ...autoRowHeight && { getRowHeight: () => 'auto' as const }, + sx: { + '.MuiDataGrid-virtualScroller': { minHeight: '3em' }, + ...autoRowHeight && { '.MuiDataGrid-cell': { minHeight: '52px !important' } } + }, + style: undefined, // override style making it fill the flex hideFooterSelectedRowCount: true, hideFooter: true, slots: { @@ -42,7 +50,7 @@ export function ArrayField({ label, helperText, fields, value, columns: [ ...fields.map(f => { const def = byType[f.$type]?.column - return ({ + return { field: f.k, headerName: f.headerName ?? (typeof f.label === 'string' ? f.label : labelFromKey(f.k)), disableColumnMenu: true, @@ -51,8 +59,9 @@ export function ArrayField({ label, helperText, fields, value, }, ...def, ...f.$width ? { [f.$width >= 8 ? 'width' : 'flex']: f.$width } : (!def?.width && !def?.flex && { flex: 1 }), + hideUnder: f.$hideUnder, ...f.$column, - }) + } }), { field: '', @@ -91,10 +100,11 @@ export function ArrayField({ label, helperText, fields, value, icon: h(Edit), label: title, title, - onClick(event: MouseEvent) { + onClick(ev: MouseEvent) { + ev.stopPropagation() formDialog({ values: row as any, form, title }).then(x => { if (x) - set(value!.map((oldRec, i) => i === $idx ? x : oldRec), event) + set(value!.map((oldRec, i) => i === $idx ? x : oldRec), ev) }) } }), @@ -102,19 +112,28 @@ export function ArrayField({ label, helperText, fields, value, icon: h(Delete), label: "Delete", showInMenu: reorder, - onClick: ev => set(value!.filter((rec, i) => i !== $idx), ev), + onClick: ev => { + ev.stopPropagation() + set(value!.filter((rec, i) => i !== $idx), ev) + }, }), reorder && $idx && h(GridActionsCellItem as any, { icon: h(ArrowUpward), label: "Move up", showInMenu: true, - onClick: ev => set(swap(value!.slice(), $idx, $idx - 1), ev), + onClick: ev => { + ev.stopPropagation() + set(swap(value!.slice(), $idx, $idx - 1), ev) + }, }), reorder && $idx < rows.length - 1 && h(GridActionsCellItem as any, { icon: h(ArrowDownward), label: "Move down", showInMenu: true, - onClick: ev => set(swap(value!.slice(), $idx, $idx + 1), ev), + onClick: ev => { + ev.stopPropagation() + set(swap(value!.slice(), $idx, $idx + 1), ev) + }, }), ].filter(Boolean) } diff --git a/admin/src/ConfigFilePage.ts b/admin/src/ConfigFilePage.ts index 13e28b291..d5e68ee98 100644 --- a/admin/src/ConfigFilePage.ts +++ b/admin/src/ConfigFilePage.ts @@ -63,7 +63,7 @@ export default function ConfigFilePage() { function copy() { if (!text) return - navigator.clipboard.writeText(text.replace(/^\s*(\w*password|srp):.+\n/gm, '')) + navigator.clipboard.writeText(text.replace(/^(\s*(\w*password\w*|srp):\s*).+\n/gm, '$1: removed\n')) toast("copied") } } diff --git a/admin/src/ConfigForm.ts b/admin/src/ConfigForm.ts index 47351b43d..846d245a2 100644 --- a/admin/src/ConfigForm.ts +++ b/admin/src/ConfigForm.ts @@ -2,7 +2,7 @@ import { Form, FormProps } from '@hfs/mui-grid-form' import { apiCall, useApiEx } from './api' import { createElement as h, useEffect, useState, Dispatch } from 'react' import _ from 'lodash' -import { IconBtn, modifiedProps } from './mui' +import { IconBtn, propsForModifiedValues } from './mui' import { RestartAlt } from '@mui/icons-material' import { Callback, onlyTruthy } from '../../src/cross' @@ -35,7 +35,7 @@ export function ConfigForm({ keys, form, saveOnChange, onSave, ...rest }: }, save: saveOnChange ? false : { onClick: save, - ...modifiedProps(modified), + ...propsForModifiedValues(modified), }, ...formProps, ...rest, diff --git a/admin/src/DataTable.ts b/admin/src/DataTable.ts index 8a04887ee..58469c63f 100644 --- a/admin/src/DataTable.ts +++ b/admin/src/DataTable.ts @@ -34,6 +34,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini const theme = useTheme() const apiRef = useGridApiRef() const [actionsLength, setActionsLength] = useState(0) + const [merged, setMerged] = useState(0) const manipulatedColumns = useMemo(() => { const { localeText } = enUS.components.MuiDataGrid.defaultProps as any const ret = columns.map(col => { @@ -100,6 +101,8 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini && field)) const o = Object.fromEntries(fields.map(x => [x, false])) _.merge(initialState, { columns: { columnVisibilityModel: o } }) + // count the hidden columns that are merged into visible columns + setMerged(_.sumBy(fields, k => _.find(columns, col => !fields.includes(col.field) && col.mergeRender?.[k]) ? 1 : 0)) return fields }, [manipulatedColumns, width]) const [vis, setVis] = useState({}) @@ -128,6 +131,9 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini columns: manipulatedColumns, apiRef, ...rest, + sx: { + '& .MuiDataGrid-virtualScroller': { minHeight: '3em' } // without this, no-entries gets just 1px + }, slots: { noRowsOverlay: () => initializing ? null : h(Center, {}, noRows || "No entries"), footer: CustomFooter, @@ -142,10 +148,10 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini onCellClick({ field, row }) { if (field === ACTIONS) return if (window.getSelection()?.type === 'Range') return // not a click but a drag - const n = apiRef.current.getVisibleColumns().length - const showCols = manipulatedColumns.filter(x => + const visibleInList = merged + apiRef.current.getVisibleColumns().length + const showInDialog = manipulatedColumns.filter(x => !x.dialogHidden && (x.renderCell || x.field === ACTIONS || row[x.field] !== undefined)) - if (showCols.length <= n) return + if (showInDialog.length <= visibleInList) return // no need for dialog newDialog({ title: "Details", onClose() { @@ -163,7 +169,7 @@ export function DataTable({ columns, initialState={}, actions, actionsProps, ini gridAutoFlow: 'dense', minWidth: 'max(16em, 40vw)', sx: { opacity: curRow ? undefined : .5 }, - }, showCols.map(col => + }, showInDialog.map(col => h(Box, { key: col.field, gridColumn: col.flex && '1/-1' }, h(Box, { bgcolor: '#0003', p: 1 }, col.headerName || col.field), h(Flex, { minHeight: '2.5em', px: 1, wordBreak: 'break-word' }, diff --git a/admin/src/FileField.ts b/admin/src/FileField.ts index 116762395..8e5c47367 100644 --- a/admin/src/FileField.ts +++ b/admin/src/FileField.ts @@ -14,8 +14,7 @@ export default function FileField({ value, onChange, files=true, folders=false, ...props, value, onChange, - onTyping: (v: string) => !v.includes('\n') && v, - InputProps: { multiline: true }, + wrap: true, end: h(IconBtn, { icon: Eject, title: "Browse files...", diff --git a/admin/src/FileForm.ts b/admin/src/FileForm.ts index 2800e89e7..7f2eecc18 100644 --- a/admin/src/FileForm.ts +++ b/admin/src/FileForm.ts @@ -7,11 +7,12 @@ import { BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, Sel } from '@hfs/mui-grid-form' import { apiCall, UseApi } from './api' import { - basename, defaultPerms, formatBytes, formatTimestamp, isEqualLax, isWhoObject, newDialog, objSameKeys, + basename, defaultPerms, formatBytes, formatTimestamp, isWhoObject, newDialog, objSameKeys, onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, HTTP_MESSAGES, xlate, md, Callback, - useRequestRender, splitAt + useRequestRender, splitAt, IMAGE_FILEMASK } from './misc' -import { Btn, IconBtn, LinkBtn, modifiedProps, useBreakpoint, wikiLink } from './mui' +import { isModifiedConfig } from './AccountForm' +import { Btn, Flex, IconBtn, LinkBtn, propsForModifiedValues, useBreakpoint, wikiLink } from './mui' import { reloadVfs, VfsNode } from './VfsPage' import _ from 'lodash' import FileField from './FileField' @@ -22,6 +23,8 @@ import { moveVfs } from './VfsTree' import QrCreator from 'qr-creator'; import MenuButton from './MenuButton' import addFiles, { addLink, addVirtual } from './addFiles' +import { SYS_ICONS } from '@hfs/frontend/src/sysIcons' +import { hIcon } from '@hfs/frontend/src/misc' const ACCEPT_LINK = "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept" @@ -72,6 +75,8 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: can_upload: isDir, can_delete: isDir, } + const defaultIcon = !values.icon + const embeddedIcon = values.icon && !values.icon.includes('.') return h(Form, { values, set(v, k) { @@ -130,7 +135,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: ], onError: alertDialog, save: { - ...modifiedProps(!isEqualLax(values, rest)), + ...propsForModifiedValues(isModifiedConfig(values, rest)), async onClick() { const props = _.omit(values, ['ctime','mtime','size','id']) ;(props as any).masks ||= null // undefined cannot be serialized @@ -152,12 +157,30 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: : isDir ? "Content from this path on disk will be listed, but you can also add more" : undefined, }, { k: 'id', comp: LinkField, statusApi, xs: 12 }, + { + k: 'iconType', + comp: SelectField, + options: ['default', 'file', 'embedded'], + value: !values.icon ? 'default' : embeddedIcon ? 'embedded' : 'file', + fromField: v => setValues({ ...values, icon: v === 'default' ? '' : v === 'file' ? 'select.a.file' : Object.keys(SYS_ICONS)[0] }), + xs: defaultIcon ? 12 : true, + }, + !defaultIcon && { k: 'icon', xs: 7, md: 8, xl: 10, + ...embeddedIcon ? { + comp: SelectField, // uniqBy to avoid same icon (with different names), but it works only on array, so first step is to convert the object + options: _.map(_.uniqBy(_.map(SYS_ICONS, (v,k) => [k, v[0], v[1] ?? k] as const), x => x[2]), ([k, emoji]) => + ({ value: k, label: h(Flex, { gap: '.5em' }, hIcon(k), hIcon(emoji), ' ', k) }) ), // show both font-icon and emoji versions + helperText: "Second icon you see is the fallback" + } : { + label: "Icon file", placeholder: "default", comp: FileField, fileMask: IMAGE_FILEMASK, + } + }, perm('can_read', "Who can see but not download will be asked to login"), - 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_delete', [needSourceWarning, "Those who can delete can also rename and cut/move"]), perm('can_upload', needSourceWarning, { contentText: "subfolders" }), + perm('can_see', ["See this item in the list. ", wikiLink('Permissions', "More help.")]), isLink && { k: 'target', comp: BoolField, @@ -171,17 +194,18 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: 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, + showWebsite && { k: 'default', comp: BoolField, xl: showWebsite ? 8 : 12, 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") }, - isDir && { k: 'masks', multiline: true, + { k: 'comment', multiline: true, xl: true }, + isDir && { k: 'masks', multiline: true, xl: 6, toField: yaml.stringify, fromField: v => v ? yaml.parse(v) : undefined, sx: { '& textarea': { fontFamily: 'monospace' } }, helperText: ["Special field, leave empty unless you know what you are doing. YAML syntax. ", wikiLink('Masks-field', "(examples)")] - } + }, ] }) diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index ecbf837e3..77e8ba5c8 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -3,8 +3,10 @@ import { createElement as h, ReactNode, useState } from 'react' import { Box, Card, CardContent, LinearProgress, Link } from '@mui/material' import { apiCall, useApiEx, useApiList } from './api' -import { dontBotherWithKeys, objSameKeys, onlyTruthy, prefix, REPO_URL, md, - replaceStringToReact, wait, with_ } from './misc' +import { + dontBotherWithKeys, objSameKeys, onlyTruthy, prefix, REPO_URL, md, + replaceStringToReact, wait, with_, DAY, HOUR +} from './misc' import { Btn, Flex, InLink, LinkBtn, wikiLink, } from './mui' import { BrowserUpdated as UpdateIcon, CheckCircle, Error, Info, Launch, OpenInNew, Warning } from '@mui/icons-material' import { state, useSnapState } from './state' @@ -15,24 +17,15 @@ import { Account } from './AccountsPage' import _ from 'lodash' import { subscribeKey } from 'valtio/utils' import { SwitchThemeBtn } from './theme' -import { BoolField } from '@hfs/mui-grid-form' +import { CheckboxField } from '@hfs/mui-grid-form' import { ConfigForm } from './ConfigForm' - -interface ServerStatus { listening: boolean, port: number, error?: string, busy?: string } - -interface Status { - http: ServerStatus - https: ServerStatus - frpDetected: boolean - proxyDetected?: boolean - updatePossible: boolean | string - version: string -} +import { Release } from '../../src/update' +import { adminApis } from '../../src/adminApis' export default function HomePage() { const SOLUTION_SEP = " — " const { username } = useSnapState() - const { data: status, reload: reloadStatus, element: statusEl } = useApiEx('get_status') + 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'] }) @@ -60,17 +53,21 @@ export default function HomePage() { ]])) return h(Box, { display:'flex', gap: 2, flexDirection:'column', alignItems: 'flex-start', height: '100%' }, username && entry('', "Welcome, "+username), + dontBotherWithKeys(status.alerts?.map(x => entry('warning', md(x, { html: false })))), errors.length ? dontBotherWithKeys(errors.map(msg => entry('error', dontBotherWithKeys(msg)))) : entry('success', "Server is working"), - !vfs ? h(LinearProgress) - : !vfs.root?.children?.length && !vfs.root?.source ? entry('warning', "You have no files shared", SOLUTION_SEP, fsLink("add some")) - : entry('', md("This is the Admin-panel, where you manage your server. Access your files on "), - h(Link, { target:'frontend', href: '../..' }, "Front-end", h(Launch, { sx: { verticalAlign: 'sub', ml: '.2em' } }))), !href && entry('warning', "Frontend unreachable: ", _.map(serverErrors, (v,k) => k + " " + (v ? "is in error" : "is off")).join(', '), !errors.length && [ SOLUTION_SEP, cfgLink("switch http or https on") ] ), + with_(status.acmeRenewError, x => x && entry('warning', x)), plugins.find(x => x.badApi) && entry('warning', "Some plugins may be incompatible"), + !cfg.data?.split_uploads && (Date.now() - Number(status.cloudflareDetected || 0)) < DAY + && entry('', wikiLink('Reverse-proxy#cloudflare', "Cloudflare detected, read our guide")), + !vfs ? h(LinearProgress) + : !vfs.root?.children?.length && !vfs.root?.source ? entry('warning', "You have no files shared", SOLUTION_SEP, fsLink("add some")) + : entry('', md("This is the Admin-panel, where you manage your server. Access your files on "), + h(Link, { target:'frontend', href: '../..' }, "Front-end", h(Launch, { sx: { verticalAlign: 'sub', ml: '.2em' } }))), !account?.adminActualAccess && entry('', md("On localhost you don't need to login"), SOLUTION_SEP, "to access Admin-panel from another computer ", h(InLink, { to:'accounts' }, md("create an account with *admin* permission")) ), with_(proxyWarning(cfg, status), x => x && entry('warning', x, @@ -92,58 +89,69 @@ export default function HomePage() { h('li',{}, `disable "admin access for localhost" in HFS (safe, but you won't see users' IPs)`), )), entry('', wikiLink('', "See the documentation"), " and ", h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "get support")), + !updates && with_(status.autoCheckUpdateResult, x => x?.isNewer && h(Update, { info: x, bodyCollapsed: true, title: "An update has been found" })), pluginUpdates.length > 0 && entry('success', "Updates available for plugin(s): " + pluginUpdates.map(p => p.id).join(', ')), - status.updatePossible === 'local' ? h(Btn, { - icon: UpdateIcon, - onClick: () => update() - }, "Update from local file") - : !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`) - : h(Flex, { vert: true }, - updates.map((x: any) => - h(Flex, { key: x.name, alignItems: 'flex-start', flexWrap: 'wrap' }, - h(Card, {}, h(CardContent, {}, - h(Btn, { - icon: UpdateIcon, - ...!x.isNewer && x.prerelease && { color: 'warning', variant: 'outlined' }, - onClick: () => update(x.tag_name) - }, prefix("Install ", x.name, x.isNewer ? '' : " (older)")), - h(Box, { mt: 1 }, renderChangelog(x.body)) - )), - )), - )), + h(ConfigForm, { + gridProps: { sx: { columns: '13em 3', gap: 0, display: 'block', mt: 0, '&>div.MuiGrid-item': { pt: 0 }, '.MuiCheckbox-root': { pl: '2px' } } }, + saveOnChange: true, + form: { fields: [ + status.updatePossible === 'local' ? h(Btn, { icon: UpdateIcon, onClick: () => update() }, "Update from local file") + : !updates && h(Btn, { + variant: 'outlined', + icon: UpdateIcon, + onClick() { + apiCall('wait_project_info').then(reloadStatus) + setCheckPlugins(true) // this only happens once, actually (until you change page) + 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"), + { k: 'auto_check_update', comp: CheckboxField, label: "Auto check updates daily" }, + { k: 'update_to_beta', comp: CheckboxField, label: "Include beta versions" }, + ] } + }), + updates && with_(_.find(updates, 'isNewer'), newer => + !updates.length || !status.updatePossible && !newer ? entry('', "No update available") + : newer && !status.updatePossible ? entry('success', `Version ${newer.name} available`) + : h(Flex, { vert: true }, + updates.map((x: any) => h(Update, { info: x })) )), h(SwitchThemeBtn, { variant: 'outlined' }), + Date.now() - Number(new Date(status.started)) > HOUR && h(Link, { + title: "Donate", + target: 'donate', + style: { textDecoration: 'none', position: 'fixed', bottom: 0, right: 4, fontSize: 'large' }, + href: 'https://www.paypal.com/donate/?hosted_button_id=HC8MB4GRVU5T2' + }, '❤️') + ) +} + +function Update({ info, title, bodyCollapsed }: { title?: ReactNode, info: Release, bodyCollapsed?: boolean }) { + const [collapsed, setCollapsed] = useState(bodyCollapsed) + return h(Flex, { key: info.name, alignItems: 'flex-start', flexWrap: 'wrap' }, + h(Card, {}, h(CardContent, {}, + title && h(Box, { fontSize: 'larger', mb: 1 }, title), + h(Btn, { + icon: UpdateIcon, + ...!info.isNewer && info.prerelease && { color: 'warning', variant: 'outlined' }, + onClick: () => update(info.tag_name) + }, prefix("Install ", info.name, info.isNewer ? '' : " (older)")), + collapsed ? h(LinkBtn, { sx: { display: 'block', mt: 1 }, onClick(){ setCollapsed(false) } }, "See details") + : h(Box, { mt: 1 }, renderChangelog(info.body)) + )), ) } function renderChangelog(s: string) { return md(s, { + html: false, 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] ) @@ -183,7 +191,9 @@ function entry(color: Color, ...content: ReactNode[]) { h(({ success: CheckCircle, info: Info, '': Info, warning: Warning, error: Error })[color], { sx: { mr: 1, color: color ? undefined : 'primary.main' } }), - ...content) + h('span', { style: ['warning', 'error'].includes(color) ? { animation: '.5s blink 2' } : undefined }, + ...content) + ) } function fsLink(text=`File System page`) { diff --git a/admin/src/InstalledPlugins.ts b/admin/src/InstalledPlugins.ts index 05f0a587c..66c8aec77 100644 --- a/admin/src/InstalledPlugins.ts +++ b/admin/src/InstalledPlugins.ts @@ -5,23 +5,23 @@ import { createElement as h, Fragment, useEffect } from 'react' import { Box, Link } from '@mui/material' 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 { HTTP_FAILED_DEPENDENCY, md, newObj, prefix, with_, xlate } from './misc' import { alertDialog, confirmDialog, formDialog, toast } from './dialog' import _ from 'lodash' import { Account } from './AccountsPage' -import { BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField -} from '@hfs/mui-grid-form' +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, iconTooltip } from './mui' +import VfsPathField from './VfsPathField' export default function InstalledPlugins({ updates }: { updates?: true }) { - const { list, updateEntry, error, updateList, initializing } = useApiList(updates ? 'get_plugin_updates' : 'get_plugins') + const { list, error, updateList, initializing } = useApiList(updates ? 'get_plugin_updates' : 'get_plugins') useEffect(() => { if (!initializing) updateList(list => - _.sortBy(list, x => (x.started ? '0' : '1') + x.id)) + _.sortBy(list, x => (x.started ? '0' : '1') + treatPluginName(x.id))) }, [initializing]); const size = 'small' return h(DataTable, { @@ -55,15 +55,15 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { actions: ({ row, id }) => updates ? [ h(IconBtn, { icon: Upgrade, - title: row.updated ? "Already updated" : "Update", + title: row.downloading ? "Downloading" : row.updated ? "Already updated" : "Update", disabled: row.updated, + progress: row.downloading, size, async onClick() { await apiCall('update_plugin', { id, branch: row.branch }, { timeout: false }).catch(e => { throw e.code !== HTTP_FAILED_DEPENDENCY ? e : Error("Failed dependencies: " + e.cause?.map((x: any) => prefix(`plugin "`, x.id || x.repo, `" `) + x.error).join('; ')) }) - updateEntry({ id }, { updated: true }) toast("Plugin updated") } }) @@ -103,7 +103,8 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { addToBar: [h(Btn, { variant: 'outlined', onClick: () => save(values) }, "Save")], }), values: lastSaved, - dialogProps: _.merge({ sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen) + dialogProps: _.merge({ maxWidth: 'md', sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen) + with_(row.configDialog?.maxWidth, x => x?.length === 2 ? { maxWidth: x } : x ? { sx: { maxWidth: x } } : null), // this makes maxWidth support css values without having to wrap in sx, as in DialogProps it only supports breakpoints row.configDialog), }) if (values && !_.isEqual(lastSaved, values)) @@ -135,14 +136,20 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { }) } +// hide the hfs- prefix, as one may want to use it for its repository, because github is the context, but in the hfs context the prefix it's not only redundant, but also ruins the sorting +function treatPluginName(name: string) { + return name.replace(/hfs-/, '') +} + export function renderName({ row, value }: any) { const { repo } = row return h(Fragment, {}, + row.downgrade && errorIcon("This version is older than the one you installed. It is possible that the author found a problem with your version and decided to retire it.", true), errorIcon(row.error || row.badApi, !row.error), repo?.includes('//') ? h(Link, { href: repo, target: 'plugin' }, value) : with_(repo?.split('/'), arr => arr?.length !== 2 ? value : h(Fragment, {}, - h(Link, { href: 'https://github.com/' + repo, target: 'plugin', onClick(ev) { ev.stopPropagation() } }, arr[1].replace(/hfs-/, '')), + h(Link, { href: 'https://github.com/' + repo, target: 'plugin', onClick(ev) { ev.stopPropagation() } }, treatPluginName(arr[1])), '\xa0by ', arr[0] )) ) @@ -156,7 +163,9 @@ function makeFields(config: any) { return Object.entries(config).map(([k,o]: [string,any]) => { if (!_.isPlainObject(o)) return o - let { type, defaultValue, fields, frontend, ...rest } = o + let { type, defaultValue, fields, frontend, helperText, ...rest } = o + if (helperText) + helperText = md(helperText, { html: false }) const comp = (type2comp as any)[type] as Field | undefined if (comp === ArrayField) { rest.valuesForAdd = newObj(fields, x => x.defaultValue) @@ -164,7 +173,7 @@ function makeFields(config: any) { } if (defaultValue !== undefined && type === 'boolean') rest.placeholder = `Default value is ${JSON.stringify(defaultValue)}` - return { k, comp, fields, ...rest } + return { k, comp, fields, helperText, ...rest } }) } @@ -176,6 +185,7 @@ const type2comp = { multiselect: MultiSelectField, array: ArrayField, real_path: FileField, + vfs_path: VfsPathField, username: UsernameField, } @@ -190,12 +200,11 @@ export async function startPlugin(id: string) { } } -function UsernameField({ value, onChange, multiple, ...rest }: FieldProps) { +function UsernameField({ value, onChange, multiple, groups, ...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", + options: data?.list.filter(x => groups === undefined || groups === !x.hasPassword).map(x => x.username), ...rest, }) } diff --git a/admin/src/InternetPage.ts b/admin/src/InternetPage.ts index 06f86b42a..4ae465bcb 100644 --- a/admin/src/InternetPage.ts +++ b/admin/src/InternetPage.ts @@ -5,7 +5,7 @@ import { CardMembership, Check, Dns, HomeWorkTwoTone, Lock, Public, PublicTwoTon import { apiCall, useApiEvents, useApiEx } from './api' import { closeDialog, DAY, formatTimestamp, wait, wantArray, with_, PORT_DISABLED, isIP, CFG, md, useRequestRender, replace, restartAnimation, prefix } from './misc' -import { Flex, LinkBtn, Btn, Country } from './mui' +import { Flex, LinkBtn, Btn, Country, wikiLink } from './mui' import { alertDialog, confirmDialog, formDialog, promptDialog, toast, waitDialog } from './dialog' import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form' import { suggestMakingCert } from './OptionsPage' @@ -23,7 +23,7 @@ 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'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.` +const MSG_ISP = h('div', {}, "HFS will probably not be reachable on the Internet. ", wikiLink('Work-on-the-internet#double-nat', "Read more")) export default function InternetPage() { const [checkResult, setCheckResult] = useState() @@ -214,9 +214,7 @@ export default function InternetPage() { 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), { trueText: "Continue anyway" }) ) - if (res === false) return + if (await stopOnCheckDomain(domain)) return await apiCall('make_cert', { domain, altNames, email: values.acme_email }, { timeout: 20_000 }) .then(async () => { await alertDialog("Certificate created", 'success') @@ -314,6 +312,11 @@ export default function InternetPage() { ) } + async function stopOnCheckDomain(domain: string) { + return domain && false === await apiCall('check_domain', { domain }).catch(e => + confirmDialog(String(e), { trueText: "Continue anyway", falseText: "Stop" })) + } + async function verify(again=false): Promise { await nat.loading const data = nat.getData() // fresh data @@ -326,8 +329,7 @@ export default function InternetPage() { { 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 + if (await stopOnCheckDomain(domain)) 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')) @@ -366,7 +368,13 @@ export default function InternetPage() { h('li', {}, "There could be a firewall, try configuring or disabling it."), (data.externalPort || data.internalPort!) <= 1024 && h('li', {}, "Your Internet Provider may be blocking ports under 1024. ", - data.upnp && h(Button, { size: 'small', onClick() { close(); mapPort(HIGHER_PORT).then(verifyAgain) } }, "Try " + HIGHER_PORT) ), + data.upnp && h(Button, { + size: 'small', + onClick() { + close(); + mapPort(HIGHER_PORT).then(verifyAgain) + } + }, "Try " + HIGHER_PORT)), data.mapped && h('li', {}, "A bug in your modem/router, try rebooting it."), h('li', {}, MSG_ISP), )), 'warning') diff --git a/admin/src/LangPage.ts b/admin/src/LangPage.ts index 0b8766024..583a623c0 100644 --- a/admin/src/LangPage.ts +++ b/admin/src/LangPage.ts @@ -26,7 +26,7 @@ export default function LangPage() { h(DataTable, { error, loading: connecting, - rows: list as any, + rows: useMemo(() => _.sortBy(list, x => (x.embedded ? 2 : 1) + x.code), [list.length]), // multi-sorting is only in pro version of DataGrid hideFooter: true, sx: { flex: 1 }, columns: [ diff --git a/admin/src/LoginRequired.ts b/admin/src/LoginRequired.ts index f4a209bea..09d604301 100644 --- a/admin/src/LoginRequired.ts +++ b/admin/src/LoginRequired.ts @@ -2,8 +2,8 @@ import { state, useSnapState } from './state' import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' -import { getHFS, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, makeSessionRefresher } from './misc' -import { Form } from '@hfs/mui-grid-form' +import { CFG, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, makeSessionRefresher } from './misc' +import { BoolField, Form } from '@hfs/mui-grid-form' import { apiCall } from './api' import { srpClientSequence } from '@hfs/shared' import { Alert, Box } from '@mui/material' @@ -22,7 +22,7 @@ export function LoginRequired({ children }: any) { } function LoginForm() { - const [values, setValues] = useState({ username: '', password: '' }) + const [values, setValues] = useState({ username: '', password: '', ipChange: false }) const [error, setError] = useState('') const formRef = useRef() const empty = formRef.current?.querySelector('input[value=""]') @@ -31,12 +31,15 @@ function LoginForm() { h(Form, { formRef, values, + m: 2, + maxWidth: '25em', set(v, k) { setValues(values => ({ ...values, [k]: v })) }, fields: [ { k: 'username', autoComplete: 'username', autoFocus: true, required: true }, { k: 'password', type: 'password', autoComplete: 'current-password', required: true }, + { k: 'ipChange', comp: BoolField, label: "Allow IP change during this session" }, ], addToBar: [ error && h(Alert, { severity: 'error', sx: { flex: 1 } }, error) ], saveOnEnter: true, @@ -46,7 +49,9 @@ function LoginForm() { async onClick() { try { setError('') - await login(values.username, values.password) + await login(values.username, values.password, { + [CFG.allow_session_ip_change]: values.ipChange + }) } catch(e) { setError(String(e)) @@ -57,8 +62,8 @@ function LoginForm() { ) } -async function login(username: string, password: string) { - const res = await srpClientSequence(username, password, apiCall).catch(err => { +async function login(username: string, password: string, extra?: object) { + const res = await srpClientSequence(username, password, apiCall, extra).catch(err => { throw err?.code === HTTP_UNAUTHORIZED ? "Wrong username or password" : err === 'trust' ? "Login aborted: server identity cannot be trusted" : err?.name === 'AbortError' ? "Server didn't respond" diff --git a/admin/src/LogsPage.ts b/admin/src/LogsPage.ts index 9fe9b7bc0..fa958754a 100644 --- a/admin/src/LogsPage.ts +++ b/admin/src/LogsPage.ts @@ -4,8 +4,10 @@ import { createElement as h, Fragment, ReactNode, useEffect, useMemo, useState } import { Box, Tab, Tabs } from '@mui/material' 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 { + CFG, Dict, formatBytes, HTTP_UNAUTHORIZED, newDialog, prefix, shortenAgent, splitAt, tryJson, md, + typedKeys, NBSP, _dbg, mapFilter, safeDecodeURIComponent, stringAfter +} from '@hfs/shared' import { NetmaskField, Flex, IconBtn, useBreakpoint, usePauseButton, useToggleButton, WildcardsSupported, Country, hTooltip, Btn, wikiLink @@ -95,6 +97,8 @@ export default function LogsPage() { } } +const LOGS_ON_FILE: string[] = [CFG.log, CFG.error_log] + function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string, addToFooter?: ReactNode }) { const [showCountry, setShowCountry] = useState(false) const [showAgent, setShowAgent] = useState(false) @@ -111,7 +115,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string const invert = true const [firstSight, setFirstSight] = useState(!hidden) useEffect(() => setFirstSight(x => x || !hidden), [hidden]) - useApi(firstSight && 'get_log_file', { file, range: limited || !skipped ? -MAX : `0-${skipped}` }, { + useApi(firstSight && LOGS_ON_FILE.includes(file) && 'get_log_file', { file, range: limited || !skipped ? -MAX : `0-${skipped}` }, { skipParse: true, skipLog: true, onResponse(res, body) { const lines = body.split('\n') @@ -272,7 +276,7 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string const [path, query] = splitAt('?', value).map(safeDecodeURIComponent) const ul = row.extra?.ul if (ul) - return typeof ul === 'string' ? ul // legacy pre-0.51 + return typeof ul === 'string' ? ul //legacy pre-0.51 : path + ul.join(' + ') if (!path.startsWith(API_URL)) return [path, query && h(Box, { key: 0, component: 'span', color: 'text.secondary', fontSize: 'smaller' }, '?', query)] @@ -284,62 +288,70 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string ] }) - function enhanceLogLine(x: any) { - if (!x) return - const { extra } = x - if ((extra?.country || x.country) && !showCountry) + function enhanceLogLine(row: any) { + if (!row) return + const { extra } = row + if ((extra?.country || row.country) && !showCountry) setShowCountry(true) if (extra?.ua && !showAgent) setShowAgent(true) - x.notes = extra?.dl ? "fully downloaded" - : (x.method === 'PUT' || extra?.ul) ? "uploaded " + formatBytes(extra?.size, { sep: NBSP }) - : x.status === HTTP_UNAUTHORIZED && x.uri?.startsWith(API_URL + 'loginSrp') ? "login failed" + prefix(':\n', extra?.u) - : _.map(extra?.params, (v, k) => `${k}: ${v}\n`).join('') + (x.notes || '') - return x + if (row.uri) { + const partial = stringAfter('?', row.uri).includes('partial=') + row.notes = extra?.dl ? "fully downloaded" + : (row.method === 'PUT' || extra?.ul) ? "uploaded " + (partial ? "up to " : "") + formatBytes(extra?.size, { sep: NBSP }) + : row.status === HTTP_UNAUTHORIZED && row.uri?.startsWith(API_URL + 'loginSrp') ? "login failed" + prefix(':\n', extra?.u) + : _.map(extra?.params, (v, k) => `${k}: ${v}\n`).join('') + (row.notes || '') + } + return row } } +const UW = 'https://upload.wikimedia.org/wikipedia/commons/' +const BROWSER_ICONS = { + 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 + '9/98/Microsoft_Edge_logo_%282019%29.svg', + Opera: UW + '4/49/Opera_2015_icon.svg', +} +const OS_ICONS = { + 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 +} +const OSS = { + apple: /Mac OS|iPhone OS/, + win: /Windows NT/, + android: /Android/, + linux: /Linux/, +} + export function agentIcons(agent: string | undefined) { if (!agent) return - const UW = 'https://upload.wikimedia.org/wikipedia/commons/' const short = shortenAgent(agent) - 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 + '9/98/Microsoft_Edge_logo_%282019%29.svg', - Opera: UW + '4/49/Opera_2015_icon.svg', - } }) + const browserIcon = h(AgentIcon, { k: short, altText: true, map: BROWSER_ICONS }) const os = _.findKey(OSS, re => re.test(agent)) - 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', {}, browserIcon, ' ', osIcon) ) + return hTooltip(agent, undefined, h(Box, { fontSize: '110%' }, browserIcon, ' ', os && osIcon(os as any)) ) } const alreadyFailed: any = {} +export function osIcon(k: keyof typeof OS_ICONS) { + return h(AgentIcon, { k, map: OS_ICONS }) +} + 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' }, + src, + style: { height: '1.2em', verticalAlign: 'bottom', marginRight: '.2em' }, onError() { setErr(alreadyFailed[k] = true) } }) } -const OSS = { - apple: /Mac OS|iPhone OS/, - win: /Windows NT/, - android: /Android/, - linux: /Linux/, -} - function parseLogLine(line: string, id: number) { const m = /^(.+?) (.+?) (.+?) \[(.{11}):(.{14})] "(\w+) ([^"]+) HTTP\/\d.\d" (\d+) (-|\d+) ?(.*)/.exec(line) if (!m) return diff --git a/admin/src/MonitorPage.ts b/admin/src/MonitorPage.ts index 4fe60eeb1..8aea3a4b3 100644 --- a/admin/src/MonitorPage.ts +++ b/admin/src/MonitorPage.ts @@ -16,7 +16,8 @@ import { StandardCSSProperties } from '@mui/system/styleFunctionSx/StandardCssPr import { agentIcons } from './LogsPage' import { state, useSnapState } from './state' import { useBlockIp } from './useBlockIp' -import { alertDialog } from './dialog' +import { alertDialog, confirmDialog } from './dialog' +import { useInterval } from 'usehooks-ts' export default function MonitorPage() { return h(Fragment, {}, @@ -26,13 +27,13 @@ export default function MonitorPage() { } function MoreInfo() { - const { data: status, element } = useApiEx('get_status') + const { data: status, element, reload } = useApiEx('get_status') + useInterval(reload, 10_000) // status hardly change, but it can const { data: connections } = useApiEvents('get_connection_stats') - if (status && connections) - Object.assign(status, connections) const [allInfo, setAllInfo] = useState(false) const md = useBreakpoint('md') const sm = useBreakpoint('sm') + const xl = useBreakpoint('xl') const formatDuration = createDurationFormatter({ maxTokens: 2, skipZeroes: true }) return element || h(Box, { display: 'flex', flexWrap: 'wrap', gap: { xs: .5, md: 1 }, mb: { xs: 1, sm: 2 } }, (allInfo || md) && pair('started', { @@ -43,15 +44,17 @@ function MoreInfo() { (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) + onDelete: () => confirmDialog("Reset stats?") + .then(yes => yes && 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' }), + (allInfo || sm) && pair('ips', { label: "IPs", title: () => "Currently connected" }), (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, { + (xl || allInfo) && pair('ram', { label: "RAM", render: formatBytes }), + !xl && h(IconBtn, { size: 'small', icon: allInfo ? ChevronLeft : ChevronRight, title: "Show more", @@ -70,7 +73,7 @@ function MoreInfo() { } function pair(k: string, { label, minWidth, render, title, onDelete }: PairOptions={}) { - let v = _.get(status, k) + let v = _.get(connections, k) ?? _.get(status, k) if (v === undefined) return null let color: Color = undefined @@ -116,7 +119,7 @@ function Connections() { fullWidth: false, value: monitorOnlyFiles, onChange: v => state.monitorOnlyFiles = v, - options: { "Show only files": true, "Show all connections": false } + options: { "Show downloads+uploads": true, "Show all connections": false } }), ), h(DataTable, { diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index 04434b296..7f62b0e51 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -7,8 +7,8 @@ import { state, useSnapState } from './state' import { Link as RouterLink } from 'react-router-dom' import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material' import { Dict, MAX_TILE_SIZE, REPO_URL, isIpLocalHost, wait, with_, try_, ipForUrl, SORT_BY_OPTIONS, THEME_OPTIONS, - CFG, md } from './misc' -import { iconTooltip, InLink, LinkBtn, modifiedProps, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported } from './mui' + CFG, md, IMAGE_FILEMASK } from './misc' +import { iconTooltip, InLink, LinkBtn, propsForModifiedValues, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported } from './mui' import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form'; import { ArrayField } from './ArrayField' import FileField from './FileField' @@ -57,12 +57,12 @@ export default function OptionsPage() { } const maxDownloadsDefaults = { comp: NumberField, - min: 0, placeholder: "no limit", toField: (x: any) => x || '', sm: 4, } const httpsEnabled = values.https_port >= 0 + const isWindows = status?.platform === 'win32' return h(Form, { sx: { maxWidth: '60em' }, values, @@ -73,7 +73,7 @@ export default function OptionsPage() { onError: alertDialog, save: { onClick: save, - ...modifiedProps( Object.keys(changes).length>0), + ...propsForModifiedValues( Object.keys(changes).length>0), }, barSx: { gap: 2 }, addToBar: [ @@ -88,7 +88,7 @@ export default function OptionsPage() { component: RouterLink, to: "/edit", startIcon: h(EditNote), - }, sm ? "Edit config file" : "File"), + }, sm ? "Config file" : "File"), ], defaults() { return { sm: 6 } @@ -117,7 +117,19 @@ export default function OptionsPage() { helperText: "Not applied to localhost. Doesn't work with proxies." }, - { k: 'listen_interface', comp: SelectField, sm: 4, options: [{ label: "any", value: '' }, '127.0.0.1', '::1', ...status?.ips||[]] }, + { + k: 'listen_interface', + comp: SelectField, + sm: 4, + options: [ + { label: "any", value: '' }, + { label: "any IPv4", value: '0.0.0.0' }, + { label: "any IPv6", 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" }, @@ -133,25 +145,31 @@ export default function OptionsPage() { helperText: "Access Admin-panel without entering credentials" }, - { k: 'proxies', comp: NumberField, min: 0, max: 9, label: "Number of HTTP proxies", + { k: 'proxies', comp: NumberField, max: 9, label: "Number of HTTP proxies", placeholder: "none", error: proxyWarning(values, status), helperText: "Wrong number will prevent detection of users' IP address" }, - { k: 'allowed_referer', placeholder: "any", label: "Links from other websites", comp: AllowedReferer, }, + { k: 'allowed_referer', placeholder: "any", label: "Links from other websites", comp: AllowedReferer, + helperText: "In case another website is linking your files" }, - { k: 'block', label: false, comp: ArrayField, prepend: true, sm: true, + { k: 'block', label: false, comp: ArrayField, prepend: true, sm: true, autoRowHeight: 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" }, + { k: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: true, $width: 2, + $column: { mergeRender: { comment: {}, expire: {} } }, + helperText: h(WildcardsSupported) }, + { k: 'expire', $type: 'dateTime', minDate: new Date(), sm: 6, $hideUnder: 'sm', + helperText: "Leave empty for no expiration" }, { k: 'disabled', $type: 'boolean', label: "Enabled", + helperText: "In case you want to not block without deleting the rule", toField: (x: any) => !x, fromField: (x: any) => x ? undefined : true, sm: 6, + $width: 80, }, - { k: 'comment' }, + { k: 'comment', $hideUnder: 'sm' }, ], }, @@ -161,33 +179,39 @@ export default function OptionsPage() { }, { 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", 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: 'auto_play_seconds', comp: NumberField, xs: 6, sm: 3, min: 1, max: 10000, required: true, + 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, max: MAX_TILE_SIZE, required: true, + 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: true, label: "Sort numeric names" }, - { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.png|*.ico|*.jpg|*.jpeg|*.gif|*.svg', sm: 12, + { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, 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", + { k: 'dont_overwrite_uploading', comp: BoolField, md: 4, label: "Uploads don't overwrite", 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", + { k : CFG.split_uploads, comp: NumberField, unit: 'MB', md: 2, step: .1, + fromField: x => x * 1E6, toField: x => x ? x / 1E6 : null, + placeholder: "disabled", label: "Split uploads in chunks", helperText: "Overcome proxy limits" }, + { 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, sm: 4, md: 3, min : 0, unit: "MBytes", placeholder: "None", + { 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" }, 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: 'keep_session_alive', comp: BoolField, sm: 4, md: 6, 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: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 4, md: 3, unit: "seconds", required: true, + label: "Calculate ZIP size for", 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, + { k: 'descript_ion', comp: BoolField, ...isWindows && { sm: 4, md: 3 }, label: "Enable comments", helperText: "In file DESCRIPT.ION" }, + { k: 'show_hidden_files', comp: BoolField, sm: 4, md: 3 }, + { k: 'descript_ion_encoding', sm: 4, md: 6, 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", diff --git a/admin/src/VfsMenuBar.ts b/admin/src/VfsMenuBar.ts index 95b0fed7c..edf9e4026 100644 --- a/admin/src/VfsMenuBar.ts +++ b/admin/src/VfsMenuBar.ts @@ -2,7 +2,8 @@ import { createElement as h } from 'react' import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material' -import { Microsoft, Storage } from '@mui/icons-material' +import { Storage } from '@mui/icons-material' +import { osIcon } from './LogsPage' import { reloadVfs } from './VfsPage' import { prefix } from './misc' import { Btn, Flex, reloadBtn, useBreakpoint } from './mui' @@ -44,7 +45,7 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined }) const { data: integrated, reload } = useApi(isWindows && 'windows_integrated') const sm = useBreakpoint('sm') return !isWindows ? null : h(Btn, { - icon: Microsoft, + icon: osIcon('win'), variant: 'outlined', doneMessage: true, ...(!integrated?.is ? { diff --git a/admin/src/VfsTree.ts b/admin/src/VfsTree.ts index e7d44b6d8..1bc44e07e 100644 --- a/admin/src/VfsTree.ts +++ b/admin/src/VfsTree.ts @@ -1,7 +1,7 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt import { state, useSnapState } from './state' -import { createElement as h, ReactElement, useRef, useState } from 'react' +import { createElement as h, ReactElement, useEffect, useRef, useState } from 'react' import { TreeItem, TreeView } from '@mui/x-tree-view' import { ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock, RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff } from '@mui/icons-material' @@ -23,7 +23,11 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map() if (!vfs) return null + // be sure selected element is visible const treeId = 'vfs' + const first = selectedFiles[0] + useEffect(() => document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'center', behavior: 'instant' as any }), + [first]) return h(TreeView, { // @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef() ref, diff --git a/admin/src/addFiles.ts b/admin/src/addFiles.ts index ceddb50c5..b3695d549 100644 --- a/admin/src/addFiles.ts +++ b/admin/src/addFiles.ts @@ -7,7 +7,7 @@ import { reloadVfs } from './VfsPage' import { state } from './state' import { apiCall } from './api' import FilePicker from './FilePicker' -import { focusSelector } from '@hfs/shared' +import { focusSelector, pathEncode } from '@hfs/shared' let lastFolder: undefined | string export default function addFiles() { @@ -36,7 +36,7 @@ export default function addFiles() { h('li', { key: file }, file, ': ', err)) ) ), 'error') - const ids = res.filter(x => x.name).map(x => parent.id + encodeURI(x.name) + (x.link.endsWith('/') ? '/' : '')) + const ids = res.filter(x => x.name).map(x => parent.id + pathEncode(x.name) + (x.link.endsWith('/') ? '/' : '')) reloadVfs(ids) close() } @@ -53,7 +53,7 @@ export async function addVirtual() { const { id: parent } = getFolderFromSelected() const res = await apiCall('add_vfs', { parent, name }) await alertDialog(`Folder "${res.name}" created`, 'success') - reloadVfs([ parent + encodeURI(res.name) + '/' ]) + reloadVfs([ parent + pathEncode(res.name) + '/' ]) } catch(e) { await alertDialog(e as Error) @@ -64,7 +64,7 @@ export async function addLink() { try { const { id: parent } = getFolderFromSelected() const res = await apiCall('add_vfs', { parent, name: 'new link', url: 'https://example.com' }) - reloadVfs([ parent + encodeURI(res.name) ]) + reloadVfs([ parent + pathEncode(res.name) ]) toast("Link created", 'success', { onClose: () => focusSelector('input[name=url]') }) diff --git a/admin/src/api.ts b/admin/src/api.ts index 51d63ee09..e8bf8960f 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -32,7 +32,7 @@ export function useApiEx(...args: Parameters) { !args[0] ? null : res.error ? h(Alert, { severity: 'error' }, xlate(String(res.error), ERRORS), h(IconBtn, { icon: Refresh, title: "Reload", onClick: res.reload, sx: { m:'-10px 0 -8px 16px' } }) ) - : res.loading || res.data === undefined ? spinner() + : res.data === undefined ? spinner() : null, Object.values(res)) } diff --git a/admin/src/importAccountsCsv.ts b/admin/src/importAccountsCsv.ts index 0dd45437f..832f5455f 100644 --- a/admin/src/importAccountsCsv.ts +++ b/admin/src/importAccountsCsv.ts @@ -37,7 +37,7 @@ export async function importAccountsCsv(cb?: () => void) { save: { startIcon: h(Upload), children: 'Go' }, fields: [ h(Box, { p: 1 }, "Total lines:", rows.length), - { k: 'skipFirstLines', comp: NumberField, min: 0, max: rows.length-1, typing: true, md: 6, + { k: 'skipFirstLines', comp: NumberField, max: rows.length-1, typing: true, md: 6, helperText: h(Fragment, {}, "First line: ", h('code', {}, row.join(', ')) ), }, { k: 'overwriteExistingAccounts', comp: BoolField, md: 6 }, diff --git a/admin/src/index.scss b/admin/src/index.scss index 34c38cfae..3d8996a0b 100644 --- a/admin/src/index.scss +++ b/admin/src/index.scss @@ -67,10 +67,6 @@ h2.MuiDialogTitle-root { /* less padding */ } @keyframes animate-dash { to { background-position: 20px 0; } } -@keyframes blink { - 0% {opacity: 1} - 50% {opacity: 0.2} -} @keyframes success { 50% { transform: scale(1.5); color: var(--success); } 100% { transform: inherit; color: inherit; } diff --git a/admin/src/mui.ts b/admin/src/mui.ts index c541bb635..4703b42ac 100644 --- a/admin/src/mui.ts +++ b/admin/src/mui.ts @@ -3,8 +3,10 @@ import { PauseCircle, PlayCircle, Refresh, SvgIconComponent } from '@mui/icons-material' import { SxProps } from '@mui/system' -import { createElement as h, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef, - ForwardedRef, useState, useMemo } from 'react' +import { + createElement as h, forwardRef, Fragment, ReactElement, ReactNode, useCallback, useEffect, useRef, + ForwardedRef, useState, useMemo, isValidElement +} from 'react' import { Box, BoxProps, Breakpoint, ButtonProps, CircularProgress, IconButton, IconButtonProps, Link, LinkProps, Tooltip, TooltipProps, useMediaQuery } from '@mui/material' import { anyDialogOpen, closeDialog, formatPerc, isIpLan, isIpLocalHost, prefix, WIKI_URL, with_ } from './misc' @@ -99,7 +101,8 @@ export function reloadBtn(onClick: any, props?: any) { return h(IconBtn, { icon: Refresh, title: "Reload", onClick, ...props }) } -export function modifiedProps(modified: boolean | undefined) { +// modify look to convey that a form has been modified +export function propsForModifiedValues(modified: boolean | undefined) { return modified ? { sx: { outline: '2px solid' } } : undefined } @@ -122,7 +125,7 @@ export const IconBtn = forwardRef((props: IconBtnProps, ref: ForwardedRef { - icon?: SvgIconComponent + icon?: SvgIconComponent | ReactElement title?: ReactNode disabled?: boolean | string progress?: boolean | number @@ -140,12 +143,12 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, const [loadingState, setLoadingState] = useStateMounted(false) if (typeof disabled === 'string') title = disabled - disabled = loadingState || Boolean(progress) || disabled === undefined ? undefined : Boolean(disabled) + disabled = loadingState || progress || disabled ? true : undefined if (link) onClick = () => window.open(link) const showLabel = useBreakpoint(labelFrom || 'xs') const ref = useRefPass(forwarded) - const common = _.merge(modifiedProps(modified), { + const common = _.merge(propsForModifiedValues(modified), { ref, disabled, 'aria-hidden': disabled, @@ -159,9 +162,10 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, } }, } as const, rest) + const iconElement = isValidElement(icon) ? icon : (icon && h(icon)) let ret: ReactElement = children || !icon ? h(LoadingButton, _.merge({ variant: 'contained', - startIcon: icon && h(icon), + startIcon: iconElement, loading: Boolean(loading || loadingState || progress), loadingPosition: icon && 'start', loadingIndicator: typeof progress !== 'number' ? undefined @@ -174,7 +178,7 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, link, ...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null), style: { position:'absolute', top: '10%', left: '10%', width: '80%', height: '80%' } }), - h(icon) + iconElement, ) const aria = rest['aria-label'] ?? with_(_.isString(title) && title, x => x ? `${children || ''} (${x})` : undefined) diff --git a/admin/src/theme.ts b/admin/src/theme.ts index 8e0dabfa6..9fbbd6863 100644 --- a/admin/src/theme.ts +++ b/admin/src/theme.ts @@ -18,7 +18,7 @@ export function useMyTheme() { return useMemo(() => createTheme({ palette: lightMode || { mode: 'dark', - text: { primary: '#bbb', secondary: '#777' }, + text: { primary: '#bbb', secondary: '#fff6' }, primary: { main: '#469', light: '#68c' }, secondary: { main: '#969' }, }, diff --git a/admin/src/useBlockIp.ts b/admin/src/useBlockIp.ts index e478739ee..eade02404 100644 --- a/admin/src/useBlockIp.ts +++ b/admin/src/useBlockIp.ts @@ -1,6 +1,5 @@ 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' @@ -8,10 +7,6 @@ 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, { @@ -20,7 +15,10 @@ export function useBlockIp() { confirm: "Block address " + ip, ...isBlocked(ip) && { disabled: true, title: "Blocked" }, ...options, - onClick: () => block(ip, { comment }), + onClick() { + return apiCall('add_block', { ip, merge: { comment } }) + .then(reload).then(() => toast("Blocked", 'success')) + }, }), } } diff --git a/central.json b/central.json index 7aab99a7a..e2cf1fb88 100644 --- a/central.json +++ b/central.json @@ -1,26 +1,11 @@ { "dnsServers": ["1.1.1.1", "8.8.8.8"], - "checkServerServices": [ - { - "url": "https://ports.yougetsignal.com/check-port.php", - "headers": {"content-type": "application/x-www-form-urlencoded"}, - "method": "post", - "body": "remoteAddress=$IP&portNumber=$PORT", - "regexpFailure": "is closed", - "regexpSuccess": "is open" - }, - { - "url": "https://canyouseeme.org", - "headers": {"content-type": "application/x-www-form-urlencoded"}, - "method": "post", - "body": "port=$PORT", - "regexpFailure": "Error:", - "regexpSuccess": "Success:" - }, + "selfCheckServices": [ { - "url": "http://ifconfig.co/port/$PORT?ip=$IP", - "regexpFailure": "reachable\": false", - "regexpSuccess": "reachable\": true" + "url": "http://hfstest.rejetto.com/v3?url=$URL", + "headers": {"User-Agent": "HFS"}, + "regexpFailure": "\"error\"", + "regexpSuccess": "\"good\"" } ], "selfCheckServices_disabled": [ @@ -35,14 +20,6 @@ "regexpSuccess": "true" } ], - "selfCheckServices": [ - { - "url": "http://hfstest.rejetto.com/v3?url=$URL", - "headers": {"User-Agent": "HFS"}, - "regexpFailure": "\"error\"", - "regexpSuccess": "\"good\"" - } - ], "publicIpServices": [ "http://ipv4.icanhazip.com", { "v": 6, "type": "http", "url": "http://ipv6.icanhazip.com" }, diff --git a/config.md b/config.md index e766264cd..fc99fe27f 100644 --- a/config.md +++ b/config.md @@ -77,7 +77,7 @@ Configuration can be done in several ways - `proxies` number of proxies between server and clients to be trusted about providing clients' IP addresses. Default is 0. - `delete_unfinished_uploads_after` should unfinished uploads be deleted after a number of seconds. 0 for immediate, empty for never. Default is 1 day. - `favicon` path to file to be used as favicon. Default is none. -- `force_https` redirect http traffic to https. Requires https to be working. Default is false. +- `force_https` redirect http traffic to https. Requires https to be working. Default is true. - `force_lang` force translation for frontend. Default is none, meaning *let browser decide*. - `admin_net` net-mask specifying what addresses are allowed to access Admin-panel. Default is any. - `title` text displayed in the tab of your browser. Default is "File server". @@ -120,7 +120,10 @@ Configuration can be done in several ways - `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. +- `auto_basic` automatically detect (based on user-agent) when the basic web inteface should be served, to support legacy browsers. Default is true. No UI. +- `allow_session_ip_change` should requests of the same login session be allowed from different IP addresses. Default is false, to prevent cookie stealing. You can set it `true` to always allow it, or `https` to allow only on https, where stealing the cookie is harder. No UI. +- `authorization_header` support Authentication HTTP header. Default is true. No UI. +- `cache_control_disk_files` number of seconds after which the browser should bypass the cache and check the server for an updated version of the file. Default is 5. No UI. - `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) @@ -134,7 +137,7 @@ Valid keys in a node are: Value is a list and its entries are nodes. - `rename`: similar to name, but it's from the parent node point. Use this to change the name of entries that are read from the source, not listed in the VFS. - Value is a dictionary, where the key is the original name. + Value is a dictionary, where the key is the original name. No UI. - `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` diff --git a/dev-plugins.md b/dev-plugins.md index 4bcef8932..c66370e4c 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -11,6 +11,10 @@ Each plug-in has access to the same set of features. 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. +## Backend / Frontend + +Plugins can run both in backend (the server) and frontend (the browser). Frontend files reside in the "public" folder, while all the rest is backend. + ## Exported object `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 @@ -120,16 +124,20 @@ used must be strictly JSON (thus, no single quotes, only double quotes for strin - `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. - `customHtml: object | () => object` return custom-html sections programmatically. +- `customRest: { [name]: (parameters: object) => any }` declare backend functions to be called by frontend with `HFS.customRestCall` +- `customApi: { [name]: (parameters) => any }` declare functions to be called by other plugins (only backend, not frontend) using `api.customApiCall` (documented below) ### FieldDescriptor Currently, these properties are supported: -- `type: 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'real_path' | 'array' | 'username'ì` . Default is `string`. +- `type: 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'real_path' | 'vfs_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 - using `HFS.getPluginConfig()[CONFIG_KEY]` but also css can access it as `var(--PLUGIN_NAME-CONFIG_KEY)` + using `HFS.getPluginConfig()[CONFIG_KEY]` but also css can access it as `var(--PLUGIN_NAME-CONFIG_KEY)`. + Hint: if you need to use a numeric config in CSS but you need to add a unit (like `em`), + the trick is to use something like this `calc(var(--plugin-something) * 1em)`. Based on `type`, other properties are supported: - `string` @@ -154,6 +162,9 @@ Based on `type`, other properties are supported: - `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` + - `groups: undefined | boolean` true if you want only groups, false if you want only users. Default is undefined. + - `multiple: boolean` if you set this to true, the field will allow the selection of multiple accounts, + and the resulting value will be array of strings, instead of a string. Default is false. ## api object @@ -171,6 +182,13 @@ The `api` object you get as parameter of the `init` contains the following: - `log(...args)` print log in a standard form for plugins. +- `addBlock({ ip, expire?, comment?, disabled? }, merge?)` add a blocking rule on specified IP. You can use merge to + append the IP to an existing rule (if any, otherwise is created). Eg: + ```js + // try to append to existing rule, by comment + addBlock({ ip: '1.2.3.4' }, { comment: "banned by my plugin" }) + ``` + - `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. @@ -209,6 +227,10 @@ The `api` object you get as parameter of the `init` contains the following: - `notifyClient(channel: string, eventName: string, data?: any)` send a message to those frontends that are on the same channel. +- `misc` many functions and constants available in [misc.ts](https://github.com/rejetto/hfs/blob/main/src/misc.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. It's just a shorter version of `api.require('./misc')` + ## Front-end specific The following information applies to the default front-end, and may not apply to a custom one. @@ -254,6 +276,12 @@ The HFS objects contains many properties: - `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. +- `customRestCall(name: string, parameters?: object): Promise` call backend functions exported with `customRest`. +- `userBelongsTo(groupOrUsername: string): boolean` returns true if logged in account belongs to the specified group name. + Returns true if the specified name is the one of the logged in account. +- `DirEntry: class_constructor(n :string, otherProps?: DirEntry)` this is the class of the objects inside `HFS.state.list`; + in case you need to add to the list, do it by instantiating this class. E.g. `new HFS.DirEntry(name)` +- `fileShow(entry: DirEntry, options?: { startPlaying: true )` open file-show on the specified entry. 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. @@ -280,90 +308,111 @@ Some frontend-events can return Html, which can be expressed in several ways - 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. +These events will receive a `def` property (in addition event's specific properties), +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. - `additionalEntryDetails` - - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container. - - parameter `{ entry: Entry }` - - The `Entry` type is an object with the following properties: - - `name: string` name of the entry. - - `ext: string` just the extension part of the name, dot excluded and lowercase. - - `isFolder: boolean` true if it's a folder. - - `n: string` name of the entry, including relative path when searched in sub-folders. - - `uri: string` relative url of the entry. - - `s?: number` size of the entry, in bytes. It may be missing, for example for folders. - - `t?: Date` generic timestamp, combination of creation-time and modified-time. - - `c?: Date` creation-time. - - `m?: Date` modified-time. - - `p?: string` permissions missing - - `cantOpen: boolean` true if current user has no permission to open this entry - - `getNext/getPrevious: ()=>Entry` return next/previous Entry in list - - `getNextFiltered/getPreviousFiltered: ()=>Entry` as above, but considers the filtered-list instead - - `getDefaultIcon: ()=>ReactElement` produces the default icon for this entry - - output `Html` + - you receive each entry of the list, and optionally produce HTML code that will be added in the `entry-details` container. + - parameter `{ entry: DirEntry }` + + The `DirEntry` type is an object with the following properties: + - `name: string` name of the entry. + - `ext: string` just the extension part of the name, dot excluded and lowercase. + - `isFolder: boolean` true if it's a folder. + - `n: string` name of the entry, including relative path when searched in sub-folders. + - `uri: string` relative url of the entry. + - `s?: number` size of the entry, in bytes. It may be missing, for example for folders. + - `t?: Date` generic timestamp, combination of creation-time and modified-time. + - `c?: Date` creation-time. + - `m?: Date` modified-time. + - `p?: string` permissions missing + - `cantOpen: boolean` true if current user has no permission to open this entry + - `getNext/getPrevious: ()=>DirEntry` return next/previous DirEntry in list + - `getNextFiltered/getPreviousFiltered: ()=>DirEntry` as above, but considers the filtered-list instead + - `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 | null` return null if you want to hide this entry + - you receive each entry of the list, and optionally produce HTML code that will completely replace the entry row/slot. + - parameter `{ entry: DirEntry }` (refer above for DirEntry 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) - - output `Html` + - you receive each entry of the list, and optionally produce HTML code that will be added after the name of the entry. + - parameter `{ entry: DirEntry }` (refer above for DirEntry object) + - output `Html` - `entryIcon` - - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon. - - parameter `{ entry: Entry }` (refer above for Entry object) - - output `Html` + - you receive an entry of the list and optionally produce HTML that will be used in place of the standard icon. + - parameter `{ entry: DirEntry }` (refer above for DirEntry object) + - output `Html` - `beforeHeader` & `afterHeader` - - use this to produce content that should go right before/after the `header` part - - output `Html` + - use this to produce content that should go right before/after the `header` part + - output `Html` - `beforeLogin` - - no parameter - - output `Html` + - no parameter + - 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. - - parameter `{ entry: Entry, menu: FileMenuEntry[], props: FileMenuProp[] }` - - output `undefined | FileMenuEntry | FileMenuEntry[]` - ```typescript - interface FileMenuEntry { - id?: string, - label: ReactNode, - subLabel: ReactNode, - href?: string, // use this if you want your entry to be a link - icon?: string, // supports: emoji, name from a limited set - onClick?: () => (Promisable) // return false to not close menu dialog - //...rest is transfered to element, for example 'target', or 'title' - } - type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement - ``` - Example, if you want to remove the 'show' item of the menu: - ```typescript - HFS.onEvent('fileMenu', ({ entry, menu }) => { - const index = menu.findIndex(x => x.id === 'show') - if (index >= 0) - menu.splice(index, 1) - }) - ``` - or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })` + - 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. + - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }` + - output `undefined | FileMenuEntry | FileMenuEntry[]` + ```typescript + interface FileMenuEntry { + id?: string, + label: ReactNode, + subLabel: ReactNode, + href?: string, // use this if you want your entry to be a link + icon?: string, // supports: emoji, name from a limited set + onClick?: () => (Promisable) // return false to not close menu dialog + //...rest is transfered to element, for example 'target', or 'title' + } + type FileMenuProp = { id?: string, label: ReactNode, value: ReactNode } | ReactElement + ``` + Example, if you want to remove the 'show' item of the menu: + ```typescript + HFS.onEvent('fileMenu', ({ entry, menu }) => { + const index = menu.findIndex(x => x.id === 'show') + if (index >= 0) + menu.splice(index, 1) + }) + ``` + 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: DirEntry }` (refer above for DirEntry object) + - output `ReactComponent` +- `showPlay` + - emitted on each file played inside file-show. Use setCover if you want to customize the background picture. + - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }` - `menuZip` - - parameter `{ def: ReactNode }` - - output `Html` + - 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 }` +- `sortCompare` + - you can decide the order of entries by comparing two entries. + Return a negative value if entry `a` must appear before `b`, or positive if you want the opposite. + Return zero or any falsy value if you want to leave the order to what the user decided in his options. + - parameter `{ a: DirEntry, b: DirEntry }` + - output `number | undefined` +- `enableEntrySelection` + - selection of multiple entries is used for some standard actions like deletion or zip. + When none of such standard actions is permitted on an entry, its selection control (checkbox) is disabled. + If you want to override this behavior, because you have a custom action that makes use of the selection, return `true`. + - parameter `{ entry: DirEntry }` + - output `boolean` +- `entryToggleSelection` + - an entry is being un/selected + - parameter `{ entry: DirEntry }` + - can be prevented - All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place: +- `appendMenuBar` inside menu-bar, at the end - `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) @@ -400,8 +449,8 @@ 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". +Some events allow you to stop their default behavior, by returning `api.events.preventDefault`. +This is reported in the list below with the word "preventable". ```js api.events.on('deleting', ({ node }) => node.source.endsWith('.jpg')) @@ -417,7 +466,7 @@ This section is still partially documented, and you may need to have a look at t - parameters: { node, ctx } - called just before trying to delete a file or folder (which still may not exist and fail) - async supported - - stoppable + - preventable - `login` - `logout` - `attemptingLogin` @@ -443,11 +492,14 @@ This section is still partially documented, and you may need to have a look at t - `pluginStarted` - `uploadStart` - parameters: { ctx, writeStream } - - stoppable + - preventable - return: callback to call when upload is finished - `uploadFinished` - `publicIpsChanged` - parameters: { IPs, IP4, IP6, IPX } +- `newSocket` + - parameters: { socket,ip } + - preventable # Notifications (backend-to-frontend events) @@ -561,6 +613,8 @@ You can refer to these published plugins for reference, like Published plugins are required to specify the `apiRequired` property. +### Multiple versions + It is possible to publish different versions of the plugin to be compatible with different versions of HFS. To do that, just have your other versions in branches with name starting with `api`. HFS will scan through them in inverted alphabetical order searching for a compatible one. @@ -613,6 +667,20 @@ If you want to override a text regardless of the language, use the special langu ## API version history +- 9.6 (v0.54.0) + - frontend event: showPlay + - api.addBlock + - api.misc + - frontend event: paste + - exports.customRest + HFS.customRestCall + - config.type: vfs_path + - frontend event: sortCompare + - HFS.userBelongsTo + - HFS.DirEntry + - frontend event: appendMenuBar + - config.helperText: basic md formatting + - HFS.onEvent.setOrder + - backend event: newSocket - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/index.html b/frontend/index.html index 73c7cd028..5633ca678 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,6 +7,7 @@ + diff --git a/frontend/src/Breadcrumbs.ts b/frontend/src/Breadcrumbs.ts index edfff8ca1..85b362fb0 100644 --- a/frontend/src/Breadcrumbs.ts +++ b/frontend/src/Breadcrumbs.ts @@ -1,12 +1,13 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { Link } from 'react-router-dom' +import { Link, LinkProps } from 'react-router-dom' import { createElement as h, Fragment, ReactElement } from 'react' import { getPrefixUrl, hIcon } from './misc' import { DirEntry, state, useSnapState } from './state' import { usePath, reloadList } from './useFetchList' import { useI18N } from './i18n' import { openFileMenu } from './fileMenu' +import { createFolder } from './upload' export function Breadcrumbs() { const base = getPrefixUrl() + '/' @@ -16,19 +17,22 @@ export function Breadcrumbs() { const breadcrumbs = currentPath ? currentPath.split('/').map(x => [prev += x + '/', decodeURIComponent(x)]) : [] const {t} = useI18N() return h(Fragment, {}, - h(Breadcrumb, { label: hIcon('parent', { alt: t`parent folder` }), path: parent }), - h(Breadcrumb, { label: hIcon('home', { alt: t`home` }), path: base, current: !currentPath }), + h(Breadcrumb, { id: 'breadcrumb-parent', label: hIcon('parent', { alt: t`parent folder` }), path: parent }), + h(Breadcrumb, { id: 'breadcrumb-home', label: hIcon('home', { alt: t`home` }), path: base, current: !currentPath }), breadcrumbs.map(([path,label], i) => h(Breadcrumb, { key: path, path, label, - current: i === breadcrumbs.length - 1, + ...i === breadcrumbs.length - 1 && { + current: true, + label: h(Fragment, {}, label, hIcon('menu', { style: { position: 'relative', top: 1, marginLeft: '.3em' } })) + }, }) ) ) } -function Breadcrumb({ path, label, current, title }:{ current?: boolean, path: string, label?: string | ReactElement, title?: string }) { +function Breadcrumb({ path, label, current, ...rest }: { current?: boolean, path: string, label?: string | ReactElement } & Omit) { const PAD = '\u00A0\u00A0' // make small elements easier to tap. Don't use min-width 'cause it requires display-inline that breaks word-wrapping if (typeof label === 'string' && label.length < 3) label = PAD + label + PAD @@ -38,11 +42,17 @@ function Breadcrumb({ path, label, current, title }:{ current?: boolean, path: s return h(Link, { className: 'breadcrumb', to: path || '/', - title, + ...rest, async onClick(ev) { if (!current) return ev.preventDefault() openFileMenu(new DirEntry(decodeURIComponent(path), { p }), ev, [ + props?.can_upload && { + id: 'create-folder', + label: t`Create folder`, + icon: 'folder', + onClick: createFolder, + }, { id: 'reload', label: t`Reload`, diff --git a/frontend/src/BrowseFiles.ts b/frontend/src/BrowseFiles.ts index d4c806b26..f57ca0899 100644 --- a/frontend/src/BrowseFiles.ts +++ b/frontend/src/BrowseFiles.ts @@ -1,10 +1,10 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { Link } from 'react-router-dom' +import { Link, useNavigate } 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, getHFS } from './misc' +import { useEventListener, useMediaQuery, useWindowSize } from 'usehooks-ts' +import { domOn, formatBytes, ErrorMsg, hIcon, onlyTruthy, noAriaTitle, prefix, isMac, isCtrlKey, hfsEvent } from './misc' import { Checkbox, CustomCode, iconBtn, Spinner } from './components' import { Head } from './Head' import { DirEntry, state, useSnapState } from './state' @@ -16,7 +16,7 @@ import _ from 'lodash' import { t, useI18N } from './i18n' import { makeOnClickOpen, openFileMenu } from './fileMenu' import { ClipBar } from './clip' -import { fileShow, getShowType } from './show' +import { fileShow, getShowComponent } from './show' export const MISSING_PERM = "Missing permission" @@ -63,13 +63,18 @@ function FilesList() { const theList = filteredList || list const total = theList.length const nPages = Math.ceil(total / pageSize) + const pageEnd = offset + pageSize * (1+extraPages) + const thisPage = theList.slice(offset, pageEnd) - useEffect(() => setPage(0), [theList[0]]) + useEffect(() => setPage(0), [theList[0]]) // reset page if the list changes + // reset scrolling if the page changes useEffect(() => { document.scrollingElement?.scrollTo(0, 0) setExtraPages(0) setScrolledPages(0) }, [page]) + + // infinite scrolling const calcScrolledPages = useMemo(() => _.throttle(() => { const i = _.findLastIndex(document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS), el => @@ -86,6 +91,45 @@ function FilesList() { calcScrolledPages() }), [page, extraPages, nPages]) + // type to focus + const [focus, setFocus] = useState('') + useEffect(() => setFocus(''), [theList]) // reset + const navigate = useNavigate() + const timeout = useRef() + useEventListener('keydown', ev => { + if (ev.target !== document.body && !(ev.target && ref.current?.contains(ev.target as any))) return + if (isCtrlKey(ev as any) === 'Backspace' && location.pathname > '/') + return navigate(location.pathname + '..') + const { key } = ev + if (ev.metaKey || ev.ctrlKey || ev.altKey) return + setFocus(was => { + const will = key === 'Backspace' ? was.slice(0, -1) + : key === 'Escape' || key === 'Tab' ? '' + : key.length === 1 ? was + key.toLocaleLowerCase() + : was + if (will !== was) { + clearTimeout(timeout.current) + timeout.current = setTimeout(() => setFocus(''), 5_000) as any + } + return will + }) + }) + const focusIndex = useMemo(() => { + if (!focus) return -1 + const match = (x: typeof theList[0]) => x.name.toLocaleLowerCase().normalize().startsWith(focus) + const inThisPage = thisPage.findIndex(match) // first attempt within this page + if (inThisPage >= 0) + return inThisPage + offset + const i = theList.findIndex(match) // search again on whole list + if (i >= 0) + setPage(Math.floor(i / pageSize)) + return i + }, [focus]) + useEffect(() => { // wait for possible page-change before focusing + if (focusIndex >= 0) + (document.querySelector(`a[href="${theList[focusIndex]?.uri}"]`) as HTMLElement)?.focus() + }, [focusIndex]) + const ref = useRef() const [goBottom, setGoBottom] = useState(false) @@ -94,7 +138,8 @@ function FilesList() { setGoBottom(false) window.scrollTo(0, document.body.scrollHeight) }, [goBottom]) - const pageChange = useCallback((i: number, pleaseGoBottom?: boolean) => { + const changePage = useCallback((i: number, pleaseGoBottom?: boolean) => { + setFocus('') if (pleaseGoBottom) setGoBottom(true) if (i < page || i > page + extraPages) @@ -111,9 +156,10 @@ function FilesList() { : filteredList && !filteredList.length && t('filter_none', "No match for this filter") return h(Fragment, {}, + focus && h('div', { id: 'focus-typing', className: focusIndex < 0 ? 'focus-typing-mismatch' : '' }, focus, hIcon('info', { style: { cursor: 'default' }, title: `ESC: ${t`Cancel`}` })), h('ul', { ref, className: 'dir' }, msgInstead ? h('p', {}, msgInstead) - : theList.slice(offset, offset + pageSize * (1+extraPages)).map((entry, idx) => + : thisPage.map((entry, idx) => h(Entry, { key: entry.key || entry.n, midnight, @@ -127,7 +173,7 @@ function FilesList() { current: page + scrolledPages, atBottom, pageSize, - pageChange, + changePage, }) ) } @@ -137,9 +183,9 @@ interface PagingProps { current: number atBottom: boolean pageSize: number - pageChange:(newPage:number, goBottom?:boolean) => void + changePage: (newPage:number, goBottom?:boolean) => void } -const Paging = memo(({ nPages, current, pageSize, pageChange, atBottom }: PagingProps) => { +const Paging = memo(({ nPages, current, pageSize, changePage, atBottom }: PagingProps) => { useEffect(() => { document.body.style.overflowY = 'scroll' return () => { document.body.style.overflowY = '' } @@ -153,7 +199,7 @@ const Paging = memo(({ nPages, current, pageSize, pageChange, atBottom }: Paging h('button', { title: t('go_first', "Go to first item"), className: !current ? 'toggled' : undefined, - onClick() { pageChange(0) }, + onClick() { changePage(0) }, }, hIcon('to_start')), h('div', { id: 'paging-middle' }, // using sticky first/last would prevent scrollIntoView from working _.range(1, nPages).map(i => @@ -161,13 +207,13 @@ const Paging = memo(({ nPages, current, pageSize, pageChange, atBottom }: Paging && h('button', { key: i, ...i === current && { className: 'toggled', ref }, - onClick: () => pageChange(i), + onClick: () => changePage(i), }, shrink && !(i%10) ? (i/10) + 'K' : i * pageSize) ) ), h('button', { title: t('go_last', "Go to last item"), className: atBottom ? 'toggled' : undefined, - onClick(){ pageChange(nPages-1, true) } + onClick(){ changePage(nPages-1, true) } }, hIcon('to_end')), ) }) @@ -195,7 +241,7 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => { const { uri, isFolder, name, n } = entry const { showFilter, selected, file_menu_on_link } = useSnapState() const isLink = Boolean(entry.url) - const containerName = n.slice(0, -name.length) + const containerName = n.slice(0, -name.length - (isFolder ? 1 : 0)).replaceAll('/', '/ ') let className = isFolder ? 'folder' : 'file' if (entry.cantOpen) className += ' cant-open' @@ -212,10 +258,11 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => { entry, render: x => x ? h('li', { className, label: separator }, x) : _.remove(state.list, { n }) && null }, showFilter && h(Checkbox, { - disabled: isLink, + disabled: !entry.canSelect(), 'aria-labelledby': ariaId, - value: selected[uri], + value: selected[uri] || false, onChange(v) { + if (hfsEvent('entryToggleSelection', { entry }).isDefaultPrevent()) return if (v) return state.selected[uri] = true delete state.selected[uri] @@ -225,7 +272,7 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => { // 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 + ico, h('span', { className: 'container-folder' }, containerName), name), // 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', @@ -248,7 +295,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 && getShowType(entry)) + if (special && getShowComponent(entry)) return fileShow(entry, { startPlaying: true }) openFileMenu(entry, ev, onlyTruthy([ file_menu_on_link && 'open', diff --git a/frontend/src/FilterBar.ts b/frontend/src/FilterBar.ts index 8e5ea040f..ff0bb72bd 100644 --- a/frontend/src/FilterBar.ts +++ b/frontend/src/FilterBar.ts @@ -4,7 +4,7 @@ import { useDebounce } from 'usehooks-ts' import { Checkbox } from './components' import { useI18N } from './i18n' import { usePath } from './useFetchList' -import { with_ } from './misc' +import { getHFS, with_ } from './misc' export function FilterBar() { const { list, filteredList, selected, patternFilter, showFilter } = useSnapState() @@ -14,13 +14,8 @@ export function FilterBar() { const {t} = useI18N() state.patternFilter = useDebounce(showFilter ? filter : '', 300) + useEffect(() => getHFS().onEvent('entryToggleSelection', () => setAll(false)), []) - const sel = Object.keys(selected).length - const fil = filteredList?.length - useEffect(() => { - if (all && sel < (fil || list.length)) - setAll(false) - }, [sel]) const tabIndex = showFilter ? undefined : -1 return h('div', { id: 'filter-bar', style: { display: showFilter ? undefined : 'none' } }, h(Checkbox, { @@ -49,19 +44,20 @@ export function FilterBar() { } }), h('span', {}, [ - sel && t('select_count', { n:sel }, "{n} selected"), - fil !== undefined && fil < list.length && t('filter_count', {n:fil}, "{n} filtered"), + with_(Object.keys(selected).length, n => n && t('select_count', { n }, "{n} selected")), + with_(filteredList?.length, n => n !== undefined && n < list.length && t('filter_count', {n}, "{n} filtered")), ].filter(Boolean).join(', ') ), ) - function select(will: boolean | undefined) { + function select(will: boolean | undefined) { // undefined will cause toggle of each element const sel = state.selected - for (const { uri } of state.filteredList || state.list) { + for (const e of state.filteredList || state.list) { + const { uri } = e const was = sel[uri] || false if (was === will) continue if (was) delete sel[uri] - else + else if (e.canSelect()) sel[uri] = true } if (will !== undefined) diff --git a/frontend/src/clip.ts b/frontend/src/clip.ts index 562139540..6efd72401 100644 --- a/frontend/src/clip.ts +++ b/frontend/src/clip.ts @@ -8,6 +8,7 @@ import { dirname, HTTP_MESSAGES, xlate } from '../../src/cross' import { apiCall } from '@hfs/shared/api' import { reloadList, usePath } from './useFetchList' import _ from 'lodash' +import { hfsEvent } from './misc' export function ClipBar() { const { clip, props } = useSnapState() @@ -43,6 +44,7 @@ export function ClipBar() { } function paste() { + if (hfsEvent('paste', { from: state.clip, to: here }).isDefaultPrevent()) return return apiCall('move_files', { uri_from: clip.map(x => x.uri), uri_to: here, @@ -61,7 +63,6 @@ export function ClipBar() { } } - export function cut(files: DirList) { state.clip = files if (files.length) diff --git a/frontend/src/components.ts b/frontend/src/components.ts index c7126fd51..9d615d181 100644 --- a/frontend/src/components.ts +++ b/frontend/src/components.ts @@ -1,9 +1,11 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt import { Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix } from './misc' -import { ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment, +import { + ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment, HTMLAttributes, InputHTMLAttributes, isValidElement, MouseEventHandler, ReactNode, SelectHTMLAttributes, - useMemo, useState, ComponentPropsWithoutRef } from 'react' + useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes +} from 'react' import _ from 'lodash' import { t } from './i18n' @@ -38,19 +40,22 @@ export const FlexV = forwardRef((props: FlexProps, ref) => h(Flex, { ref, vert: interface CheckboxProps extends Omit>, 'onChange'> { children?: ReactNode, - value: any, + value?: any, onChange?: (v: boolean, ev: ChangeEvent) => void + labelProps?: LabelHTMLAttributes } -export function Checkbox({ onChange, value, children, ...props }: CheckboxProps) { + +export const Checkbox = forwardRef(({ onChange, value, children, labelProps, ...props }: CheckboxProps, ref) => { const ret = h('input', { + ref, type: 'checkbox', - onChange: ev => onChange?.(Boolean(ev.target.checked), ev), - checked: Boolean(value), + onChange: (ev: ChangeEvent) => onChange?.(Boolean(ev.target.checked), ev), + ...value !== undefined && { checked: Boolean(value) }, value: 1, ...props }) - return !children ? ret : h('label', {}, ret, children) -} + return !children ? ret : h('label', labelProps || {}, ret, children) +}) interface SelectProps extends Omit, 'value' | 'onChange'> { value: T, // just string for the time being diff --git a/frontend/src/dialog.ts b/frontend/src/dialog.ts index 92aba4570..558b985de 100644 --- a/frontend/src/dialog.ts +++ b/frontend/src/dialog.ts @@ -92,6 +92,24 @@ export async function promptDialog(msg: string, { value, type, helperText, trim= } } +export async function formDialog({ ...rest }: DialogOptions): Promise { + return new Promise(resolve => { + const { close } = newDialog({ + className: 'dialog-form', + ...rest, + onClose: resolve, + Content() { + return h('form', { + onSubmit(ev: any) { + ev.preventDefault() + close(Object.fromEntries(new FormData(ev.target).entries())) + }, + }, h(rest.Content)) + } + }) + }) +} + export type AlertType = 'error' | 'warning' | 'info' export function alertDialog(msg: ReactElement | string | Error, type:AlertType='info') { diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 900830baf..de372d1bd 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -1,7 +1,7 @@ import { t, useI18N } from './i18n' import { dontBotherWithKeys, formatBytes, getHFS, hfsEvent, hIcon, newDialog, prefix, with_, working, - pathEncode, closeDialog, anyDialogOpen + pathEncode, closeDialog, anyDialogOpen, Falsy } from './misc' import { createElement as h, Fragment, isValidElement, MouseEvent, ReactNode } from 'react' import _ from 'lodash' @@ -9,8 +9,8 @@ import { getEntryIcon, MISSING_PERM } from './BrowseFiles' import { DirEntry, state } from './state' import { deleteFiles } from './menu' import { Link, LinkProps } from 'react-router-dom' -import { fileShow, getShowType } from './show' -import { alertDialog, promptDialog } from './dialog' +import { fileShow, getShowComponent } from './show' +import { alertDialog, promptDialog, toast } from './dialog' import { apiCall, useApi } from '@hfs/shared/api' import { inputComment } from './upload' import { cut } from './clip' @@ -26,13 +26,12 @@ interface FileMenuEntry { onClick?: (ev:MouseEvent) => any } -export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMenuEntry | 'open' | 'delete' | 'show')[]) { +export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy | FileMenuEntry | 'open' | 'delete' | 'show')[]) { const { uri, isFolder, s } = entry const canRead = !entry.p?.includes('r') - const canArchive = entry.p?.includes('A') || state.props?.can_archive && !entry.p?.includes('a') const canList = !entry.p?.match(/L/i) const forbidden = entry.cantOpen === DirEntry.FORBIDDEN - const cantDownload = forbidden || isFolder && !(canRead && canArchive && canList) // folders needs list+read+archive + const cantDownload = forbidden || isFolder && !(canRead && entry.canArchive() && canList) // folders needs list+read+archive const menu = [ !cantDownload && { id: 'download', label: t`Download`, href: uri + (isFolder ? '?get=zip' : '?dl'), icon: 'download' }, state.props?.can_comment && { id: 'comment', label: t`Comment`, icon: 'comment', onClick: () => editComment(entry) }, @@ -50,14 +49,14 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe return !isFolder || open.onClick ? open : h(LinkClosingDialog, { to: uri, reloadDocument: entry.web }, hIcon(open.icon), open.label) } if (x === 'delete') - return (state.props?.can_delete || entry.p?.includes('d')) && { + return entry.canDelete() && { id: 'delete', label: t`Delete`, icon: 'delete', onClick: () => deleteFiles([entry.uri]) } if (x === 'show') - return !entry.cantOpen && getShowType(entry) && { + return !entry.cantOpen && getShowComponent(entry) && { id: 'show', label: t`Show`, icon: 'image', @@ -79,14 +78,13 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe id: 'folder', label: t`Folder`, value: h(Link, { - to: (folder.startsWith('/') ? '' : location.pathname) + folder.split('/').map(encodeURIComponent).join('/') + '/', + to: (folder.startsWith('/') ? '' : location.pathname) + pathEncode(folder) + '/', onClick: () => closeDialog(null, true) }, folder.replaceAll('/', ' / ')) }, ].filter(Boolean) const res = hfsEvent('fileMenu', { entry, menu, props }) - if (res) - menu.push(...res.flat()) + menu.push(...res.flat()) // flat because each plugin may return an array of entries const ico = getEntryIcon(entry) const { close } = newDialog({ title: isFolder ? t`Folder menu` : t`File menu`, @@ -99,7 +97,8 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe const {t} = useI18N() const details = useApi('get_file_details', { uris: [entry.uri] }).data?.details?.[0] const showProps = [ ...props, - with_(details?.upload, x => x && { id: 'uploader', label: t`Uploader`, value: x.ip + prefix(' (', x.username, ')') }) + with_(renderUploaderFromDetails(details), value => + value && { id: 'uploader', label: t`Uploader`, value }) ] return h(Fragment, {}, h('dl', { className: 'file-dialog-properties' }, @@ -147,30 +146,28 @@ async function rename(entry: DirEntry) { if (!dest) return const { n, uri } = entry await apiCall('rename', { uri, dest }, { modal: working }) - const renamingCurrentFolder = uri === location.pathname - if (!renamingCurrentFolder) { - // update state instead of re-getting the list - const newN = n.replace(/(.*?)[^/]+(\/?)$/, (_,before,after) => before + dest + after) - const newEntry = new DirEntry(newN, { key: n, ...entry }) // by keeping old key, we avoid unmounting the element, that's causing focus lost - const i = _.findIndex(state.list, { n }) - state.list[i] = newEntry - // update filteredList too - const j = _.findIndex(state.filteredList, { n }) - if (j >= 0) - state.filteredList![j] = newEntry - } - alertDialog(t`Operation successful`).then(() => { - if (renamingCurrentFolder) - getHFS().navigate(uri + '../' + pathEncode(dest) + '/') - }) + const MSG = t`Operation successful` + if (uri === location.pathname) //current folder + return alertDialog(MSG).then(() => + getHFS().navigate(uri + '../' + pathEncode(dest) + '/') ) + // update state instead of re-getting the list + const newN = n.replace(/(.*?)[^/]+(\/?)$/, (_,before,after) => before + dest + after) + const newEntry = new DirEntry(newN, { key: n, ...entry }) // by keeping old key, we avoid unmounting the element, that's causing focus lost + const i = _.findIndex(state.list, { n }) + state.list[i] = newEntry + // update filteredList too + const j = _.findIndex(state.filteredList, { n }) + if (j >= 0) + state.filteredList![j] = newEntry + toast(MSG, 'success') } async function editComment(entry: DirEntry) { const res = await inputComment(entry.name, entry.comment) - if (res === null) return + if (res === undefined) return await apiCall('comment', { uri: entry.uri, comment: res }, { modal: working }) updateEntry(entry, e => e.comment = res) - alertDialog(t`Operation successful`) + toast(t`Operation successful`, 'success') } function updateEntry(entry: DirEntry, cb: (e: DirEntry) => unknown) { @@ -200,4 +197,10 @@ export function makeOnClickOpen(entry: DirEntry) { return setTimeout(() => getHFS().navigate(entry.uri)) // couldn't find the reason why navigating sync is reverted back location.href = entry.uri } +} + +function renderUploaderFromDetails(details: any) { + if (!details) return + const { upload: u } = details + return u && `${u.username||''}${prefix('@', u.ip)}` } \ No newline at end of file diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index b333640d4..815b45b8a 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -2,53 +2,7 @@ import { state, useSnapState } from './state' import { createElement as h, memo } from 'react' - -const SYS_ICONS: Record = { // false means we don't have the icon, only unicode - login: ['👤','user'], - user: ['👤','user'], - filter: ['✂'], - search: ['🔍'], - search_off: ['❌','cancel'], - close: ['❌','cancel'], - error: ['❌','cancel'], - stop: ['⏹️'], - settings: ['⚙','cog'], - archive: ['📦'], - logout: ['🚪'], - home: ['🏠'], - parent: ['⬅','left'], - folder: ['📂'], - file: ['📄','doc'], - spinner: ['🎲','spin6 spinner'], - password: ['🗝️','key'], - download: ['⬇️'], - upload: ['⬆️'], - reload: ['🔄','reload'], - lock: ['🔒','lock'], - admin: ['👑','crown'], - check: ['✔️'], - to_start: ['◀'], - to_end: ['▶'], - menu: ['☰'], - list: ['☰','menu'], - play: ['▶'], - pause: ['⏸'], - edit: ['✏️'], - zoom: ['↔'], - delete: ['🗑️', 'trash'], - comment: ['💬'], - link: ['↗'], - info: ['ⓘ', false], - cut: ['✄'], - paste: ['📋'], - shuffle: ['🔀'], - repeat: ['🔁', 'reload'], - success: ['👍', 'check'], - warning: ['⚠️', false], - audio: ['🎧'], - video: ['🎥'], - image: ['📸'], -} +import { SYS_ICONS } from './sysIcons' const documentComplete = document.readyState === 'complete' ? Promise.resolve() : new Promise(res => document.addEventListener('readystatechange', res)) @@ -69,7 +23,7 @@ export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => { className += ' icon' const nameIsTheIcon = name.length === 1 || name.match(/^[\uD800-\uDFFF\u2600-\u27BF\u2B00-\u2BFF\u3030-\u303F\u3297\u3299\u00A9\u00AE\u200D\u20E3\uFE0F\u2190-\u21FF\u2300-\u23FF\u2400-\u243F\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF]*$/) - const nameIsFile = !nameIsTheIcon && name.includes('.') + const nameIsFile = !nameIsTheIcon && /[.?]/.test(name) const isFontIcon = iconsReady && clazz className += nameIsFile ? ' file-icon' : isFontIcon ? ` fa-${clazz}` : ' emoji-icon' return h('span',{ diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 8c63da6f1..902c80c64 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -99,12 +99,6 @@ select { text-align: center; } // it is surely cooler on the options dialog [class^="fa-"]:before, [class*=" fa-"]:before { /* don't need extra margin on fontello icons */ margin: 0; } -.icon { - font-size: 1.2em; - height: 1.2em; width: 1.4em; // give similar size to icons - - display: inline-block; text-align: center; // same size for font icons -} img.file-icon { height: 1em; } .file-icon { background-size: contain; @@ -213,10 +207,6 @@ kbd { .ani-working { animation:1s blink infinite } -@keyframes blink { - 0% {opacity: 1} - 50% {opacity: 0.2} -} @keyframes spin { 100% { transform: rotate(360deg); } } @@ -268,6 +258,12 @@ kbd { input[type=checkbox] { margin-top: .3em; } span:empty { display:none } /* avoid flex-gap */ } +#login-options { + font-size: smaller; + input[type=checkbox] { + transform: scale(1.5); + } +} ul.dir { padding: 0; @@ -501,6 +497,8 @@ button .icon + .label { } } +form label+input { margin-top: .2em; } + .miss-perm { margin: 0 0.3em } .popup-menu-button { @@ -552,6 +550,20 @@ button .icon + .label { li:first-of-type { margin-top: .5em } } +#focus-typing { + position: fixed; + top: 0; + right: 0; + background: var(--bg); + z-index: 3; + padding: .1em .5em; + border: 1px solid currentColor; + margin: -1px; +} +.focus-typing-mismatch { + color: var(--error); +} + .tiles-mode { max-width: none; @media (min-width: 42em) { @@ -711,12 +723,13 @@ button .icon + .label { background-repeat: no-repeat; background-size: contain; background-position: center; - z-index: -1; // otherwise firefox - opacity: .3; + z-index: -1; // otherwise firefox (forgot to finished this comment) + opacity: .4; transition: opacity 1s; &.none { opacity: 0; } } - audio.showing { min-width: 60%; } + audio.showing { min-width: 60%; transition: opacity 1s; } + .cover:not(.none)+audio { opacity: .7; } audio.showing, // even on mobile, we need the whole thing to control it decently, and nav-s don't seem to be a problem img.showing { max-width: 100% } // we are ok with overlapping for simple images .main { diff --git a/frontend/src/login.ts b/frontend/src/login.ts index 157b43f11..fc73f5706 100644 --- a/frontend/src/login.ts +++ b/frontend/src/login.ts @@ -5,16 +5,16 @@ import { state, useSnapState } from './state' import { alertDialog, newDialog, toast } from './dialog' import { getHFS, hIcon, makeSessionRefresher, srpClientSequence, working, fallbackToBasicAuth, - HTTP_CONFLICT, HTTP_UNAUTHORIZED, + HTTP_CONFLICT, HTTP_UNAUTHORIZED, CFG, } from './misc' import { createElement as h, Fragment, useEffect, useRef } from 'react' import { t, useI18N } from './i18n' import { reloadList } from './useFetchList' -import { CustomCode } from './components' +import { Checkbox, CustomCode } from './components' -async function login(username:string, password:string) { +async function login(username:string, password:string, extra?: object) { const stopWorking = working() - return srpClientSequence(username, password, apiCall).then(res => { + return srpClientSequence(username, password, apiCall, extra).then(res => { stopWorking() refreshSession(res) state.loginRequired = false @@ -63,6 +63,7 @@ export async function loginDialog(closable=true, reloadAfter=true) { Content() { const usrRef = useRef() const pwdRef = useRef() + const ipRef = useRef() useEffect(() => { setTimeout(() => usrRef.current?.focus()) // setTimeout workarounds problem due to double-mount while in dev }, []) @@ -99,6 +100,10 @@ export async function loginDialog(closable=true, reloadAfter=true) { ), h('div', { style: { textAlign: 'right' } }, h('button', { type: 'submit' }, t`Continue`)), + h('div', { id: 'login-options' }, + h(Checkbox, { ref: ipRef }, + t('allow_session_ip_change', "Allow IP change during this session")), + ), ) function onKeyDown(ev: KeyboardEvent) { @@ -116,8 +121,10 @@ export async function loginDialog(closable=true, reloadAfter=true) { if (going || !usr || !pwd) return going = true try { - const res = await login(usr, pwd) - close(true) + const res = await login(usr, pwd, { + [CFG.allow_session_ip_change]: ipRef.current?.checked + }) + await close(true) toast(t`Logged in`, 'success') if (res?.redirect) setTimeout(() => // workaround: the history.back() issued by closing the dialog is messing with our navigation diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts index 3cd26594c..2bffe62f0 100644 --- a/frontend/src/menu.ts +++ b/frontend/src/menu.ts @@ -2,9 +2,11 @@ import { state, useSnapState } from './state' import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react' -import { alertDialog, confirmDialog, ConfirmOptions, promptDialog, toast } from './dialog' -import { defaultPerms, err2msg, ErrorMsg, onlyTruthy, prefix, throw_, useStateMounted, VfsPerms, working, - buildUrlQueryString} from './misc' +import { alertDialog, confirmDialog, ConfirmOptions, formDialog, toast } from './dialog' +import { + defaultPerms, err2msg, ErrorMsg, onlyTruthy, prefix, useStateMounted, VfsPerms, working, + buildUrlQueryString, hIcon, WIKI_URL +} from './misc' import { loginDialog } from './login' import { showOptions } from './options' import showUserPanel from './UserPanel' @@ -19,7 +21,7 @@ import { cut } from './clip' import { Btn, BtnProps, CustomCode } from './components' export function MenuPanel() { - const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props } = useSnapState() + const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props, searchOptions } = useSnapState() const { can_upload, can_delete, can_archive } = props ? { ...defaultPerms, ...props } : {} as VfsPerms const { uploading, qs } = useSnapshot(uploadState) useEffect(() => { @@ -116,6 +118,7 @@ export function MenuPanel() { } } })), + h(CustomCode, { name: 'appendMenuBar' }), ), remoteSearch && h('div', { id: 'searched' }, (stopSearch ? t`Searching` : t`Searched`) + ': ' + remoteSearch + prefix(' (', searchManuallyInterrupted && t`interrupted`, ')')), @@ -143,11 +146,35 @@ export function MenuPanel() { icon: 'search', label: t`Search`, onClickAnimation: false, - async onClick() { - state.remoteSearch = await promptDialog(t('search_msg', "Search this folder and sub-folders"), - { title: t`Search`, onSubmit: x => x.includes('/') ? throw_(t`Invalid value`) : x }) || '' + onClick: () => formDialog({ + title: t`Search`, + Content: () => h('div', {}, + h('label', { htmlFor: 'text' }, t('search_msg', "Search this folder and sub-folders")), + h('input', { + name: 'text', + style: { width: 0, minWidth: '100%', maxWidth: '100%', boxSizing: 'border-box' }, + autoFocus: true, + }), + h('div', { style: { margin: '1em 0' } }, + h('input', { + type: 'checkbox', + name: 'wild', + defaultChecked: searchOptions.wild, + style: { marginRight: '1em' }, + }), + "Wildcards", + h('a', { href: `${WIKI_URL}Wildcards`, target: 'doc' }, hIcon('info')), + ), + h('div', { style: { textAlign: 'right', marginTop: '.8em' } }, + h('button', {}, t`Continue`)), + ) + }).then(res => { + if (!res) return + const { text='', wild, ...rest } = res + state.searchOptions = { ...rest, wild: Boolean(wild) } + state.remoteSearch = text state.stopSearch?.() - } + }) } } } diff --git a/frontend/src/misc.ts b/frontend/src/misc.ts index 306c5d48b..241861614 100644 --- a/frontend/src/misc.ts +++ b/frontend/src/misc.ts @@ -4,11 +4,11 @@ import React, { createElement as h } from 'react' import { iconBtn, Spinner } from './components' import { newDialog, toast } from './dialog' import { Icon } from './icons' -import { Dict, domOn, getHFS, Html, HTTP_MESSAGES, useBatch } from '@hfs/shared' +import { Callback, Dict, domOn, getHFS, Html, HTTP_MESSAGES, useBatch } from '@hfs/shared' import * as cross from '../../src/cross' import * as shared from '@hfs/shared' import { apiCall, getNotifications, useApi } from '@hfs/shared/api' -import { state, useSnapState } from './state' +import { DirEntry, state, useSnapState } from './state' import { t } from './i18n' import * as dialogLib from './dialog' import _ from 'lodash' @@ -54,36 +54,46 @@ export function working() { export function hfsEvent(name: string, params?:Dict) { const output: any[] = [] - document.dispatchEvent(new CustomEvent('hfs.'+name, { detail: { params, output } })) - return output + const order: number[] = [] + const ev = new CustomEvent('hfs.'+name, { cancelable: true, detail: { params, output, order } }) + document.dispatchEvent(ev) + const sortedOutput = order.length && _.sortBy(output.map((x, i) => [order[i] || 0, x]), '0').map(x => x[1]) + return Object.assign(sortedOutput || output, { + isDefaultPrevent: () => ev.defaultPrevented, + }) } -const tools = { +Object.assign(getHFS(), { h, React, state, t, _, dialogLib, apiCall, useApi, reloadList, logout, Icon, hIcon, iconBtn, useBatch, fileShow, - toast, domOn, + toast, domOn, getNotifications, debounceAsync, useSnapState, DirEntry, misc: { ...cross, ...shared }, + emit: hfsEvent, watchState(k: string, cb: (v: any) => void) { const up = k.split('upload.')[1] return subscribeKey(up ? uploadState : state as any, up || k, cb, true) - } -} -Object.assign(getHFS(), { - ...tools, - emit: hfsEvent, - getNotifications, - debounceAsync, - useSnapState, + }, + customRestCall(name: string, ...rest: any[]) { + return apiCall(cross.PLUGIN_CUSTOM_REST_PREFIX + name, ...rest) + }, html: (html: string) => h(Html, {}, html), - onEvent(name: string, cb: (params:any, tools: any, output:any) => any) { + onEvent(name: string, cb: (params:any, extra: { output: any[], setOrder: Callback, preventDefault: Callback }, output: any[]) => any) { const key = 'hfs.' + name document.addEventListener(key, wrapper) return () => document.removeEventListener(key, wrapper) function wrapper(ev: Event) { - const { params, output } = (ev as CustomEvent).detail - const res = cb(params, tools, output) - if (res !== undefined && Array.isArray(output)) + const { params, output, order } = (ev as CustomEvent).detail + let thisOrder + const res = cb(params, { + output, + setOrder(x) { thisOrder = x }, + preventDefault: () => ev.preventDefault() + }, output) // legacy pre-0.54, third parameter used by file-icons plugin + if (res !== undefined && Array.isArray(output)) { output.push(res) + if (thisOrder) + order[output.length - 1] = thisOrder + } } } }) diff --git a/frontend/src/show.ts b/frontend/src/show.ts index 79fc55e1b..f42da5d11 100644 --- a/frontend/src/show.ts +++ b/frontend/src/show.ts @@ -1,5 +1,5 @@ import { DirEntry, DirList, ext2type, state, useSnapState } from './state' -import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' +import { createElement as h, Fragment, useEffect, useMemo, useRef, useState } from 'react' import { basename, dirname, domOn, hfsEvent, hIcon, isMac, newDialog, pathEncode, restartAnimation, useStateMounted, } from './misc' @@ -8,9 +8,10 @@ import { EntryDetails, useMidnight } from './BrowseFiles' import { Btn, FlexV, iconBtn, Spinner } from './components' import { openFileMenu } from './fileMenu' import { t, useI18N } from './i18n' -import { alertDialog } from './dialog' +import { alertDialog, toast } from './dialog' import _ from 'lodash' import { getId3Tags } from './id3' +import { subscribeKey } from 'valtio/utils' enum ZoomMode { fullWidth, @@ -19,23 +20,49 @@ enum ZoomMode { } export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { + let escOnce = false + let onClose: any + let firstUri: string const { close } = newDialog({ noFrame: true, className: 'file-show', + onClose() { + onClose?.() + }, Content() { + const { uri } = useSnapState() + useEffect(() => { + if (uri === firstUri) return + firstUri ??= uri // init + if (firstUri !== uri) // user must have clicked the folder link inside file-menu (which happens only for search results) + close() + }, [uri]) const [cur, setCur] = useState(entry) const moving = useRef(0) const lastGood = useRef(entry) const [mode, setMode] = useState(ZoomMode.contain) const [shuffle, setShuffle] = useState() + // shuffle the rest of the list as we continue getting entries, leaving intact the part we've already played/being through + const shuffleIdx = useMemo(() => shuffle?.findIndex(x => x.n === cur.n), [cur]) + useEffect(() => subscribeKey(state, 'list', list => { + const n = 1 + (shuffleIdx ?? Infinity) + setShuffle(x => list && x?.slice(0, n).concat(_.shuffle(list.slice(n)))) + }), [shuffleIdx]) const [repeat, setRepeat, { get: getRepeat }] = useStateMounted(false) const [cover, setCover] = useState('') useEffect(() => { if (shuffle) goTo(shuffle[0]) - }, [shuffle]) + }, [Boolean(shuffle)]) useEventListener('keydown', ({ key }) => { - if (key === 'Escape') return close() + if (key === 'Escape') { + if (escOnce) + return close() + escOnce = true + onClose = toast(t('esc_again', "Press ESC twice to close")).close + return + } + escOnce = false if (key === 'ArrowLeft') return goPrev() if (key === 'ArrowRight') return goNext() if (key === 'ArrowDown') return scrollY(1) @@ -57,7 +84,8 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { } }) const [showNav, setShowNav] = useState(false) - const isAudio = getShowType(cur) === Audio + const component = getShowComponent(cur) + const isAudio = component === Audio useEffect(() => setShowNav(isAudio), [isAudio]) const timerRef = useRef(0) const navClass = 'nav' + (showNav ? '' : ' nav-hidden') @@ -90,6 +118,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { const {t} = useI18N() const autoPlaySecondsLabel = t('autoplay_seconds', "Seconds to wait on images") + const folder = dirname(cur.n) return h(FlexV, { gap: 0, alignItems: 'stretch', @@ -105,7 +134,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { } }, h('div', { className: 'bar' }, - h('div', { className: 'filename' }, cur.n), + h('div', { className: 'filename' }, h('small', {}, folder), cur.n.slice(folder.length)), h('div', { className: 'controls' }, // keep on same row h(EntryDetails, { entry: cur, midnight: useMidnight() }), useWindowSize().width > 800 && iconBtn('?', showHelp), @@ -140,8 +169,8 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { h('div', {}, cur.name), t`Loading failed` ) : h('div', { className: 'showing-container', ref: containerRef }, - h('div', { className: 'cover ' + (cover ? '' : 'none'), style: { backgroundImage: `url("${pathEncode(cover)}")`, } }), - h(getShowType(cur) || Fragment, { + h('div', { className: 'cover ' + (cover ? '' : 'none'), style: { backgroundImage: cover && `url("${cover}")` } }), + h(component || Fragment, { src: cur.uri, className: 'showing', onLoad() { @@ -150,21 +179,28 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { }, onError: curFailed, async onPlay() { - const folder = dirname(cur.n) const covers = !isAudio ? [] : state.list.filter(x => folder === dirname(x.n) // same folder - && x.name.match(/(?:folder|cover|albumart.*)\.jpe?g$/i)) - setCover(_.maxBy(covers, 's')?.n || '') + && x.name.match(/(?:folder|cover|front|albumart.*)\.jpe?g$/i)) + setCover(pathEncode(_.maxBy(covers, 's')?.n || '')) const meta = { title: cur.name, album: decodeURIComponent(basename(dirname(cur.uri))), artwork: covers.map(x => ({ src: x.n })) } - if (window.MediaMetadata) - navigator.mediaSession.metadata = new MediaMetadata(meta) + const m = window.MediaMetadata && (navigator.mediaSession.metadata = new MediaMetadata(meta)) if (cur.ext === 'mp3') { setTags(Object.assign(meta, await getId3Tags(location.pathname + cur.n).catch(() => {}))) - Object.assign(navigator.mediaSession?.metadata || {}, meta) + if (m) Object.assign(m, meta) } + hfsEvent('showPlay', { + entry: cur, + meta, + setCover(src: any) { + if (typeof src !== 'string') return + setCover(src) + if (m) navigator.mediaSession.metadata = new MediaMetadata(Object.assign(meta, { artwork: [{ src }] })) + } + }) } }), tags && h('div', { className: 'meta-tags' }, @@ -181,7 +217,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { function goNext() { go(+1) } function curFailed() { - const mediaError = (document.querySelector('.showing-container .showing') as any)?.error?.code // only present in video/audio elements + const mediaError = (document.querySelector('.showing-container .showing') as any)?.error?.code // only presenti in video/audio elements if (mediaError === 2) return // happens when chrome fails to fetch cover for videos. We don't skip the file for this reason. Tested on chrome129/windows if (cur !== lastGood.current) return go() @@ -212,7 +248,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { goTo(e) function anyGood() { - return e && !e.isFolder && getShowType(e) + return e && !e.isFolder && getShowComponent(e) } } @@ -274,7 +310,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { }) } -export function getShowType(entry: DirEntry) { +export function getShowComponent(entry: DirEntry) { const res = hfsEvent('fileShow', { entry }).find(Boolean) if (res) return res diff --git a/frontend/src/state.ts b/frontend/src/state.ts index 1ed0eab66..e71d648da 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -3,7 +3,8 @@ import _ from 'lodash' import { proxy, useSnapshot } from 'valtio' import { subscribeKey } from 'valtio/utils' -import { FRONTEND_OPTIONS, getHFS, hIcon, objSameKeys, pathEncode, typedKeys } from './misc' +import { FRONTEND_OPTIONS, getHFS, hfsEvent, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc' +import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list' export const state = proxyvoid, @@ -36,7 +37,11 @@ export const state = proxy({ + expandedUsername: [], + searchOptions: { wild: true }, uploadOnExisting: getHFS().dontOverwriteUploading ? 'rename' : 'skip', uri: '', canChangePassword: false, @@ -88,14 +93,14 @@ function storeSettings() { localStorage.setItem(SETTINGS_KEY, JSON.stringify(_.pick(state, SETTINGS_TO_STORE))) } -export class DirEntry { +export class DirEntry implements StringifyProps { static FORBIDDEN = 'FORBIDDEN' public readonly n: string public readonly s?: number public readonly m?: string public readonly c?: string public readonly p?: string - public readonly icon?: string + public readonly icon?: string | true public readonly web?: true public readonly url?: string public readonly target?: string @@ -144,7 +149,19 @@ export class DirEntry { } getDefaultIcon() { - return hIcon(this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file')) + return hIcon(this.icon === true ? `${this.n}?get=icon` : (this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file'))) + } + + canArchive() { + return this.p?.includes('A') || state.props?.can_archive && !this.p?.includes('a') + } + canDelete() { + return this.p?.includes('D') || state.props?.can_delete && !this.p?.includes('d') + } + canSelect() { + if (this.url) return false + return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins + || hfsEvent('enableEntrySelection', { entry: this }).some(Boolean) } } export type DirList = DirEntry[] diff --git a/frontend/src/sysIcons.ts b/frontend/src/sysIcons.ts new file mode 100644 index 000000000..5bcda2583 --- /dev/null +++ b/frontend/src/sysIcons.ts @@ -0,0 +1,48 @@ +export const SYS_ICONS: Record = { // false means we don't have the icon, only unicode + login: ['👤','user'], + user: ['👤','user'], + filter: ['✂'], + search: ['🔍'], + search_off: ['❌','cancel'], + close: ['❌','cancel'], + error: ['❌','cancel'], + stop: ['⏹️'], + settings: ['⚙','cog'], + archive: ['📦'], + logout: ['🚪'], + home: ['🏠'], + parent: ['⬅','left'], + folder: ['📂'], + file: ['📄','doc'], + spinner: ['🎲','spin6 spinner'], + password: ['🗝️','key'], + download: ['⬇️'], + upload: ['⬆️'], + reload: ['🔄','reload'], + lock: ['🔒','lock'], + admin: ['👑','crown'], + check: ['✔️'], + to_start: ['◀'], + to_end: ['▶'], + menu: ['☰'], + list: ['☰','menu'], + play: ['▶'], + pause: ['⏸'], + edit: ['✏️'], + zoom: ['↔'], + delete: ['🗑️', 'trash'], + comment: ['💬'], + link: ['↗'], + info: ['ⓘ', false], + cut: ['✄'], + paste: ['📋'], + shuffle: ['🔀'], + repeat: ['🔁', 'reload'], + success: ['👍', 'check'], + warning: ['⚠️', false], + audio: ['🎧'], + video: ['🎥'], + image: ['📸'], + cancel: ['❌','cancel'], +} + diff --git a/frontend/src/toasts.ts b/frontend/src/toasts.ts index 5e76324c1..79da13c92 100644 --- a/frontend/src/toasts.ts +++ b/frontend/src/toasts.ts @@ -12,7 +12,7 @@ type Content = string | ReactElement export function toast(content: Content, type: ToastType='info', { timeout=5_000 }: { timeout?: number } & Omit={}) { console.debug("toast", content) const id = Math.random() - toasts.push({ id, content, type }) + toasts.push({ id, content, type, close }) const closed = pendingPromise() setTimeout(close, timeout) return { @@ -29,12 +29,15 @@ export function toast(content: Content, type: ToastType='info', { timeout=5_000 } interface ToastOptions extends Omit>, 'id' | 'content'> { - id: number content: Content type: ToastType +} +interface ToastRecord extends ToastOptions { + id: number closed?: boolean + close: () => void } -const toasts = proxy([]) +const toasts = proxy([]) export function Toasts() { const snap = useSnapshot(toasts) @@ -44,7 +47,7 @@ export function Toasts() { ) } -function Toast({ content, type, closed, id, ...props }: ToastOptions) { +function Toast({ content, type, closed, id, close, ...props }: ToastRecord) { const [addClass, setAddClass] = useState('before') const [height, setHeight] = useState('') useEffect(() => { @@ -59,7 +62,14 @@ function Toast({ content, type, closed, id, ...props }: ToastOptions) { }, [addClass]) const ref = useRef() content = useMemo(() => isValidElement(content) ? cloneElement(content) : content, [content]) // proxied elements are frozen, and crash - return h('div', { ...props, ref, style: { height }, onTransitionEnd, className: `toast ${addClass} ${_.isString(type) ? 'toast-' + type : ''} ${props.className || ''}`}, + return h('div', { + ...props, + ref, + style: { height }, + onTransitionEnd, + onClick: close, + className: `toast ${addClass} ${_.isString(type) ? 'toast-' + type : ''} ${props.className || ''}` + }, h('div', { className: 'toast-icon' }, isValidElement(type) ? type : hIcon(type)), h('div', { className: 'toast-content' }, content) ) diff --git a/frontend/src/upload.ts b/frontend/src/upload.ts index f33280193..221592af5 100644 --- a/frontend/src/upload.ts +++ b/frontend/src/upload.ts @@ -5,7 +5,7 @@ import { Btn, Flex, FlexV, iconBtn, Select } from './components' import { basename, closeDialog, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, prefix, selectFiles, working, HTTP_CONFLICT, HTTP_PAYLOAD_TOO_LARGE, formatSpeed, dirname, getHFS, onlyTruthy, with_, cpuSpeedIndex, - buildUrlQueryString, randomId, HTTP_MESSAGES, + buildUrlQueryString, randomId, HTTP_MESSAGES, pathEncode, pendingPromise, } from './misc' import _ from 'lodash' import { INTERNAL_Snapshot, proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' @@ -156,7 +156,7 @@ export function showUpload() { h(FilesList, { entries: uploadState.adding, actions: { - delete: rec => _.remove(uploadState.adding, rec), + cancel: rec => _.remove(uploadState.adding, rec), async comment(rec){ if (!props?.can_comment) return const s = await inputComment(basename(rec.file.name), rec.comment) @@ -192,7 +192,7 @@ export function showUpload() { h(FilesList, { entries: uploadState.qs[idx].entries, actions: { - delete: f => { + cancel: f => { if (f === uploadState.uploading) return abortCurrentUpload() const q = uploadState.qs[idx] @@ -310,38 +310,64 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) { overrideStatus = 0 uploadState.uploading = toUpload await subscribeNotifications() - req = new XMLHttpRequest() - req.onloadend = () => { - if (req?.readyState !== 4) return - const status = overrideStatus || req.status - closeLast?.() - if (!status || status === HTTP_CONFLICT) // 0 = user-aborted, HTTP_CONFLICT = skipped because existing - uploadState.skipped.push(toUpload) - else if (status >= 400) - error(status) - else - done() - if (!resuming) + const splitSize = getHFS().splitUploads + const fullSize = toUpload.file.size + let offset = resume + do { // at least one iteration, even for empty files + req = new XMLHttpRequest() + const finished = pendingPromise() + req.onloadend = () => { + finished.resolve() + if (req?.readyState !== 4) return + const status = overrideStatus || req.status + if (!partial) // if the upload ends here, the offer for resuming must stop + closeLast?.() + if (resuming) { // resuming requested + resuming = false // this behavior is only for once, for cancellation of the upload that is in the background while resume is confirmed + stopLooping() + return + } + if (!status || status === HTTP_CONFLICT) // 0 = user-aborted, HTTP_CONFLICT = skipped because existing + uploadState.skipped.push(toUpload) + else if (status >= 400) + error(status) + else { + if (splitSize) { + offset += splitSize + if (offset < fullSize) return // continue looping + } + done() + } next() - } - req.onerror = () => error(0) - let lastProgress = 0 - req.upload.onprogress = (e:any) => { - uploadState.partial = e.loaded + resume - uploadState.progress = uploadState.partial / (e.total + resume) - bytesSent += e.loaded - lastProgress - lastProgress = e.loaded - } - let uploadPath = path(toUpload.file) - if (toUpload.name) - uploadPath = prefix('', dirname(uploadPath), '/') + toUpload.name - req.open('PUT', to + encodeURI(uploadPath) + buildUrlQueryString({ - notificationChannel, - ...resume && { resume: String(resume) }, - ...toUpload.comment && { comment: toUpload.comment }, - ...with_(state.uploadOnExisting, x => x !== 'rename' && { existing: x }), // rename is the default - }), true) - req.send(toUpload.file.slice(resume)) + } + req.onerror = () => { + error(0) + finished.resolve() + stopLooping() + } + let lastProgress = 0 + req.upload.onprogress = (e:any) => { + uploadState.partial = e.loaded + offset + uploadState.progress = uploadState.partial / fullSize + bytesSent += e.loaded - lastProgress + lastProgress = e.loaded + } + let uploadPath = path(toUpload.file) + if (toUpload.name) + uploadPath = prefix('', dirname(uploadPath), '/') + toUpload.name + const partial = splitSize && offset + splitSize < fullSize + req.open('PUT', to + pathEncode(uploadPath) + buildUrlQueryString({ + notificationChannel, + ...partial && { partial: 'y' }, + ...offset && { resume: String(offset) }, + ...toUpload.comment && { comment: toUpload.comment }, + ...with_(state.uploadOnExisting, x => x !== 'rename' && { existing: x }), // rename is the default + }), true) + req.send(toUpload.file.slice(offset, splitSize ? offset + splitSize : undefined)) + await finished + } while (offset < fullSize) + + function stopLooping() { offset = fullSize } async function subscribeNotifications() { if (notificationChannel) return @@ -398,6 +424,7 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) { } function next() { + stopLooping() uploadState.uploading = undefined uploadState.partial = 0 const { qs } = uploadState @@ -477,7 +504,7 @@ export function acceptDropFiles(cb: false | undefined | ((files:File[], to: stri } } -async function createFolder() { +export async function createFolder() { const name = await promptDialog(t`Enter folder name`) if (!name) return const uri = location.pathname diff --git a/frontend/src/useFetchList.ts b/frontend/src/useFetchList.ts index ef5af6e38..59d4ada0a 100644 --- a/frontend/src/useFetchList.ts +++ b/frontend/src/useFetchList.ts @@ -7,7 +7,7 @@ import _ from 'lodash' import { subscribeKey } from 'valtio/utils' import { useIsMounted } from 'usehooks-ts' import { alertDialog } from './dialog' -import { hfsEvent, HTTP_MESSAGES, HTTP_METHOD_NOT_ALLOWED, HTTP_UNAUTHORIZED, LIST, urlParams, waitFor, xlate } from './misc' +import { hfsEvent, HTTP_MESSAGES, HTTP_METHOD_NOT_ALLOWED, HTTP_UNAUTHORIZED, LIST, urlParams, xlate } from './misc' import { t } from './i18n' import { useLocation, useNavigate } from 'react-router-dom' import { closeLoginDialog } from './login' @@ -17,10 +17,8 @@ export function usePath() { } // allow links with ?search -waitFor(() => urlParams).then(x => { - if (x) - state.remoteSearch = x.search -}) +setTimeout(() => // wait, urlParams is defined at top level + state.remoteSearch = urlParams.search || '') export default function useFetchList() { const snap = useSnapState() @@ -39,7 +37,6 @@ export default function useFetchList() { state.uri = uri // this should be a better way than uriChanged state.showFilter = false state.stopSearch?.() - hfsEvent('uriChanged', { uri, previous }) // legacy pre-0.52, remove in 0.54 } state.searchManuallyInterrupted = false if (previous && previous !== uri && search) { @@ -47,7 +44,8 @@ export default function useFetchList() { return } - const params = { uri, search } + const params = { uri, search, ...snap.searchOptions } + params.wild = params.wild ? undefined : 'no' if (snap.listReloader === lastReloader.current && _.isEqual(params, lastParams.current)) return lastParams.current = params lastReloader.current = snap.listReloader @@ -147,15 +145,28 @@ function sort(list: DirList) { const byTime = sort_by === 'time' const invert = state.invert_order ? -1 : 1 return list.sort((a,b) => - folders_first && -compare(a.isFolder, b.isFolder) + hfsEvent('sortCompare', { a, b }).find(Boolean) + || folders_first && -compare(a.isFolder, b.isFolder) || invert * (bySize ? compare(a.s||0, b.s||0) : byExt ? localCompare(a.ext, b.ext) : byTime ? compare(a.t, b.t) : 0 ) - || sort_numerics && (invert * compare(parseFloat(a.n), parseFloat(b.n))) + || sort_numerics && (invert * compareNumerics(a.n, b.n)) || invert * localCompare(a.n, b.n) // fallback to name/path ) + + function compareNumerics(a: string, b: string) { + const re = /\d/g + if (!re.exec(a)) return 0 + const i = re.lastIndex + if (i) { // doesn't start with a number + if (!b.startsWith(a.slice(0, i -1))) return 0 // b is comparable only if it has same leading part + a = a.slice(i-1) + b = b.slice(i-1) + } + return compare(parseFloat(a), parseFloat(b)) + } } // generic comparison diff --git a/hfs.ico b/hfs.ico new file mode 100644 index 000000000..fdbc759a1 Binary files /dev/null and b/hfs.ico differ diff --git a/mui-grid-form/StringField.ts b/mui-grid-form/StringField.ts index 5b39807fd..f1b5618e2 100644 --- a/mui-grid-form/StringField.ts +++ b/mui-grid-form/StringField.ts @@ -13,8 +13,9 @@ interface StringFieldProps extends FieldProps, Partial({ sx: Object.assign({}, stickyBar && { width: 'fit-content', zIndex: 2, backgroundColor: 'background.paper', borderRadius: 1, - position: 'sticky', bottom: 0, p: 1, m: -1, boxShadow: '0px 0px 30px #000', + position: 'sticky', bottom: 0, p: 1, m: -1, boxShadow: '0px 0px 15px #000', }, barSx) }, diff --git a/mui-grid-form/misc-fields.ts b/mui-grid-form/misc-fields.ts index 2a456e566..32d0947cd 100644 --- a/mui-grid-form/misc-fields.ts +++ b/mui-grid-form/misc-fields.ts @@ -4,16 +4,10 @@ import { createElement as h, useEffect, useState } from 'react' import { StringField } from './StringField' import { FieldProps } from '.' import { - Box, - Checkbox, - FormControl, - FormControlLabel, - FormGroup, - FormHelperText, - FormLabel, - InputAdornment, - Switch + Box, Checkbox, FormControl, FormControlLabel, FormGroup, FormHelperText, FormLabel, IconButton, + InputAdornment, Switch } from '@mui/material' +import { Cancel } from '@mui/icons-material' import _ from 'lodash' export function DisplayField({ value, empty='-', ...props }: any) { @@ -22,7 +16,7 @@ export function DisplayField({ value, empty='-', ...props }: any) { return h(StringField, { ...props, value, disabled: true }) } -export function NumberField({ value, onChange, setApi, required, min, max, step, unit, ...props }: FieldProps) { +export function NumberField({ value, onChange, setApi, required, min=0, max, step, unit, clearable, ...props }: FieldProps) { setApi?.({ getError() { return value == null ? (required ? "required" : false) @@ -40,7 +34,16 @@ export function NumberField({ value, onChange, setApi, required, min, max, step, }, inputProps: { min, max, step, }, InputProps: _.merge({ - sx: { '& input': { appearance: 'textfield' } } + sx: { '& input': { appearance: 'textfield' } }, + startAdornment: (clearable ?? props.placeholder) && (value || value === 0) && h(InputAdornment, { + position: 'start', + }, h(IconButton, { + size: 'small', + edge: 'start', + sx: { ml: -1, opacity: .5 }, + 'aria-label': "clear", + onClick(event){ onChange(null, { was: value, event }) } + }, h(Cancel))), }, unit && { sx: { pr: '6px', '& input': { pl: '.2em', textAlign: 'right' } }, endAdornment: h(InputAdornment, { @@ -52,14 +55,14 @@ export function NumberField({ value, onChange, setApi, required, min, max, step, }) } -export function BoolField({ label='', value, onChange, setApi, helperText, error, +export function BoolField({ label='', value, onChange, setApi, helperText, error, Control=Switch, type, // avoid passing this by accident, as it disrupts the control ...props }: FieldProps) { const setter = () => value ?? false const [state, setState] = useState(setter) useEffect(() => setState(setter), [value]) //eslint-disable-line - const control = h(Switch, { + const control = h(Control, { checked: state, ...props, onChange(event) { @@ -67,11 +70,15 @@ export function BoolField({ label='', value, onChange, setApi, helperText, error } }) return h(Box, { ml: 1, sx: error ? { color: 'error.main', outlineOffset: 6, outline: '1px solid' } : undefined }, - h(FormControlLabel, { label, control, labelPlacement: 'end', ...props.size==='small' && { sx: { '& .MuiFormControlLabel-label': { fontSize: '.9rem' } } } }), + h(FormControlLabel, { label, control, labelPlacement: 'end', sx: { mr: 0, ...props.size==='small' && { '& .MuiFormControlLabel-label': { fontSize: '.9rem' } } } }), helperText && h(FormHelperText, { sx: { mt: 0 }, error }, helperText) ) } +export function CheckboxField(props: FieldProps) { + return h(BoolField, { Control: Checkbox, ...props }) +} + export function CheckboxesField({ label, options, value, onChange, columns, columnWidth }: FieldProps & { options: string[] }) { const doCols = columns > 1 || Boolean(columnWidth) return h(FormControl, { fullWidth: doCols }, diff --git a/package-lock.json b/package-lock.json index a793cd94b..7018f3e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hfs", - "version": "0.53.2", + "version": "0.54.0-rc12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hfs", - "version": "0.53.2", + "version": "0.54.0-rc12", "license": "GPL-3.0", "workspaces": [ "admin", @@ -16,14 +16,15 @@ ], "dependencies": { "@koa/router": "^13.0.1", - "@node-rs/crc32": "^1.6.0", + "@node-rs/crc32": "^1.10.3", "@rejetto/kvstorage": "^0.12.2", - "acme-client": "^5.3.1", - "buffer-crc32": "^0.2.13", + "acme-client": "^5.4.0", + "buffer-crc32": "^1.0.0", "fast-glob": "^3.2.7", "find-process": "^1.4.7", "formidable": "^3.5.1", "fs-x-attributes": "^1.0.2", + "fswin": "^3.24.829", "iconv-lite": "^0.6.3", "ip2location-nodejs": "^9.6.0", "koa": "^2.13.4", @@ -107,7 +108,7 @@ "@types/react-dom": "^18.2.18", "@types/react-virtualized-auto-sizer": "^1.0.4", "@types/react-window": "^1.8.8", - "vite": "^5.0.12" + "vite": "^5.4.8" } }, "admin/node_modules/@mui/x-data-grid": { @@ -770,17 +771,17 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "engines": { "node": ">=6.9.0" } @@ -2288,6 +2289,34 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.2.0.tgz", + "integrity": "sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", + "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -3326,10 +3355,21 @@ "react-dom": "^17.0.0 || ^18.0.0" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", + "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, "node_modules/@node-rs/crc32": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.6.1.tgz", - "integrity": "sha512-LRl9o3Ft7Rqs1DrMHr6HLHSx6SAtAJrgNFQZddiCwtpt6CF82ubKOhjY6wK/Cx8dbXq1IoXE0V3CS02oosHQGQ==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.3.tgz", + "integrity": "sha512-4UgH0fDRxs0eMSgrUN0UUM4BpIEbVKutiSkFLICwegbgIger3c1t7V3jOYralK0xTBHraW3r59wlESdc3h/nQg==", "engines": { "node": ">= 10" }, @@ -3338,25 +3378,26 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@node-rs/crc32-android-arm-eabi": "1.6.1", - "@node-rs/crc32-android-arm64": "1.6.1", - "@node-rs/crc32-darwin-arm64": "1.6.1", - "@node-rs/crc32-darwin-x64": "1.6.1", - "@node-rs/crc32-freebsd-x64": "1.6.1", - "@node-rs/crc32-linux-arm-gnueabihf": "1.6.1", - "@node-rs/crc32-linux-arm64-gnu": "1.6.1", - "@node-rs/crc32-linux-arm64-musl": "1.6.1", - "@node-rs/crc32-linux-x64-gnu": "1.6.1", - "@node-rs/crc32-linux-x64-musl": "1.6.1", - "@node-rs/crc32-win32-arm64-msvc": "1.6.1", - "@node-rs/crc32-win32-ia32-msvc": "1.6.1", - "@node-rs/crc32-win32-x64-msvc": "1.6.1" + "@node-rs/crc32-android-arm-eabi": "1.10.3", + "@node-rs/crc32-android-arm64": "1.10.3", + "@node-rs/crc32-darwin-arm64": "1.10.3", + "@node-rs/crc32-darwin-x64": "1.10.3", + "@node-rs/crc32-freebsd-x64": "1.10.3", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.3", + "@node-rs/crc32-linux-arm64-gnu": "1.10.3", + "@node-rs/crc32-linux-arm64-musl": "1.10.3", + "@node-rs/crc32-linux-x64-gnu": "1.10.3", + "@node-rs/crc32-linux-x64-musl": "1.10.3", + "@node-rs/crc32-wasm32-wasi": "1.10.3", + "@node-rs/crc32-win32-arm64-msvc": "1.10.3", + "@node-rs/crc32-win32-ia32-msvc": "1.10.3", + "@node-rs/crc32-win32-x64-msvc": "1.10.3" } }, "node_modules/@node-rs/crc32-android-arm-eabi": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.6.1.tgz", - "integrity": "sha512-pvYzF2Jfw0EbE7hUGTB7Hapc/Q2vBUynxndVQhJSP1hgajNKgxdx5yYuKvINFeXYIpsEQ6tS1r4muRq3/+v61w==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.3.tgz", + "integrity": "sha512-V9iNJd5ux9I415qOldmxZIHrazYMJNsQ6v+Kq/t9FTQyYqiEeHvRc1FzBh9MT6Uc24InwMhBeC1WVw0BL4VaxQ==", "cpu": [ "arm" ], @@ -3369,9 +3410,9 @@ } }, "node_modules/@node-rs/crc32-android-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.6.1.tgz", - "integrity": "sha512-dtmAGGB91sstUsWw+a6hsJweLpuFnu7ID9aFjbI2ghUf2ybiL479nGE36kxtlAYei8eqRcjmvo7+G4VjFXDuxg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.3.tgz", + "integrity": "sha512-d6xLAhbk5FDGpltAKTFs7hZO/PWpHeihZ/ZCKx2LEVz8jXQEshpo2/ojnfb5FAw6oNzU2H+S/RI5GeCr7paa1Q==", "cpu": [ "arm64" ], @@ -3384,9 +3425,9 @@ } }, "node_modules/@node-rs/crc32-darwin-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.6.1.tgz", - "integrity": "sha512-b8LQ5mBf2+7MwiINr9uvWawYLOticMiqlyczE+WkJqUh59UGPXuSEMAmJG7zE+5zXkn2lVu4vB40klW4aBr9vg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.3.tgz", + "integrity": "sha512-IoX6HC4dlKc9BONe7632DADBtiHUiIVD7Bibuj3bGrvOBllN8hvBL9+dDC+/iDdOeuiBKgb0hgL5h2nPIybpzA==", "cpu": [ "arm64" ], @@ -3399,9 +3440,9 @@ } }, "node_modules/@node-rs/crc32-darwin-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.6.1.tgz", - "integrity": "sha512-VhVDKvk/HymNDySbXeeoRFhBDBpqSAl0UwX2SNU2mE86cudsPGP49s41oYlI/qN2YeSrlKPJlzOo65Y4PhxVQg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.3.tgz", + "integrity": "sha512-JUDGAX/0W4A9ok9p6yuy4fAsBDrq8Db0sUjKLMZ/+P3NHB+Qk+OsZUsEDxP3yhBJxhPq97JpN4bBzgMnkDajpw==", "cpu": [ "x64" ], @@ -3414,9 +3455,9 @@ } }, "node_modules/@node-rs/crc32-freebsd-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.6.1.tgz", - "integrity": "sha512-VPeq58gtUHUxe2KHwmeloO7X25fk1BbU3aJsKswe1wQIbms2KNfypy6fVNQs/VLsTurRZKJSJNKJxFvb8Vs6pQ==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.3.tgz", + "integrity": "sha512-mbpVcrF9cRJm9ksv2vVaWc/yRsLJErdb90Kusc6I8CgsBxpS6/wI637i0khSl1l10iWrALXjfh6osihixANYhQ==", "cpu": [ "x64" ], @@ -3429,9 +3470,9 @@ } }, "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.6.1.tgz", - "integrity": "sha512-Ee9oNLnAhd3xXyvC3xEtVNlvWTPs17S17gBo6Go+xRHVRf1nS8Xvxkt27WDpYt4DQnvCz8n5u52gbaVjSqi0tA==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.3.tgz", + "integrity": "sha512-9MZohdtKzdnb16xRKU76t1UTEJu80dFO8f2/N0geJYNobnT1E6p/+5pqB/G1/H6OnPvjqMuFuLVL4BJVvO4GYQ==", "cpu": [ "arm" ], @@ -3444,9 +3485,9 @@ } }, "node_modules/@node-rs/crc32-linux-arm64-gnu": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.6.1.tgz", - "integrity": "sha512-0Q90Tzl/gpZtQ13pcEnnqL/usWk7X5l7SCquB9QW9IIAha+vtVOyylcuyrXOiDQaMcEfVs2EMWtcufXfrxZwiw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.3.tgz", + "integrity": "sha512-t1+9ik4awZF+luQp94HsUH8M1lSw8jWjvQiLaHyxMzrM0NY0/oIkhjqdOswXL11Wybkc63eunNwVqGKWfJEi4Q==", "cpu": [ "arm64" ], @@ -3459,9 +3500,9 @@ } }, "node_modules/@node-rs/crc32-linux-arm64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.6.1.tgz", - "integrity": "sha512-PF1klYsrRwfCALepuvCn6Egbw+k5MaNoEuR6l94sJmWE1KkEcAtFXiV5R2o4XDsWxRk8haKYymH27QGqpslxxw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.3.tgz", + "integrity": "sha512-fsxOk9CpFzyon+vktvCICwhGk0b+tnfEZfPOXa3QDrkyZD7R7cHmpEHGim1BYgJZIJSTBfal5eM11hzBGjJbxw==", "cpu": [ "arm64" ], @@ -3474,9 +3515,9 @@ } }, "node_modules/@node-rs/crc32-linux-x64-gnu": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.6.1.tgz", - "integrity": "sha512-6NpgS8NQpGy8xQDSfKWBAA129fANvL+WxoZ3PjGT4yFLcisHA2g5FYDIxnH79Q0p2bIsB6DFP6v310r2Weck6g==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.3.tgz", + "integrity": "sha512-0zIX68FIeqpRMRNvmB5AgONnLMm628+8mV9UDuCRmGppME8WGnY+Dirx+TPUeTJ4f27+in+6CU4u6LJDi9cXmQ==", "cpu": [ "x64" ], @@ -3489,9 +3530,9 @@ } }, "node_modules/@node-rs/crc32-linux-x64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.6.1.tgz", - "integrity": "sha512-W17m90y6U0erfH0Hpqu7iCF6F9g4x3RXe0Y9H8ewu4q77P8JkOj2yhWiFUG/xJbVmDJ87mGBJkIXQx9NH6xK+w==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.3.tgz", + "integrity": "sha512-dKKt0FEm8JDp2MvIu1J7vg8Dc5D5upNO6LAuvfShq9Hy8hYNQWy6f+AF8mSm/c5wWnjn+pv7I1+jvrZIe6wMig==", "cpu": [ "x64" ], @@ -3503,10 +3544,25 @@ "node": ">= 10" } }, + "node_modules/@node-rs/crc32-wasm32-wasi": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.3.tgz", + "integrity": "sha512-oT2V4r0lGZqZHkFLHeXu5Z8C8SutIvBVV0Ws3unz4/KhwmlMcOZYRmSelUSSILbjNLrg4FihCe20tC1VbmaNxA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@node-rs/crc32-win32-arm64-msvc": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.6.1.tgz", - "integrity": "sha512-Vs+7xsERI5v+zQ+jPXCuRDB8oW2dZMy5kzeKM6oB43mYIoLLWWw5JgaMCOsbkb50da8BIaZxn8eWaqv8TNfzwg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.3.tgz", + "integrity": "sha512-IwP/TjDoQycv3ZCbAHV3qS9oH8pmBo7h9RC0chOvKY0g9+RxRl0nXhxcAcmZvJugKdJd+eCOR9fJrWzcwQOgFg==", "cpu": [ "arm64" ], @@ -3519,9 +3575,9 @@ } }, "node_modules/@node-rs/crc32-win32-ia32-msvc": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.6.1.tgz", - "integrity": "sha512-ux9MC146/kCKHBxNCQnVJxroQn0TaUTubyYFivh+YTnhVMw+kVls5HukesT5o1CXVochhFjIJ8AfVPWoeZg1BQ==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.3.tgz", + "integrity": "sha512-YK0qYTHUFqriqAkHyXfe3IpDFfpG5fc2yuNl7MXn4ejklLLyNQPOCSawvPU7ouOBgtQDaAH60yZhFhsXZfwSfQ==", "cpu": [ "ia32" ], @@ -3534,9 +3590,9 @@ } }, "node_modules/@node-rs/crc32-win32-x64-msvc": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.6.1.tgz", - "integrity": "sha512-/HQScvmbh1j7Y1uFS4ceL7hjU50wraUWQ5jD8cIAb1ibcf04Lwsm7BMh5BkoT8ZC/WtIALMxVM01kn5SJjPCgw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.3.tgz", + "integrity": "sha512-VI9jd8ECiij4YADsfzVuDnhk/UZ5op4RYHyN40yZzwhzcOQ8DDluOeHv91FPHSyMYJEsVsqbr3cqtD6R47xYjw==", "cpu": [ "x64" ], @@ -3969,6 +4025,15 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", @@ -4357,14 +4422,14 @@ } }, "node_modules/acme-client": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.3.1.tgz", - "integrity": "sha512-cGlfyoIAVlFdr60jYWBb6/ZQdpDBt2piapbRmGSwgTDfqCbFKt9n5+RPXuk1tbQawRHN+gZGV5HsXiHEtv2Whw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", "dependencies": { - "@peculiar/x509": "^1.10.0", + "@peculiar/x509": "^1.11.0", "asn1js": "^3.0.5", "axios": "^1.7.2", - "debug": "^4.1.1", + "debug": "^4.3.5", "node-forge": "^1.3.1" }, "engines": { @@ -4779,11 +4844,11 @@ } }, "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "engines": { - "node": "*" + "node": ">=8.0.0" } }, "node_modules/buffer-from": { @@ -5058,9 +5123,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookies": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", - "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" @@ -5200,11 +5265,11 @@ "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5722,6 +5787,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fswin": { + "version": "3.24.829", + "resolved": "https://registry.npmjs.org/fswin/-/fswin-3.24.829.tgz", + "integrity": "sha512-t3KHDNSMHbUzjpzb35c+27dGMLcE5gXvYZ4to5BITvCvPr3dZvX41VUzgEMQ8mVozbn5uiQ9p61/cQVLDEy+ag==", + "engines": { + "node": ">= 8.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -6003,9 +6076,9 @@ ] }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" @@ -6350,15 +6423,15 @@ } }, "node_modules/koa": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.14.2.tgz", - "integrity": "sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", + "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", "dependencies": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", - "cookies": "~0.8.0", + "cookies": "~0.9.0", "debug": "^4.3.2", "delegates": "^1.0.0", "depd": "^2.0.0", @@ -6815,12 +6888,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -6837,9 +6904,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multistream": { "version": "4.1.0", @@ -7299,9 +7366,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "3.0.1", @@ -7394,9 +7461,9 @@ } }, "node_modules/pkg-fetch/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -7463,9 +7530,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -7483,8 +7550,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8377,9 +8444,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -9039,13 +9106,13 @@ } }, "node_modules/vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" }, "bin": { @@ -9794,14 +9861,14 @@ } }, "@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==" + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" }, "@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==" + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" }, "@babel/helper-validator-option": { "version": "7.24.7", @@ -10844,6 +10911,34 @@ } } }, + "@emnapi/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.2.0.tgz", + "integrity": "sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==", + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.0.1", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", + "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, "@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -11182,7 +11277,7 @@ "tssrp6a": "^3.0.0", "usehooks-ts": "^2.9.5", "valtio": "^1.13.0", - "vite": "^5.0.12", + "vite": "^5.4.8", "watch-size": "^2.0.0", "web-vitals": "^2.1.4" }, @@ -11470,102 +11565,123 @@ "react-transition-group": "^4.4.5" } }, + "@napi-rs/wasm-runtime": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", + "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", + "optional": true, + "requires": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, "@node-rs/crc32": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.6.1.tgz", - "integrity": "sha512-LRl9o3Ft7Rqs1DrMHr6HLHSx6SAtAJrgNFQZddiCwtpt6CF82ubKOhjY6wK/Cx8dbXq1IoXE0V3CS02oosHQGQ==", - "requires": { - "@node-rs/crc32-android-arm-eabi": "1.6.1", - "@node-rs/crc32-android-arm64": "1.6.1", - "@node-rs/crc32-darwin-arm64": "1.6.1", - "@node-rs/crc32-darwin-x64": "1.6.1", - "@node-rs/crc32-freebsd-x64": "1.6.1", - "@node-rs/crc32-linux-arm-gnueabihf": "1.6.1", - "@node-rs/crc32-linux-arm64-gnu": "1.6.1", - "@node-rs/crc32-linux-arm64-musl": "1.6.1", - "@node-rs/crc32-linux-x64-gnu": "1.6.1", - "@node-rs/crc32-linux-x64-musl": "1.6.1", - "@node-rs/crc32-win32-arm64-msvc": "1.6.1", - "@node-rs/crc32-win32-ia32-msvc": "1.6.1", - "@node-rs/crc32-win32-x64-msvc": "1.6.1" + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.3.tgz", + "integrity": "sha512-4UgH0fDRxs0eMSgrUN0UUM4BpIEbVKutiSkFLICwegbgIger3c1t7V3jOYralK0xTBHraW3r59wlESdc3h/nQg==", + "requires": { + "@node-rs/crc32-android-arm-eabi": "1.10.3", + "@node-rs/crc32-android-arm64": "1.10.3", + "@node-rs/crc32-darwin-arm64": "1.10.3", + "@node-rs/crc32-darwin-x64": "1.10.3", + "@node-rs/crc32-freebsd-x64": "1.10.3", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.3", + "@node-rs/crc32-linux-arm64-gnu": "1.10.3", + "@node-rs/crc32-linux-arm64-musl": "1.10.3", + "@node-rs/crc32-linux-x64-gnu": "1.10.3", + "@node-rs/crc32-linux-x64-musl": "1.10.3", + "@node-rs/crc32-wasm32-wasi": "1.10.3", + "@node-rs/crc32-win32-arm64-msvc": "1.10.3", + "@node-rs/crc32-win32-ia32-msvc": "1.10.3", + "@node-rs/crc32-win32-x64-msvc": "1.10.3" } }, "@node-rs/crc32-android-arm-eabi": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.6.1.tgz", - "integrity": "sha512-pvYzF2Jfw0EbE7hUGTB7Hapc/Q2vBUynxndVQhJSP1hgajNKgxdx5yYuKvINFeXYIpsEQ6tS1r4muRq3/+v61w==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.3.tgz", + "integrity": "sha512-V9iNJd5ux9I415qOldmxZIHrazYMJNsQ6v+Kq/t9FTQyYqiEeHvRc1FzBh9MT6Uc24InwMhBeC1WVw0BL4VaxQ==", "optional": true }, "@node-rs/crc32-android-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.6.1.tgz", - "integrity": "sha512-dtmAGGB91sstUsWw+a6hsJweLpuFnu7ID9aFjbI2ghUf2ybiL479nGE36kxtlAYei8eqRcjmvo7+G4VjFXDuxg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.3.tgz", + "integrity": "sha512-d6xLAhbk5FDGpltAKTFs7hZO/PWpHeihZ/ZCKx2LEVz8jXQEshpo2/ojnfb5FAw6oNzU2H+S/RI5GeCr7paa1Q==", "optional": true }, "@node-rs/crc32-darwin-arm64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.6.1.tgz", - "integrity": "sha512-b8LQ5mBf2+7MwiINr9uvWawYLOticMiqlyczE+WkJqUh59UGPXuSEMAmJG7zE+5zXkn2lVu4vB40klW4aBr9vg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.3.tgz", + "integrity": "sha512-IoX6HC4dlKc9BONe7632DADBtiHUiIVD7Bibuj3bGrvOBllN8hvBL9+dDC+/iDdOeuiBKgb0hgL5h2nPIybpzA==", "optional": true }, "@node-rs/crc32-darwin-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.6.1.tgz", - "integrity": "sha512-VhVDKvk/HymNDySbXeeoRFhBDBpqSAl0UwX2SNU2mE86cudsPGP49s41oYlI/qN2YeSrlKPJlzOo65Y4PhxVQg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.3.tgz", + "integrity": "sha512-JUDGAX/0W4A9ok9p6yuy4fAsBDrq8Db0sUjKLMZ/+P3NHB+Qk+OsZUsEDxP3yhBJxhPq97JpN4bBzgMnkDajpw==", "optional": true }, "@node-rs/crc32-freebsd-x64": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.6.1.tgz", - "integrity": "sha512-VPeq58gtUHUxe2KHwmeloO7X25fk1BbU3aJsKswe1wQIbms2KNfypy6fVNQs/VLsTurRZKJSJNKJxFvb8Vs6pQ==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.3.tgz", + "integrity": "sha512-mbpVcrF9cRJm9ksv2vVaWc/yRsLJErdb90Kusc6I8CgsBxpS6/wI637i0khSl1l10iWrALXjfh6osihixANYhQ==", "optional": true }, "@node-rs/crc32-linux-arm-gnueabihf": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.6.1.tgz", - "integrity": "sha512-Ee9oNLnAhd3xXyvC3xEtVNlvWTPs17S17gBo6Go+xRHVRf1nS8Xvxkt27WDpYt4DQnvCz8n5u52gbaVjSqi0tA==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.3.tgz", + "integrity": "sha512-9MZohdtKzdnb16xRKU76t1UTEJu80dFO8f2/N0geJYNobnT1E6p/+5pqB/G1/H6OnPvjqMuFuLVL4BJVvO4GYQ==", "optional": true }, "@node-rs/crc32-linux-arm64-gnu": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.6.1.tgz", - "integrity": "sha512-0Q90Tzl/gpZtQ13pcEnnqL/usWk7X5l7SCquB9QW9IIAha+vtVOyylcuyrXOiDQaMcEfVs2EMWtcufXfrxZwiw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.3.tgz", + "integrity": "sha512-t1+9ik4awZF+luQp94HsUH8M1lSw8jWjvQiLaHyxMzrM0NY0/oIkhjqdOswXL11Wybkc63eunNwVqGKWfJEi4Q==", "optional": true }, "@node-rs/crc32-linux-arm64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.6.1.tgz", - "integrity": "sha512-PF1klYsrRwfCALepuvCn6Egbw+k5MaNoEuR6l94sJmWE1KkEcAtFXiV5R2o4XDsWxRk8haKYymH27QGqpslxxw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.3.tgz", + "integrity": "sha512-fsxOk9CpFzyon+vktvCICwhGk0b+tnfEZfPOXa3QDrkyZD7R7cHmpEHGim1BYgJZIJSTBfal5eM11hzBGjJbxw==", "optional": true }, "@node-rs/crc32-linux-x64-gnu": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.6.1.tgz", - "integrity": "sha512-6NpgS8NQpGy8xQDSfKWBAA129fANvL+WxoZ3PjGT4yFLcisHA2g5FYDIxnH79Q0p2bIsB6DFP6v310r2Weck6g==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.3.tgz", + "integrity": "sha512-0zIX68FIeqpRMRNvmB5AgONnLMm628+8mV9UDuCRmGppME8WGnY+Dirx+TPUeTJ4f27+in+6CU4u6LJDi9cXmQ==", "optional": true }, "@node-rs/crc32-linux-x64-musl": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.6.1.tgz", - "integrity": "sha512-W17m90y6U0erfH0Hpqu7iCF6F9g4x3RXe0Y9H8ewu4q77P8JkOj2yhWiFUG/xJbVmDJ87mGBJkIXQx9NH6xK+w==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.3.tgz", + "integrity": "sha512-dKKt0FEm8JDp2MvIu1J7vg8Dc5D5upNO6LAuvfShq9Hy8hYNQWy6f+AF8mSm/c5wWnjn+pv7I1+jvrZIe6wMig==", "optional": true }, + "@node-rs/crc32-wasm32-wasi": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.3.tgz", + "integrity": "sha512-oT2V4r0lGZqZHkFLHeXu5Z8C8SutIvBVV0Ws3unz4/KhwmlMcOZYRmSelUSSILbjNLrg4FihCe20tC1VbmaNxA==", + "optional": true, + "requires": { + "@napi-rs/wasm-runtime": "^0.2.3" + } + }, "@node-rs/crc32-win32-arm64-msvc": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.6.1.tgz", - "integrity": "sha512-Vs+7xsERI5v+zQ+jPXCuRDB8oW2dZMy5kzeKM6oB43mYIoLLWWw5JgaMCOsbkb50da8BIaZxn8eWaqv8TNfzwg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.3.tgz", + "integrity": "sha512-IwP/TjDoQycv3ZCbAHV3qS9oH8pmBo7h9RC0chOvKY0g9+RxRl0nXhxcAcmZvJugKdJd+eCOR9fJrWzcwQOgFg==", "optional": true }, "@node-rs/crc32-win32-ia32-msvc": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.6.1.tgz", - "integrity": "sha512-ux9MC146/kCKHBxNCQnVJxroQn0TaUTubyYFivh+YTnhVMw+kVls5HukesT5o1CXVochhFjIJ8AfVPWoeZg1BQ==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.3.tgz", + "integrity": "sha512-YK0qYTHUFqriqAkHyXfe3IpDFfpG5fc2yuNl7MXn4ejklLLyNQPOCSawvPU7ouOBgtQDaAH60yZhFhsXZfwSfQ==", "optional": true }, "@node-rs/crc32-win32-x64-msvc": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.6.1.tgz", - "integrity": "sha512-/HQScvmbh1j7Y1uFS4ceL7hjU50wraUWQ5jD8cIAb1ibcf04Lwsm7BMh5BkoT8ZC/WtIALMxVM01kn5SJjPCgw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.3.tgz", + "integrity": "sha512-VI9jd8ECiij4YADsfzVuDnhk/UZ5op4RYHyN40yZzwhzcOQ8DDluOeHv91FPHSyMYJEsVsqbr3cqtD6R47xYjw==", "optional": true }, "@nodelib/fs.scandir": { @@ -11877,6 +11993,15 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, "@types/accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", @@ -12252,14 +12377,14 @@ } }, "acme-client": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.3.1.tgz", - "integrity": "sha512-cGlfyoIAVlFdr60jYWBb6/ZQdpDBt2piapbRmGSwgTDfqCbFKt9n5+RPXuk1tbQawRHN+gZGV5HsXiHEtv2Whw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", "requires": { - "@peculiar/x509": "^1.10.0", + "@peculiar/x509": "^1.11.0", "asn1js": "^3.0.5", "axios": "^1.7.2", - "debug": "^4.1.1", + "debug": "^4.3.5", "node-forge": "^1.3.1" } }, @@ -12543,9 +12668,9 @@ } }, "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==" }, "buffer-from": { "version": "1.1.2", @@ -12727,9 +12852,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "cookies": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", - "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", "requires": { "depd": "~2.0.0", "keygrip": "~1.1.0" @@ -12834,11 +12959,11 @@ "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "decamelize": { @@ -13216,6 +13341,11 @@ "dev": true, "optional": true }, + "fswin": { + "version": "3.24.829", + "resolved": "https://registry.npmjs.org/fswin/-/fswin-3.24.829.tgz", + "integrity": "sha512-t3KHDNSMHbUzjpzb35c+27dGMLcE5gXvYZ4to5BITvCvPr3dZvX41VUzgEMQ8mVozbn5uiQ9p61/cQVLDEy+ag==" + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -13415,9 +13545,9 @@ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true }, "ignore-by-default": { @@ -13671,15 +13801,15 @@ "dev": true }, "koa": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.14.2.tgz", - "integrity": "sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", + "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", "requires": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", - "cookies": "~0.8.0", + "cookies": "~0.9.0", "debug": "^4.3.2", "delegates": "^1.0.0", "depd": "^2.0.0", @@ -14030,12 +14160,6 @@ } } }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -14048,9 +14172,9 @@ } }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "multistream": { "version": "4.1.0", @@ -14369,9 +14493,9 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, "picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { "version": "3.0.1", @@ -14478,9 +14602,9 @@ } }, "semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, "universalify": { @@ -14492,14 +14616,14 @@ } }, "postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "dependencies": { "nanoid": { @@ -15158,9 +15282,9 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" }, "source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, "source-map-support": { @@ -15646,14 +15770,14 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, "requires": { "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" } }, diff --git a/package.json b/package.json index 77a2800d0..1e434b597 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hfs", - "version": "0.53.2", + "version": "0.54.0-rc12", "description": "HTTP File Server", "keywords": [ "file server", @@ -16,7 +16,7 @@ "start-frontend": "npm run start --workspace=frontend", "start-admin": "npm run start --workspace=admin", "build-all": "npm audit --omit=dev --audit-level=moderate && rm -rf dist && npm i && npm run build-server && npm run build-frontend && npm run build-admin && echo COMPLETED", - "build-server": "rm -rf dist/src dist/plugins && tsc --target es2018 && touch package.json && cp -v -r package.json central.json README* LICENSE* plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node afterbuild.js", + "build-server": "rm -rf dist/src dist/plugins && tsc --target es2018 && touch package.json && cp -v -r package.json central.json README* LICENSE* hfs.ico plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node afterbuild.js", "build-frontend": "npm run build --workspace=frontend", "build-admin": "npm run build --workspace=admin", "server-for-test": "node dist/src --cwd . --config tests && rm custom.html", @@ -27,7 +27,7 @@ "dist-bin": "npm run dist-modules && npm run dist-bin-win && npm run dist-bin-linux && npm run dist-bin-mac && npm run dist-bin-mac-arm", "dist-modules": "cp package*.json central.json dist && cd dist && npm ci --omit=dev && cd .. && node prune_modules", "dist-pre": "cd dist && rm -rf node_modules/@node-rs/crc32-*", - "dist-bin-win": "npm run dist-pre && cd dist && npm i -f --no-save --omit=dev @node-rs/crc32-win32-x64-msvc && pkg . --public -C gzip -t node18-win-x64 && zip hfs-windows-x64-$(jq -r .version ../package.json).zip hfs.exe -r plugins && cd ..", + "dist-bin-win": "npm run dist-pre && cd dist && npm i -f --no-save --omit=dev @node-rs/crc32-win32-x64-msvc && pkg . --public -C gzip -t node18-win-x64 && npx resedit-cli --in hfs.exe --icon 1,../hfs.ico --out hfs.exe && zip hfs-windows-x64-$(jq -r .version ../package.json).zip hfs.exe -r plugins && cd ..", "dist-bin-mac-arm": "npm run dist-pre && cd dist && npm i -f --no-save --omit=dev @node-rs/crc32-darwin-arm64 && pkg . --public -C gzip -t node18-macos-arm64 && zip hfs-mac-arm64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..", "dist-bin-mac": "npm run dist-pre && cd dist && npm i -f --no-save --omit=dev @node-rs/crc32-darwin-x64 && pkg . --public -C gzip -t node18-macos-x64 && zip hfs-mac-x64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..", "dist-bin-linux": "npm run dist-pre && cd dist && npm i -f --no-save --omit=dev @node-rs/crc32-linux-x64-gnu && pkg . --public -C gzip -t node18-linux-x64 && zip hfs-linux-x64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..", @@ -59,6 +59,7 @@ "central.json", "admin/**/*", "frontend/**/*", + "**/node_modules/fswin/x64/*", "**/node_modules/axios/dist/node/*" ], "targets": [ @@ -70,14 +71,15 @@ }, "dependencies": { "@koa/router": "^13.0.1", - "@node-rs/crc32": "^1.6.0", + "@node-rs/crc32": "^1.10.3", "@rejetto/kvstorage": "^0.12.2", - "acme-client": "^5.3.1", - "buffer-crc32": "^0.2.13", + "acme-client": "^5.4.0", + "buffer-crc32": "^1.0.0", "fast-glob": "^3.2.7", "find-process": "^1.4.7", "formidable": "^3.5.1", "fs-x-attributes": "^1.0.2", + "fswin": "^3.24.829", "iconv-lite": "^0.6.3", "ip2location-nodejs": "^9.6.0", "koa": "^2.13.4", diff --git a/plugins/antibrute/plugin.js b/plugins/antibrute/plugin.js index 9ed0ecde0..35d8312b9 100644 --- a/plugins/antibrute/plugin.js +++ b/plugins/antibrute/plugin.js @@ -19,7 +19,7 @@ exports.init = api => { const { block } = api.require('./block') return { unload: api.events.multi({ - attemptingLogin: async ctx => { + attemptingLogin: async ({ ctx }) => { const { ip } = ctx const now = new Date const rec = getOrSet(byIp, ip, () => ({ attempts: 0, next: now })) diff --git a/plugins/download-counter/plugin.js b/plugins/download-counter/plugin.js index 84c067251..24ce4b55d 100644 --- a/plugins/download-counter/plugin.js +++ b/plugins/download-counter/plugin.js @@ -16,21 +16,6 @@ exports.configDialog = { exports.init = async api => { const db = await api.openDb('counters.kv', { defaultPutDelay: 5_000, maxPutDelay: 30_000 }) - - try { // load legacy file - const countersFile = 'counters.yaml' - const yaml = api.require('yaml') - const { readFile, unlink } = api.require('fs/promises') - const data = await readFile(countersFile, 'utf8') - for (const [k,v] of Object.entries(yaml.parse(data))) - db.put(uri2key(k), v) - await unlink(countersFile) - api.log("data converted") - } - catch(err) { - if (err.code !== 'ENOENT') - api.log(err) - } return { frontend_js: 'main.js', frontend_css: 'style.css', diff --git a/plugins/list-uploader/public/main.js b/plugins/list-uploader/public/main.js index 80b1443f6..4051d3469 100644 --- a/plugins/list-uploader/public/main.js +++ b/plugins/list-uploader/public/main.js @@ -10,10 +10,11 @@ const text = React.useMemo(() => { if (!data || data === true) return '' const { upload: x } = data + const shouldShowIpWithUser = display === 'ip+user' && x.ip || display === 'tooltip' && HFS.state.adminUrl return !x ? '' : display === 'user' ? x.username - : display === 'ip' || !x.username ? x.ip - : x.ip + ' (' + x.username + ')' + : display === 'ip' || !x.username && shouldShowIpWithUser ? x.ip + : shouldShowIpWithUser ? x.ip + ' (' + x.username + ')' : (x.username || ' ') }, [data]) const iconOnly = display === 'tooltip' return text && HFS.h('span', { className: 'uploader', title: HFS.t`Uploader` + (iconOnly ? ' ' + text : '') }, diff --git a/prune_modules.js b/prune_modules.js index 3b1197371..9ef6c916f 100644 --- a/prune_modules.js +++ b/prune_modules.js @@ -12,13 +12,9 @@ fs.renameSync(ya+'do_c', ya+'doc') process.chdir(dist) console.log('more pruning') -fs.rmSync(nm+'node-forge/dist', {recursive:true}) -fs.rmSync(nm+'node-forge/flash', {recursive:true}) -fs.rmSync(nm+'axios/lib', {recursive:true}) -fs.rmSync(nm+'react', {recursive:true}) -fs.rmSync(nm+'yaml/browser', {recursive:true}) -fs.rmSync(nm+'limiter/dist/esm', {recursive:true}) -for (const fn of glob.sync(['**/*.map', '**/*.tsbuildinfo', '*.bak'])) +for (const f of ['fswin/ia32', 'fswin/arm64', 'node-forge/dist', 'node-forge/flash', 'axios/lib', 'react', 'yaml/browser', 'limiter/dist/esm']) + fs.rmSync(nm+f, {recursive:true}) +for (const fn of glob.sync(['**/*.map', '**/*.tsbuildinfo', '**/*.bak', '**/*.ts', '**/license', '**/*.md'])) fs.unlinkSync(fn) console.log('pruning lodash') diff --git a/shared/_main.scss b/shared/_main.scss index ab688415b..f7d6cc68c 100644 --- a/shared/_main.scss +++ b/shared/_main.scss @@ -3,3 +3,14 @@ clip-path: rect(1px 1px 1px 1px); clip: rect(1px, 1px, 1px, 1px); // legacy browsers } +.icon { // also used in admin/fs + font-size: 1.2em; + height: 1.2em; width: 1.4em; // give similar size to icons + + display: inline-block; text-align: center; // same size for font icons +} + +@keyframes blink { + 0% {opacity: 1} + 50% {opacity: 0.2} +} diff --git a/shared/api.ts b/shared/api.ts index 7a0fc65b2..0d9086772 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -55,7 +55,7 @@ export function apiCall(cmd: string, params?: Dict, options: ApiCallOptio await options.onResponse?.(res, result) if (!res.ok) throw new ApiError(res.status, data === undefined ? body : `Failed API ${cmd}: ${res.statusText}`, data) - return result as T + return result as Awaited infer R ? Awaited : T> }, err => { stop?.() if (err?.message?.includes('fetch')) { @@ -81,21 +81,20 @@ export class ApiError extends Error { export type UseApi = ReturnType> export function useApi(cmd: string | Falsy, params?: object, options: ApiCallOptions={}) { - const [data, setData] = useStateMounted(undefined) + const [data, setData] = useStateMounted>> | undefined>(undefined) const [error, setError] = useStateMounted(undefined) const [forcer, setForcer] = useStateMounted(0) const loadingRef = useRef>() const reloadingRef = useRef() - const dataRef = useRef() + const dataRef = useRef() useEffect(() => { loadingRef.current?.abort() - setData(undefined) setError(undefined) let aborted = false let req: undefined | ReturnType const wholePromise = wait(0) // postpone a bit, so that if it is aborted immediately, it is never really fired (happens mostly in dev mode) .then(() => !cmd || aborted ? undefined : req = apiCall(cmd, params, options)) - .then(res => aborted || setData(dataRef.current = res), err => { + .then(res => aborted || setData(dataRef.current = res as any) || setError(undefined), err => { if (aborted) return setError(err) setData(dataRef.current = undefined) @@ -115,9 +114,9 @@ export function useApi(cmd: string | Falsy, params?: object, options: Api reloadingRef.current = pendingPromise() }, [setForcer]) const ee = useMemo(() => new BetterEventEmitter, []) - const sub = useCallback((cb: Callback) => ee.on('data', cb), []) + const sub = useCallback((cb: Callback) => ee.on('data', cb), [ee]) useEffect(() => { ee.emit('data') }, [data]) - return { data, setData, error, reload, sub, loading: loadingRef.current || reloadingRef.current, getData: () => dataRef.current, } + return { data, setData, error, reload, sub, loading: loadingRef.current || reloadingRef.current, getData: () => dataRef.current } } type EventHandler = (type:string, data?:any) => void diff --git a/shared/dialogs.ts b/shared/dialogs.ts index 304dd0c99..8e6a1ffc1 100644 --- a/shared/dialogs.ts +++ b/shared/dialogs.ts @@ -108,7 +108,7 @@ export function Dialogs(props: HTMLAttributes) { } function Dialog(d: DialogOptions) { - const ref = useRef() + const ref = useRef(null) const [shiftY, setShiftY] = useState(0) useEffect(()=>{ const el = ref.current?.querySelector('.dialog') as HTMLElement | undefined @@ -124,7 +124,11 @@ function Dialog(d: DialogOptions) { return h('div', { ref, className: 'dialog-backdrop '+(d.className||''), - onKeyDown, + onKeyDown(ev) { + if (ev.key === 'Escape') + closeDialog() + ev.stopPropagation() + }, onClick: (ev: any) => d.closable && ev.target === ev.currentTarget // this test will tell us if really the backdrop was clicked && closeDialog() @@ -173,13 +177,6 @@ export function componentOrNode(x: ReactNode | FunctionComponent) { return isPrimitive(x) || isValidElement(x) ? x : h(x as any) } -function onKeyDown(ev:any) { - if (ev.key === 'Escape') { - closeDialog() - ev.stopPropagation() - } -} - export function newDialog(options: DialogOptions) { const $id = Math.random() const ts = performance.now() @@ -203,7 +200,7 @@ export function newDialog(options: DialogOptions) { if (history.state?.$dialog === $id) options.closed = back() closeDialogAt(i, v) - return options + return options.closed } } @@ -226,8 +223,9 @@ export function closeDialog(v?:any, skipHistory=false) { function closeDialogAt(i: number, value?: any) { const [d] = dialogs.splice(i,1) d.restoreFocus?.focus?.() // if element is not HTMLElement, it doesn't have focus method - d.closingValue = value - d?.onClose?.(value) + d.closingValue = value && typeof value === 'object' ? ref(value) : value // since this is being assigned to a valtio proxy, ref is necessary to avoid crashing with unusual (and possibly accidental) objects like React's SynteticEvents + Promise.resolve(d.closed).then(() => + d?.onClose?.(value)) return d } diff --git a/shared/index.ts b/shared/index.ts index db378795f..1c1a06b9b 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -12,6 +12,8 @@ export * from '../src/cross' ;(window as any)._ = _ +document.querySelectorAll('.removeAtBoot').forEach(e => e.remove()) + // roughly 0.7 on m1 max export const cpuSpeedIndex = (() => { let ms = performance.now() @@ -27,9 +29,12 @@ Object.assign(HFS, { getPluginPublic: () => getScriptAttr('src')?.match(/^.*\//)?.[0], getPluginConfig: () => HFS.plugins[HFS.getPluginKey()] || {}, loadScript: (uri: string) => loadScript(uri.includes('//') || uri.startsWith('/') ? uri : HFS.getPluginPublic() + uri), + userBelongsTo: (groupOrUser: string) => HFS.state.expandedUsername.includes(groupOrUser), cpuSpeedIndex, }) +export const IMAGE_FILEMASK = '*.jpg|*.jpeg|*.gif|*.svg' + //@ts-ignore if (import.meta.env.PROD) { const was = console.debug @@ -111,7 +116,7 @@ export function makeSessionRefresher(state: any) { if (!response) return const { exp } = response Object.assign(initial, response) // keep it updated, not necessary, just in case someone is looking at this instead of the state - Object.assign(state, _.pick(response, ['username', 'adminUrl', 'canChangePassword', 'accountExp'])) + Object.assign(state, _.pick(response, ['username', 'adminUrl', 'canChangePassword', 'accountExp', 'expandedUsername'])) if (!response.username || !exp) return const delta = new Date(exp).getTime() - Date.now() const t = _.clamp(delta - 30_000, 4_000, 600_000) diff --git a/shared/md.ts b/shared/md.ts index c4163f45d..ab9ea6aab 100644 --- a/shared/md.ts +++ b/shared/md.ts @@ -10,14 +10,14 @@ export const MD_TAGS = { } type OnText = (s: string) => ReactNode // md-inspired formatting, very simplified -export function md(text: string | TemplateStringsArray, { linkTarget='_blank', onText=(x=>x) as OnText }={}) { +export function md(text: string | TemplateStringsArray, { html=true, linkTarget='_blank', onText=(x=>x) as OnText }={}) { if (typeof text !== 'string') text = text[0] return replaceStringToReact(text, /(`|_|\*\*?)(.+?)\1|(\n)|\[(.+?)\]\((.+?)\)|(<(\w+?)(?:\s+[^>]*?)?>(?:.*?<\/\7>)?)/g, m => m[4] ? h(MD_TAGS.a, { href: m[5], target: linkTarget }, onText(m[4])) : m[3] ? h('br') : m[1] ? h((MD_TAGS as any)[ m[1] ] || Fragment, {}, onText(m[2])) - : h(Html, {}, m[6]), + : html ? h(Html, {}, m[6]) : m[6], onText) } diff --git a/src/QuickZipStream.ts b/src/QuickZipStream.ts index e447fb471..a756e2f47 100644 --- a/src/QuickZipStream.ts +++ b/src/QuickZipStream.ts @@ -8,7 +8,7 @@ import assert from 'assert' const ZIP64_SIZE_LIMIT = 0xffffffff const ZIP64_NUMBER_LIMIT = 0xffff -let crc32function: (input: string | Buffer, initialState?: number | undefined | null) => number +let crc32function: (input: string | Buffer, initialState?: number | undefined) => number import('@node-rs/crc32').then(lib => crc32function = lib.crc32, () => { console.log('using generic lib for crc32') crc32function = unsigned diff --git a/src/acme.ts b/src/acme.ts index 638a17448..bfe899803 100644 --- a/src/acme.ts +++ b/src/acme.ts @@ -106,8 +106,10 @@ export const makeCert = debounceAsync(async (domain: string, email?: string, alt await fs.writeFile(KEY_FILE, res.key) cert.set(CERT_FILE) // update config privateKey.set(KEY_FILE) -}, 0) + acmeRenewError = '' +}) +export let acmeRenewError = '' const acmeDomain = defineConfig('acme_domain', '') const acmeEmail = defineConfig('acme_email', '') const acmeRenew = defineConfig('acme_renew', false) // handle config changes @@ -125,6 +127,6 @@ const renewCert = debounceAsync(async () => { if (now > new Date(cert.validFrom) && now < validTo && validTo.getTime() - now.getTime() >= 30 * DAY) return console.log("certificate still good") await makeCert(domain, acmeEmail.get(), altNames) - .catch(e => console.log("error renewing certificate: ", String(e.message || e))) -}, 0, { retain: DAY, retainFailure: HOUR }) + .catch(e => console.log(acmeRenewError = `Error renewing certificate, expiring ${validTo.toLocaleDateString()}: ${String(e.message || e)}`)) +}, { retain: DAY, retainFailure: HOUR }) diff --git a/src/adminApis.ts b/src/adminApis.ts index 5fb3e3173..2efaba487 100644 --- a/src/adminApis.ts +++ b/src/adminApis.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 { ApiError, ApiHandlers } from './apiMiddleware' +import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware' import { configFile, defineConfig, getWholeConfig, setConfig } from './config' import { getBaseUrlOrDefault, getIps, getServerStatus, getUrls } from './listen' import { @@ -20,24 +20,27 @@ import langApis from './api.lang' import netApis from './api.net' import logApis from './api.log' import { getConnections } from './connections' -import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, waitFor } from './misc' +import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, typedEntries, waitFor } from './misc' import { accountCanLoginAdmin, accountsConfig } from './perm' import Koa from 'koa' -import { getProxyDetected } from './middlewares' +import { cloudflareDetected, getProxyDetected } from './middlewares' import { writeFile } from 'fs/promises' import { execFile } from 'child_process' import { promisify } from 'util' import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtml' import _ from 'lodash' -import { getUpdates, localUpdateAvailable, update, updateSupported } from './update' +import { autoCheckUpdateResult, getUpdates, localUpdateAvailable, update, updateSupported } from './update' import { resolve } from 'path' import { getErrorSections } from './errorPages' import { ip2country } from './geo' import { roots } from './roots' import { SendListReadable } from './SendList' import { get_dynamic_dns_error } from './ddns' +import { addBlock, BlockingRule } from './block' +import { alerts, getProjectInfo } from './github' +import { acmeRenewError } from './acme' -export const adminApis: ApiHandlers = { +export const adminApis = { ...vfsApis, ...accountsApis, @@ -78,6 +81,10 @@ export const adminApis: ApiHandlers = { async check_update() { return { options: await getUpdates() } }, + async wait_project_info() { // used by admin/home/check-for-updates + await getProjectInfo() + return {} + }, async ip_country({ ips }) { const res = await Promise.allSettled(ips.map(ip2country)) @@ -119,7 +126,12 @@ export const adminApis: ApiHandlers = { baseUrl: await getBaseUrlOrDefault(), roots: roots.get(), updatePossible: !await updateSupported() ? false : (await localUpdateAvailable()) ? 'local' : true, + autoCheckUpdateResult: autoCheckUpdateResult.get(), // in this form, we get the same type of the serialized json + alerts: alerts.get(), proxyDetected: getProxyDetected(), + cloudflareDetected, + ram: process.memoryUsage.rss(), + acmeRenewError, frpDetected: localhostAdmin.get() && !getProxyDetected() && getConnections().every(isLocalHost) && await frpDebounced(), @@ -135,10 +147,21 @@ export const adminApis: ApiHandlers = { return files }, -} + async add_block({ merge, ip, expire, comment }: BlockingRule & { merge?: Partial }) { + apiAssertTypes({ + string: { ip }, + string_undefined: { comment, expire }, + object_undefined: { merge }, + }) + const optionals = _.pickBy({ expire, comment }, v => v !== undefined) // passing undefined-s would override values in merge + addBlock({ ip, ...optionals }, merge) + return {} + } + +} satisfies ApiHandlers -for (const [k, was] of Object.entries(adminApis)) - adminApis[k] = (params, ctx) => { +for (const [k, was] of typedEntries(adminApis)) + (adminApis[k] as any) = ((params, ctx) => { if (!allowAdmin(ctx)) return new ApiError(HTTP_FORBIDDEN) if (ctxAdminAccess(ctx)) @@ -147,7 +170,7 @@ for (const [k, was] of Object.entries(adminApis)) return ctx.headers.accept === 'text/event-stream' ? new SendListReadable({ doAtStart: x => x.error(HTTP_UNAUTHORIZED, true, props) }) : new ApiError(HTTP_UNAUTHORIZED, props) - } + }) satisfies ApiHandler export const localhostAdmin = defineConfig('localhost_admin', true) export const adminNet = defineConfig('admin_net', '', v => makeNetMatcher(v, true) ) diff --git a/src/api.accounts.ts b/src/api.accounts.ts index 5affeb0d7..6f237d260 100644 --- a/src/api.accounts.ts +++ b/src/api.accounts.ts @@ -6,7 +6,7 @@ import { Account, accountCanLoginAdmin, accountHasPassword, accountsConfig, addA import _ from 'lodash' import { HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_NOT_FOUND } from './const' import { getCurrentUsername, invalidateSessionBefore } from './auth' -import { apiAssertTypes } from './misc' +import { apiAssertTypes, onlyTruthy } from './misc' export type AccountAdminSend = NonNullable> function prepareAccount(ac: Account | undefined) { @@ -31,7 +31,7 @@ export default { }, get_accounts() { - return { list: Object.values(accountsConfig.get()).map(prepareAccount) } + return { list: onlyTruthy(Object.values(accountsConfig.get()).map(prepareAccount)) } }, get_admins() { diff --git a/src/api.auth.ts b/src/api.auth.ts index 5a233d678..1184b8b7a 100644 --- a/src/api.auth.ts +++ b/src/api.auth.ts @@ -1,12 +1,12 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { Account, accountCanLogin, changeSrpHelper, getAccount, getFromAccount } from './perm' +import { Account, accountCanLogin, changeSrpHelper, expandUsername, getAccount, getFromAccount } from './perm' import { ApiError, ApiHandler } from './apiMiddleware' import { SRPServerSessionStep1 } from 'tssrp6a' import { ADMIN_URI, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND } from './const' import { ctxAdminAccess } from './adminApis' import { sessionDuration } from './middlewares' -import { getCurrentUsername, setLoggedIn, srpStep1 } from './auth' +import { getCurrentUsername, setLoggedIn, srpServerStep1 } from './auth' import { defineConfig } from './config' import events from './events' @@ -19,16 +19,16 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => { const account = getAccount(username) if (!ctx.session) return new ApiError(HTTP_SERVER_ERROR) - await events.emitAsync('attemptingLogin', ctx) + if ((await events.emitAsync('attemptingLogin', { ctx, username }))?.isDefaultPrevented()) return if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames ctx.logExtra({ u: username }) ctx.state.dontLog = false // log even if log_api is false return new ApiError(HTTP_UNAUTHORIZED) } try { - const { step1, ...rest } = await srpStep1(account) + const { srpServer, ...rest } = await srpServerStep1(account) const sid = Math.random() - ongoingLogins[sid] = step1 + ongoingLogins[sid] = srpServer setTimeout(()=> delete ongoingLogins[sid], 60_000) ctx.session.loggingIn = { username, sid } // temporarily store until process is complete return rest @@ -77,8 +77,10 @@ export const logout: ApiHandler = async ({}, ctx) => { } export const refresh_session: ApiHandler = async ({}, ctx) => { + const username = getCurrentUsername(ctx) return !ctx.session ? new ApiError(HTTP_SERVER_ERROR) : { - username: getCurrentUsername(ctx), + username, + expandedUsername: expandUsername(username), adminUrl: ctxAdminAccess(ctx) ? ctx.state.revProxyPath + ADMIN_URI : undefined, canChangePassword: canChangePassword(ctx.state.account), exp: keepSessionAlive.get() ? new Date(Date.now() + sessionDuration.compiled()) : undefined, diff --git a/src/api.get_file_list.ts b/src/api.get_file_list.ts index 395bd124e..ce557c1a1 100644 --- a/src/api.get_file_list.ts +++ b/src/api.get_file_list.ts @@ -17,9 +17,9 @@ import { ctxAdminAccess } from './adminApis' import { dontOverwriteUploading } from './upload' import { SendListReadable } from './SendList' -export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string, target?: string } +export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string, target?: string, icon?: string | true } -export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search, c, onlyFolders, admin }, ctx) => { +export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search, wild, c, onlyFolders, admin }, ctx) => { const node = await urlToNode(uri, ctx) const list = ctx.get('accept') === 'text/event-stream' ? new SendListReadable() : undefined if (!node) @@ -34,17 +34,19 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search return fail() offset = Number(offset) limit = Number(limit) - const filter = pattern2filter(String(search || '')) + search = String(search || '').toLocaleLowerCase() + const filter = wild === 'no' ? (s: string) => s.includes(search) + : pattern2filter(search) const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, depth: search ? Infinity : 0 }) const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry) const can_upload = admin || hasPermission(node, 'can_upload', ctx) - const fakeChild = await applyParentToChild(undefined, node) // can we delete children + const fakeChild = await applyParentToChild({ source: 'dummy-file' }, node) // used to check permission; simple but but can produce false results const can_delete = admin || hasPermission(fakeChild, 'can_delete', ctx) - const can_archive = admin || hasPermission(node, 'can_archive', ctx) + const can_archive = admin || hasPermission(fakeChild, 'can_archive', ctx) const can_comment = can_upload && areCommentsEnabled() const can_overwrite = can_upload && (can_delete || !dontOverwriteUploading.get()) - const comment = await getCommentFor(node.source) - const props = { can_archive, can_upload, can_delete, can_overwrite, can_comment, comment, accept: node.accept } + const comment = node.comment ?? await getCommentFor(node.source) + const props = { can_archive, can_upload, can_delete, can_overwrite, can_comment, comment, accept: node.accept, icon: getNodeIcon(node) } ctx.state.browsing = uri.replace(/\/{2,}/g, '/') updateConnectionForCtx(ctx) if (!list) @@ -101,6 +103,10 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search } } + function getNodeIcon(node: VfsNode) { + return node.icon?.includes('.') || node.icon // true = specific for this file, otherwise is a SYS_ICONS + } + async function nodeToDirEntry(ctx: Koa.Context, node: VfsNode): Promise { const { source, url } = node const name = getNodeName(node) @@ -108,7 +114,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search return name ? { n: name, url, target: node.target } : null const isFolder = await nodeIsDirectory(node) try { - const st = source ? await stat(source) : undefined + const st = source ? node.stats || await stat(source) : undefined const pl = node.can_list === WHO_NO_ONE ? 'l' : !hasPermission(node, 'can_list', ctx) ? 'L' : '' @@ -116,15 +122,16 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search const pr = node.can_read === WHO_NO_ONE && !(isFolder && filesInsideCould()) ? 'r' : !hasPermission(node, 'can_read', ctx) ? 'R' : '' - const pd = !can_delete && hasPermission(node, 'can_delete', ctx) ? 'd' : '' - const pa = isFolder && Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A' + const pd = Boolean(can_delete) === hasPermission(node, 'can_delete', ctx) ? '' : can_delete ? 'd' : 'D' + const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A' return { n: name + (isFolder ? '/' : ''), c: st?.ctime, m: !st || Math.abs(+st.mtime - +st.ctime) < 1000 ? undefined : st.mtime, s: isFolder ? undefined : st?.size, p: (pr + pl + pd + pa) || undefined, - comment: await getCommentFor(source), + comment: node.comment ?? await getCommentFor(source), + icon: getNodeIcon(node), web: await hasDefaultFile(node, ctx) ? true : undefined, } } diff --git a/src/api.log.ts b/src/api.log.ts index 41fc1a138..08b5d89ca 100644 --- a/src/api.log.ts +++ b/src/api.log.ts @@ -5,17 +5,17 @@ import { HTTP_NOT_ACCEPTABLE, HTTP_NOT_FOUND, wait } from './cross' import events from './events' import { loggers } from './log' import { SendListReadable } from './SendList' -import { serveFile } from './serveFile' +import { forceDownload, serveFile } from './serveFile' import { ips } from './ips' export default { - async get_log_file({ file = 'log', range = '' }, ctx) { + async get_log_file({ file = 'log', range = '' }, ctx) { // this is limited to logs on file, and serves the file instead of a list of records const log = _.find(loggers, { name: file }) if (!log) throw HTTP_NOT_FOUND if (!log.path) throw HTTP_NOT_ACCEPTABLE - ctx.attachment(log.path) + forceDownload(ctx, log.path) if (range) ctx.request.header.range = `bytes=${range}` if (ctx.method === 'POST') // this would cause method_not_allowed @@ -44,6 +44,7 @@ export default { ctx.res.once('close', events.on('console', x => list.add(x))) return } + // for other logs we only provide updates. Use get_log_file to download past content if (!_.find(loggers, { name: file })) return list.error(HTTP_NOT_FOUND, true) list.ready() diff --git a/src/api.monitor.ts b/src/api.monitor.ts index a3b6e3b6d..49776b5b1 100644 --- a/src/api.monitor.ts +++ b/src/api.monitor.ts @@ -69,9 +69,9 @@ export default { agent: shortenAgent(ctx.get('user-agent')), archive: s.archive, ...s.browsing ? { op: 'browsing', path: decodeURIComponent(s.browsing) } - : s.uploadPath ? { op: 'upload',path: decodeURIComponent(s.uploadPath) } + : s.uploadPath ? { op: 'upload', path: decodeURIComponent(s.uploadPath) } : { - op: !s.considerAsGui && s.op || undefined, + op: !s.considerAsGui && (ctx.state.archive || ctx.state.vfsNode) ? 'download' : undefined, path: try_(() => decodeURIComponent(ctx.originalUrl), () => ctx.originalUrl), }, opProgress: _.isNumber(s.opProgress) ? _.round(s.opProgress, 3) : undefined, diff --git a/src/api.plugins.ts b/src/api.plugins.ts index c33731270..a3e1577b0 100644 --- a/src/api.plugins.ts +++ b/src/api.plugins.ts @@ -3,14 +3,16 @@ import { AvailablePlugin, enablePlugins, getAvailablePlugins, getPluginConfigFields, mapPlugins, Plugin, pluginsConfig, PATH as PLUGINS_PATH, enablePlugin, getPluginInfo, setPluginConfig, isPluginRunning, - stopPlugin, startPlugin, CommonPluginInterface, getMissingDependencies, + stopPlugin, startPlugin, CommonPluginInterface, getMissingDependencies, findPluginByRepo, } from './plugins' import _ from 'lodash' import assert from 'assert' import { HTTP_CONFLICT, newObj, waitFor } from './misc' import { ApiError, ApiHandlers } from './apiMiddleware' import { rm } from 'fs/promises' -import { downloadPlugin, getFolder2repo, readOnlineCompatiblePlugin, readOnlinePlugin, searchPlugins } from './github' +import { + downloadPlugin, getFolder2repo, readOnlineCompatiblePlugin, readOnlinePlugin, searchPlugins, downloading +} from './github' import { HTTP_FAILED_DEPENDENCY, HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const' import { SendListReadable } from './SendList' @@ -28,10 +30,18 @@ const apis: ApiHandlers = { }) }, - async get_plugin_updates() { + async get_plugin_updates({}, ctx) { return new SendListReadable({ async doAtStart(list) { const errs: string[] = [] + list.events(ctx, { + pluginDownload({ repo, status }) { + list.update({ id: findPluginByRepo(repo)?.id }, { downloading: status ?? null }) + }, + pluginDownloaded({ id }) { + list.update({ id }, { updated: true }) + } + }) await Promise.allSettled(_.map(getFolder2repo(), async (repo, folder) => { try { if (!repo) return @@ -39,11 +49,13 @@ const apis: ApiHandlers = { if (!online) return const disk = getPluginInfo(folder) if (!disk) return // plugin removed in the meantime? - if (online.version !== disk.version) { // not just newer one, in case a version was retired - online.id = disk.id // id is installation-dependant, and online cannot know - online.repo = serialize(disk).repo // show the user the current repo we are getting this update from, not a possibly-changed future one - list.add(online) - } + if (online.version === disk.version) return // different, not just newer ones, in case a version was retired + list.add(Object.assign(online, { + id: disk.id, // id is installation-dependant, and online cannot know + repo: serialize(disk).repo, // show the user the current repo we are getting this update from, not a possibly-changed future one + downgrade: online.version! < disk.version, + downloading: _.isString(online.repo) && downloading[online.repo], + })) } catch (err: any) { if (err.message !== '404') // the plugin is declaring a wrong repo errs.push(err.code || err.message) @@ -51,7 +63,7 @@ const apis: ApiHandlers = { })) for (const x of _.uniq(errs)) list.error(x) - list.close() + list.ready() } }) }, @@ -104,9 +116,9 @@ const apis: ApiHandlers = { if (repos.includes(repo)) list.update({ id: repo }, { installed: false }) }, - pluginDownload({ id, status }) { - if (repos.includes(id)) - list.update({ id }, { downloading: status ?? null }) + pluginDownload({ repo, status }) { + if (repos.includes(repo)) + list.update({ id: repo }, { downloading: status ?? null }) } }) try { diff --git a/src/api.vfs.ts b/src/api.vfs.ts index 85eb7af49..64a5c8f75 100644 --- a/src/api.vfs.ts +++ b/src/api.vfs.ts @@ -10,7 +10,10 @@ import { dirStream, enforceFinal, enforceStarting, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, PERM_KEYS, VfsNodeAdminSend } from './misc' -import { IS_WINDOWS, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE } from './const' +import { + IS_WINDOWS, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE, + IS_BINARY, APP_PATH +} from './const' import { getDiskSpace, getDiskSpaces, getDrives } from './util-os' import { getBaseUrlOrDefault, getServerStatus } from './listen' import { promisify } from 'util' @@ -23,7 +26,7 @@ async function urlToNodeOriginal(uri: string) { return n?.isTemp ? n.original : n } -const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url','target', ...PERM_KEYS] +const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url','target','comment','icon', ...PERM_KEYS] const apis: ApiHandlers = { @@ -32,7 +35,7 @@ const apis: ApiHandlers = { async function recur(node=vfs): Promise { const { source } = node - const stats = !source ? undefined : await stat(source!).catch(() => undefined) + const stats = !source ? undefined : (node.stats || await stat(source!).catch(() => undefined)) const isDir = !nodeIsLink(node) && (!source || (stats?.isDirectory() ?? node.children?.length! > 0)) const copyStats: Pick = stats ? _.pick(stats, ['size', 'ctime', 'mtime']) : { size: source ? -1 : undefined } @@ -199,14 +202,16 @@ const apis: ApiHandlers = { try { const matching = makeMatcher(fileMask) path = isWindowsDrive(path) ? path + '\\' : resolve(path || '/') - for await (const [name, isDir] of dirStream(path)) { + for await (const entry of dirStream(path)) { if (ctx.req.aborted) return + const {path:name} = entry + const isDir = entry.isDirectory() if (!isDir) if (!files || fileMask && !matching(name)) continue try { - const stats = await stat(join(path, name)) + const stats = entry.stats || await stat(join(path, name)) list.add({ n: name, s: stats.size, @@ -231,6 +236,7 @@ const apis: ApiHandlers = { const url = h.srv!.name + '://localhost:' + h.port for (const k of ['*', 'Directory']) { await reg('add', WINDOWS_REG_KEY.replace('*', k), '/ve', '/f', '/d', 'Add to HFS (new)') + await reg('add', WINDOWS_REG_KEY.replace('*', k), '/v', 'icon', '/f', '/d', IS_BINARY ? process.execPath : APP_PATH + '\\hfs.ico') await reg('add', WINDOWS_REG_KEY.replace('*', k) + '\\command', '/ve', '/f', '/d', `powershell -WindowStyle Hidden -Command " $wsh = New-Object -ComObject Wscript.Shell; $j = @{parent=@'\n${parent}\n'@; source=@'\n%1\n'@} | ConvertTo-Json -Compress diff --git a/src/apiMiddleware.ts b/src/apiMiddleware.ts index d987a68c8..ccfbbfe29 100644 --- a/src/apiMiddleware.ts +++ b/src/apiMiddleware.ts @@ -4,8 +4,9 @@ import Koa from 'koa' import createSSE from './sse' import { Readable } from 'stream' import { asyncGeneratorToReadable, CFG, Promisable } from './misc' -import { HTTP_BAD_REQUEST, HTTP_FOOL, HTTP_NOT_FOUND } from './const' +import { HTTP_BAD_REQUEST, HTTP_FOOL, HTTP_NOT_FOUND, PLUGIN_CUSTOM_REST_PREFIX } from './const' import { defineConfig } from './config' +import { firstPlugin } from './plugins' export class ApiError extends Error { constructor(public status:number, message?:string | Error | object) { @@ -13,7 +14,8 @@ export class ApiError extends Error { } } type ApiHandlerResult = Record | ApiError | Readable | AsyncGenerator | null -export type ApiHandler = (params:any, ctx:Koa.Context) => Promisable +// allow defining extra parameters that can be used when an api to invoke another (like copy_files) +export type ApiHandler = (params:any, ctx:Koa.Context, ...ignore: unknown[]) => Promisable export type ApiHandlers = Record const logApi = defineConfig(CFG.log_api, true) @@ -31,7 +33,9 @@ export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware { || apiName.startsWith('get_') // "get_" apis are safe because they make no change if (!safe) return send(HTTP_FOOL, "missing header x-hfs-anti-csrf=1") - const apiFun = apis.hasOwnProperty(apiName) && apis[apiName]! + const customApiRest = apiName.startsWith(PLUGIN_CUSTOM_REST_PREFIX) && apiName.slice(PLUGIN_CUSTOM_REST_PREFIX.length) + const apiFun = customApiRest && firstPlugin(pl => pl.getData().customRest?.[customApiRest]) + || apis.hasOwnProperty(apiName) && apis[apiName]! if (!apiFun) return send(HTTP_NOT_FOUND, 'invalid api') // we don't rely on SameSite cookie option because it's https-only diff --git a/src/auth.ts b/src/auth.ts index c2048e4dd..3222f4f76 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,21 +3,21 @@ import { HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './cross-const' import { SRPParameters, SRPRoutines, SRPServerSession } from 'tssrp6a' import { Context } from 'koa' import { srpClientPart } from './srp' -import { DAY, getOrSet } from './cross' +import { CFG, DAY, getOrSet } from './cross' import { createHash } from 'node:crypto' import events from './events' const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters()) -export async function srpStep1(account: Account) { +export async function srpServerStep1(account: Account) { if (!account.srp) throw HTTP_NOT_ACCEPTABLE const [salt, verifier] = account.srp.split('|') if (!salt || !verifier) throw Error("malformed account") const srpSession = new SRPServerSession(srp6aNimbusRoutines) - const step1 = await srpSession.step1(account.username, BigInt(salt), BigInt(verifier)) - return { step1, salt, pubKey: String(step1.B) } // cast to string cause bigint can't be jsonized + const srpServer = await srpSession.step1(account.username, BigInt(salt), BigInt(verifier)) + return { srpServer, salt, pubKey: String(srpServer.B) } // cast to string cause bigint can't be jsonized } const cache: any = {} @@ -26,10 +26,10 @@ export async function srpCheck(username: string, password: string) { if (!account?.srp || !password) return const k = createHash('sha256').update(username + password + account.srp).digest("hex") const good = await getOrSet(cache, k, async () => { - const { step1, salt, pubKey } = await srpStep1(account) + const { srpServer, salt, pubKey } = await srpServerStep1(account) const client = await srpClientPart(username, password, salt, pubKey) setTimeout(() => delete cache[k], 60_000) - return step1.step2(client.A, client.M1).then(() => 1, () => 0) + return srpServer.step2(client.A, client.M1).then(() => 1, () => 0) }) return good ? account : undefined } @@ -52,6 +52,8 @@ export async function setLoggedIn(ctx: Context, username: string | false) { if (!a) return s.username = normalizeUsername(username) s.ts = Date.now() + const k = CFG.allow_session_ip_change + s[k] = k in ctx.query || Boolean(ctx.state.params?.[k]) || undefined if (!a.expire && a.days_to_live) updateAccount(a, { expire: new Date(Date.now() + a.days_to_live! * DAY) }) await events.emitAsync('login', ctx) diff --git a/src/block.ts b/src/block.ts index 90bfddb04..d2bd86f01 100644 --- a/src/block.ts +++ b/src/block.ts @@ -4,6 +4,7 @@ import { defineConfig } from './config' import { disconnect, getConnections, normalizeIp } from './connections' import { makeNetMatcher, MINUTE, onlyTruthy } from './misc' import { Socket } from 'net' +import _ from 'lodash' export interface BlockingRule { ip: string, comment?: string, expire?: Date, disabled?: boolean } @@ -33,3 +34,11 @@ setInterval(() => { // twice a minute, check if any block has expired console.log("blocking rules:", n, "expired") block.set(next) }, MINUTE/2) + +export function addBlock(rule: BlockingRule, merge?: Partial) { + block.set(was => { + const foundIdx = merge ? _.findIndex(was, merge) : -1 + return foundIdx < 0 ? [...was, { ...merge, ...rule }] + : was.map((x, i) => i === foundIdx ? { ...x, ...rule, ip: `${x.ip}|${rule.ip}` } : x) + }) +} \ No newline at end of file diff --git a/src/commands.ts b/src/commands.ts index fc1877cf4..96a9cb13b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -11,7 +11,7 @@ import { createInterface } from 'readline' import { getAvailablePlugins, mapPlugins, startPlugin, stopPlugin } from './plugins' import { purgeFileAttr } from './fileAttr' import { downloadPlugin } from './github' -import { formatBytes, formatSpeed, formatTimestamp, makeMatcher } from './cross' +import { Dict, formatBytes, formatSpeed, formatTimestamp, makeMatcher } from './cross' import apiMonitor from './api.monitor' if (!argv.updating) @@ -30,8 +30,11 @@ if (!argv.updating) function parseCommandLine(line: string) { if (!line) return - const [name, ...params] = line.trim().split(/ +/) - const cmd = (commands as any)[name!] + let [name, ...params] = line.trim().split(/ +/) + name = aliases[name!] || name + let cmd = (commands as any)[name!] + if (cmd?.alias) + cmd = (commands as any)[cmd.alias] if (!cmd) return console.error("cannot understand entered command, try 'help'") if (cmd.cb.length > params.length) @@ -44,6 +47,10 @@ function parseCommandLine(line: string) { }) } +const aliases: Dict = { + exit: 'quit', +} + const commands = { help: { params: '', diff --git a/src/config.ts b/src/config.ts index dbcaefbc0..cd8ed0481 100644 --- a/src/config.ts +++ b/src/config.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 { argv, ORIGINAL_CWD, VERSION } from './const' +import { argv, ORIGINAL_CWD, VERSION, CONFIG_FILE } from './const' import { watchLoad } from './watchLoad' import yaml from 'yaml' import _ from 'lodash' @@ -10,8 +10,6 @@ import { join, resolve } from 'path' import events from './events' import { copyFile, stat } from 'fs/promises' -const FILE = 'config.yaml' - // keep definition of config properties const configProps: Record = {} @@ -19,11 +17,11 @@ let started = false // this will tell the difference for subscribeConfig()s that let state: Record = {} // current state of config properties const filePath = with_(argv.config || process.env.HFS_CONFIG, p => { if (!p) - return FILE + return CONFIG_FILE p = resolve(ORIGINAL_CWD, p) try { if (statSync(p).isDirectory()) // try to detect if path points to a folder, in which case we add the standard filename - return join(p, FILE) + return join(p, CONFIG_FILE) } catch {} return p diff --git a/src/connections.ts b/src/connections.ts index 544780b50..2bf975c74 100644 --- a/src/connections.ts +++ b/src/connections.ts @@ -40,6 +40,9 @@ export function normalizeIp(ip: string) { const all: Connection[] = [] export function newConnection(socket: Socket) { + const ip = normalizeIp(socket.remoteAddress || '') + if (events.emit('newSocket', { socket, ip })?.isDefaultPrevented()) + return socket.destroy() new Connection(socket) } @@ -78,6 +81,6 @@ export function disconnect(what: Context | Socket, debugLog='') { if ('socket' in what) what = what.socket if (debugLog) - console.debug("disconnection:", debugLog, what.remoteAddress) + console.debug("disconnection:", debugLog, normalizeIp(what.remoteAddress || '')) return what.destroy() } \ No newline at end of file diff --git a/src/const.ts b/src/const.ts index ebec426d4..c8c1bf022 100644 --- a/src/const.ts +++ b/src/const.ts @@ -3,15 +3,25 @@ import minimist from 'minimist' import * as fs from 'fs' import { homedir } from 'os' -import { mkdirSync } from 'fs' +import _ from 'lodash' import { basename, dirname, join } from 'path' export * from './cross-const' -export const API_VERSION = 8.891 +export const API_VERSION = 9.6 export const COMPATIBLE_API_VERSION = 1 // while changes in the api are not breaking, this number stays the same, otherwise it is made equal to API_VERSION export const HFS_REPO = 'rejetto/hfs' export const argv = minimist(process.argv.slice(2)) +// you can add arguments with this file, currently used for the update process on mac/linux +export const ARGS_FILE = join(homedir(), 'hfs-args') +try { + const s = fs.readFileSync(ARGS_FILE, 'utf-8') + console.log('additional arguments', s) + _.defaults(argv, minimist(JSON.parse(s))) + fs.unlinkSync(ARGS_FILE) +} +catch {} + export const DEV = process.env.DEV || argv.dev ? 'DEV' : '' export const ORIGINAL_CWD = process.cwd() export const HFS_STARTED = new Date() @@ -23,9 +33,10 @@ export const RUNNING_BETA = VERSION.includes('-') export const HFS_REPO_BRANCH = RUNNING_BETA ? VERSION.split('.')[1] : 'main' export const IS_WINDOWS = process.platform === 'win32' export const IS_MAC = process.platform === 'darwin' -export const IS_BINARY = !basename(process.execPath).includes('node') // this won't be node if pkg was used -export const APP_PATH = dirname(IS_BINARY ? process.execPath : __dirname) +export const IS_BINARY = !/node|bun/.test(basename(process.execPath)) // this won't be node if pkg was used +export const APP_PATH = dirname(IS_BINARY ? process.execPath : __dirname) // __dirname's parent can be compared with cwd export const MIME_AUTO = 'auto' +export const CONFIG_FILE = 'config.yaml' // we want this to be the first stuff to be printed, then we print it in this module, that is executed at the beginning if (DEV) console.clear() @@ -36,18 +47,27 @@ console.log('started', HFS_STARTED.toLocaleString(), DEV) console.log('version', VERSION||'-') console.log('build', BUILD_TIMESTAMP||'-') const winExe = IS_WINDOWS && process.execPath.match(/(? = Record export type Falsy = false | null | undefined | '' | 0 type Truthy = T extends false | '' | 0 | null | undefined | void ? never : T export type Callback = (x:IN) => OUT export type Promisable = T | Promise - +export type StringifyProps = { [P in keyof T]: Exclude extends T[P] ? string | Exclude : T[P] } export interface VfsPerms { can_see?: Who can_read?: Who @@ -88,7 +89,7 @@ const MULTIPLIERS = ['', 'K', 'M', 'G', 'T'] export function formatBytes(n: number, { post='B', k=1024, digits=NaN, sep=' ' }={}) { if (isNaN(Number(n)) || n < 0) return '' - const i = n && Math.floor(Math.log2(n) / Math.log2(k)) + const i = n && Math.min(MULTIPLIERS.length - 1, Math.floor(Math.log2(n) / Math.log2(k))) n /= k ** i const nAsString = i && !isNaN(digits) ? n.toFixed(digits) : _.round(n, isNaN(digits) ? (n >= 100 ? 0 : 1) : digits) @@ -149,6 +150,11 @@ export function splitAt(sub: string | number, all: string): [string, string] { return i < 0 ? [all,''] : [all.slice(0, i), all.slice(i + sub.length)] } +export function stringAfter(sub: string, all: string) { + const i = all.indexOf(sub) + return i < 0 ? '' : all.slice(i + sub.length) +} + export function truthy(value: T): value is Truthy { return Boolean(value) } @@ -363,11 +369,12 @@ export function isTimestampString(v: unknown) { return typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z*$/.test(v) } -export function isEqualLax(a: any,b: any): boolean { - return a == b //eslint-disable-line - || (a && b && typeof a === 'object' && typeof b === 'object' - && Object.entries(a).every(([k, v]) => isEqualLax(v, b[k])) - && Object.entries(b).every(([k, v]) => k in a || isEqualLax(v, a[k])) ) +export function isEqualLax(a: any,b: any, overrideRule?: (a: any, b: any) => boolean | undefined): boolean { + return overrideRule?.(a, b) ?? ( + a == b || a && b && typeof a === 'object' && typeof b === 'object' + && Object.entries(a).every(([k, v]) => isEqualLax(v, b[k], overrideRule)) + && Object.entries(b).every(([k, v]) => k in a /*already checked*/ || isEqualLax(v, a[k], overrideRule)) + ) } export function xlate(input: any, table: Record) { @@ -397,12 +404,12 @@ export async function promiseBestEffort(promises: Promise[]) { return res.filter(x => x.status === 'fulfilled').map((x: any) => x.value as T) } +// encode paths leaving / separator unencoded (not like encodeURIComponent), but still encode # export function pathEncode(s: string) { - return encodeURI(s).replace(/#/g, escape) + return s.replace(/[:&#'"% ?\\]/g, escape) // escape() is not utf8, but we are encoding only ascii chars } //unused function pathDecode(s: string) { return decodeURI(s).replace(/%23/g, '#') } - // run at a specific point in time, also solving the limit of setTimeout, which doesn't work with +32bit delays export function runAt(ts: number, cb: Callback) { let cancel = false @@ -467,6 +474,13 @@ export function safeDecodeURIComponent(s: string) { catch { return s } } +export function popKey(o: any, k: string) { + if (!o) return + const x = o[k] + delete o[k] + return x +} + export function shortenAgent(agent: string) { return _.findKey(BROWSERS, re => re.test(agent)) || /^[^/(]+ ?/.exec(agent)?.[0] diff --git a/src/debounceAsync.ts b/src/debounceAsync.ts index b32d3a202..a8da7c9c6 100644 --- a/src/debounceAsync.ts +++ b/src/debounceAsync.ts @@ -4,9 +4,9 @@ export function debounceAsync( // the function you want to not call too often, too soon callback: (...args: A) => Promise, - // time to wait after invocation of the debounced function. If you call again while waiting, the timer starts again. - wait: number=100, options: { + // time to wait after invocation of the debounced function. If you call again while waiting, the timer starts again. + wait?: number, // in a train of invocations, should we execute also the first one, or just the last one? leading?: boolean, // since the wait-ing is renewed at each invocation, indefinitely, do you want to put a cap to it? @@ -15,13 +15,13 @@ export function debounceAsync = Cancelable extends true ? undefined | T : T type MaybeR = MaybeUndefined - const { leading=false, maxWait=Infinity, cancelable=false, retain=0, retainFailure } = options + const { wait=0, leading=false, maxWait=Infinity, cancelable=false, retain=0, retainFailure } = options let started = 0 // latest callback invocation let runningCallback: Promise | undefined // latest callback invocation result let latestDebouncer: Promise // latest wrapper invocation @@ -90,7 +90,7 @@ export function singleWorkerFromBatchWorker(batchWorker: (ba const ret = batchWorker(batch) batch = [] // this is reset as batchWorker starts, but without waiting return ret - }, 100, { maxWait }) + }, { wait: 100, maxWait }) return (...args: Args) => { const idx = batch.push(args) - 1 return debounced().then((x: any) => x[idx]) diff --git a/src/dirStream.ts b/src/dirStream.ts new file mode 100644 index 000000000..26e871a30 --- /dev/null +++ b/src/dirStream.ts @@ -0,0 +1,139 @@ +import { makeQ } from './makeQ' +import { stat, readdir } from 'fs/promises' +import { IS_WINDOWS } from './const' +import { join } from 'path' +import { Readable } from 'stream' +import { pendingPromise } from './cross' +import { Stats, Dirent } from 'node:fs' +import fswin from 'fswin' + +export interface DirStreamEntry extends Dirent { + closingBranch?: Promise + stats?: Stats +} + +const dirQ = makeQ(3) + +export function createDirStream(startPath: string, { depth=0, hidden=true }) { + let stopped = false + let started = false + const closingQ: string[] = [] + const stream = new Readable({ + objectMode: true, + read() { + if (started) return + started = true + dirQ.add(() => readDir('', depth) + .then(res => { // don't make the job await for it, but use it to close the stream + Promise.resolve(res?.branchDone).then(() => { + stream.push(null) + }) + }, e => { + stream.emit('error', e) + return stream.push(null) + }) + ) + } + }) + stream.on('close', () => stopped = true) + return Object.assign(stream, { + stop() { + stopped = true + if (!dirQ.isWorking()) + stream.push(null) + }, + }) + + async function readDir(path: string, depth: number) { + if (stopped) return + const base = join(startPath, path) + const subDirsDone: Promise[] = [] + let n = 0 + let last: DirStreamEntry | undefined + if (IS_WINDOWS) { // use native apis to read 'hidden' attribute + const entries = await new Promise(res => fswin.find(base + '\\*', res)) + const methods = { + isDir: false, + isFile(){ return !this.isDir }, + isDirectory(){ return this.isDir }, + isBlockDevice(){ return false }, + isCharacterDevice() { return false }, + } + for (const f of entries) { + if (stopped) break + if (!hidden && f.IS_HIDDEN) continue + work(Object.assign(Object.create(methods), { + isDir: f.IS_DIRECTORY, + name: f.LONG_NAME, + stats: { size: f.SIZE, ctime: f.CREATION_TIME, mtime: f.LAST_WRITE_TIME } as Stats + })) + } + } + else for await (let entry of await readdir(base, { withFileTypes: true })) { + if (stopped) break + if (!hidden && entry.name[0] === '.') + continue + const stats = entry.isSymbolicLink() && await stat(join(base, entry.name)).catch(() => null) + if (stats === null) continue + if (stats) + entry = new DirentFromStats(entry.name, stats) + const expanded: DirStreamEntry = entry + if (stats) + expanded.stats = stats + work(expanded) + } + + function work(entry: DirStreamEntry) { + entry.path = (path && path + '/') + entry.name + if (last && closingQ.length) // pending entries + last.closingBranch = Promise.resolve(closingQ.shift()!) + last = entry + if (depth > 0 && entry.isDirectory()) { + const branchDone = pendingPromise() // per-job + const job = () => + readDir(entry.path, depth - 1) // recur + .then(x => x, () => {}) // mute errors + .then(res => { // don't await, as readDir must resolve without branch being done + if (!res?.n) + closingQ.push(entry.path) // no children to tell i'm done + Promise.resolve(res?.branchDone).then(() => + branchDone.resolve()) + }) + dirQ.add(job) // this won't start until next tick + subDirsDone.push(branchDone) + } + stream.push(entry) + n++ + } + const branchDone = Promise.allSettled(subDirsDone).then(() => {}) + if (last) // using streams, we don't know when the entries are received, so we need to notify on last item + last.closingBranch = branchDone.then(() => path) + else + closingQ.push(path) // ok, we'll ask next one to carry this info + // don't return the promise directly, as this job ends here, but communicate to caller the promise for the whole branch + return { branchDone, n } + } +} + +type DirentStatsKeysIntersection = keyof Dirent & keyof Stats; +const kStats = Symbol('stats') +// Adapting an internal class in Node.js to mimic the behavior of `Dirent` when creating it manually from `Stats`. +// https://github.com/nodejs/node/blob/a4cf6b204f0b160480153dc293ae748bf15225f9/lib/internal/fs/utils.js#L199C1-L213 +export class DirentFromStats extends Dirent { + private readonly [kStats]: Stats; + constructor(name: string, stats: Stats) { + // @ts-expect-error The constructor has parameters, but they are not represented in types. + // https://github.com/nodejs/node/blob/a4cf6b204f0b160480153dc293ae748bf15225f9/lib/internal/fs/utils.js#L164 + super(name, null); + this[kStats] = stats; + } +} + +for (const key of Reflect.ownKeys(Dirent.prototype)) { + const name = key as DirentStatsKeysIntersection | 'constructor'; + if (name === 'constructor') + continue; + DirentFromStats.prototype[name] = function () { + return this[kStats][name](); + }; +} \ No newline at end of file diff --git a/src/errorPages.ts b/src/errorPages.ts index a954b3e59..077e251a8 100644 --- a/src/errorPages.ts +++ b/src/errorPages.ts @@ -12,7 +12,7 @@ export function getErrorSections() { // to be used with errors whose recipient is possibly human export async function sendErrorPage(ctx: Koa.Context, code=ctx.status) { ctx.type = 'text' - ctx.set('content-disposition', '') // reset ctx.attachment + ctx.set('content-disposition', '') // reset ctx.attachment (or forceDownload) ctx.status = code const msg = HTTP_MESSAGES[ctx.status] if (!msg) return diff --git a/src/events.ts b/src/events.ts index 339ece971..6a1309921 100644 --- a/src/events.ts +++ b/src/events.ts @@ -6,7 +6,8 @@ const LISTENERS_SUFFIX = '\0listeners' export class BetterEventEmitter { protected listeners = new Map() - stop = Symbol() + preventDefault = Symbol() + stop = this.preventDefault // legacy pre-0.54 (introduced in 0.53) on(event: string | string[], listener: Listener, { warnAfter=10 }={}) { if (typeof event === 'string') event = [event] @@ -50,20 +51,30 @@ export class BetterEventEmitter { emit(event: string, ...args: any[]) { let cbs = this.listeners.get(event) if (!cbs?.size) return - const ret: any[] = [] + const output: any[] = [] + let prevented = false + const extra = { + output, + preventDefault() { prevented = true } + } for (const cb of cbs) { - const res = cb(...args) - if (res !== undefined) - ret.push(res) + const res = cb(...args, extra) + if (res === this.preventDefault) + extra.preventDefault() + else if (res !== undefined) + output.push(res) } - return Object.assign(ret, { - isDefaultPrevented: () => ret.some(r => r === this.stop), + return Object.assign(output, { + isDefaultPrevented: () => prevented, }) } - emitAsync(event: string, ...args: any[]) { - const ret = Promise.all(this.emit(event, ...args) || []) - return Object.assign(ret, { - isDefaultPrevented: async () => (await ret).some((r: any) => r === this.stop) + async emitAsync(event: string, ...args: any[]) { + const syncRet = this.emit(event, ...args) + if (!syncRet) return + const asyncRet = await Promise.all(syncRet) + return Object.assign(asyncRet, { + isDefaultPrevented: () => syncRet.isDefaultPrevented() + || asyncRet.some((r: any) => r === this.preventDefault) }) } } diff --git a/src/frontEndApis.ts b/src/frontEndApis.ts index 37fd3f4bd..de4d769a4 100644 --- a/src/frontEndApis.ts +++ b/src/frontEndApis.ts @@ -8,13 +8,16 @@ import Koa from 'koa' import { dirTraversal, isValidFileName } from './util-files' import { HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FAILED_DEPENDENCY, HTTP_FORBIDDEN, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED } from './const' -import { hasPermission, statusCodeForMissingPerm, urlToNode } from './vfs' +import { hasPermission, statusCodeForMissingPerm, urlToNode, VfsNode } from './vfs' +import fs from 'fs' import { mkdir, rename, copyFile, unlink } from 'fs/promises' import { basename, dirname, join } from 'path' import { getUploadMeta } from './upload' import { apiAssertTypes, deleteNode } from './misc' import { getCommentFor, setCommentFor } from './comments' import { SendListReadable } from './SendList' +import { ctxAdminAccess } from './adminApis' +import _ from 'lodash' export const frontEndApis: ApiHandlers = { get_file_list, @@ -34,6 +37,7 @@ export const frontEndApis: ApiHandlers = { async get_file_details({ uris }, ctx) { if (typeof uris?.[0] !== 'string') return new ApiError(HTTP_BAD_REQUEST, 'bad uris') + const isAdmin = ctxAdminAccess(ctx) return { details: await Promise.all(uris.map(async (uri: any) => { if (typeof uri !== 'string') @@ -41,8 +45,11 @@ export const frontEndApis: ApiHandlers = { const node = await urlToNode(uri, ctx) if (!node) return false - const upload = node.source && await getUploadMeta(node.source).catch(() => undefined) - return upload && { upload } + let upload = node.source && await getUploadMeta(node.source).catch(() => undefined) + if (!upload) return + if (!isAdmin) + upload = _.omit(upload, 'ip') + return { upload } })) } }, @@ -111,29 +118,41 @@ export const frontEndApis: ApiHandlers = { } }, - async move_files({ uri_from, uri_to }, ctx) { + async move_files({ uri_from, uri_to }, ctx, override) { apiAssertTypes({ array: { uri_from }, string: { uri_to } }) ctx.logExtra(null, { target: uri_from.map(decodeURI), destination: decodeURI(uri_to) }) const destNode = await urlToNode(uri_to, ctx) - const code = !destNode ? HTTP_NOT_FOUND : statusCodeForMissingPerm(destNode, 'can_upload', ctx) - if (code) return new ApiError(code) + const err = !destNode ? HTTP_NOT_FOUND : statusCodeForMissingPerm(destNode, 'can_upload', ctx) + if (err) + return new ApiError(err) return { - errors: await Promise.all(uri_from.map(async (src: any) => { - if (typeof src !== 'string') return HTTP_BAD_REQUEST - const srcNode = await urlToNode(src, ctx) - if (!srcNode) return HTTP_NOT_FOUND - const s = srcNode.source! - const d = join(destNode!.source!, basename(srcNode.source!)) + errors: await Promise.all(uri_from.map(async (from1: any) => { + if (typeof from1 !== 'string') return HTTP_BAD_REQUEST + const srcNode = await urlToNode(from1, ctx) + const src = srcNode?.source + if (!src) return HTTP_NOT_FOUND + const dest = join(destNode!.source!, basename(src)) + if (_.isFunction(override)) + return override?.(srcNode, dest) return statusCodeForMissingPerm(srcNode, 'can_delete', ctx) - || rename(s, d).catch(async e => { + || rename(src, dest).catch(async e => { if (e.code !== 'EXDEV') throw e // exdev = different drive - await copyFile(s, d) - await unlink(s) + await copyFile(src, dest) + await unlink(src) }).catch(e => e.code || String(e)) })) } }, + async copy_files(params, ctx) { + return frontEndApis.move_files!(params, ctx, // same parameters + (srcNode: VfsNode, dest: string) => // but override behavior + statusCodeForMissingPerm(srcNode, 'can_read', ctx) + || copyFile(srcNode.source!, dest, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE) + .catch(e => e.code || String(e)) + ) + }, + async comment({ uri, comment }, ctx) { apiAssertTypes({ string: { uri, comment } }) ctx.logExtra(null, { target: decodeURI(uri) }) diff --git a/src/github.ts b/src/github.ts index 875ccb567..6a8b75a5f 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,35 +1,44 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt import events from './events' -import { DAY, httpString, httpStream, unzip, AsapStream, debounceAsync, asyncGeneratorToArray, wait } from './misc' +import { DAY, httpString, httpStream, unzip, AsapStream, debounceAsync, asyncGeneratorToArray, wait, popKey } from './misc' import { DISABLING_SUFFIX, findPluginByRepo, getAvailablePlugins, getPluginInfo, isPluginEnabled, mapPlugins, parsePluginSource, PATH as PLUGINS_PATH, Repo, startPlugin, stopPlugin, STORAGE_FOLDER } from './plugins' import { ApiError } from './apiMiddleware' import _ from 'lodash' -import { DEV, HFS_REPO, HFS_REPO_BRANCH, HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_NOT_ACCEPTABLE, - HTTP_SERVER_ERROR } from './const' +import { + DEV, HFS_REPO, HFS_REPO_BRANCH, HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_NOT_ACCEPTABLE, + HTTP_SERVER_ERROR, VERSION +} from './const' import { rename, rm } from 'fs/promises' import { join } from 'path' import { readFileSync } from 'fs' +import { storedMap } from './persistence' const DIST_ROOT = 'dist' type DownloadStatus = true | undefined -const downloading: Record = {} +export const downloading: { [repo:string]: DownloadStatus } = {} -function downloadProgress(id: string, status: DownloadStatus) { +function downloadProgress(repo: string, status: DownloadStatus) { if (status === undefined) - delete downloading[id] + delete downloading[repo] else - downloading[id] = status - events.emit('pluginDownload', { id, status }) + downloading[repo] = status + events.emit('pluginDownload', { repo, status }) } // determine default branch, possibly without consuming api quota async function getGithubDefaultBranch(repo: string) { - const test = await httpString(`https://github.com/${repo}/archive/refs/heads/main.zip`, { method: 'HEAD' }).then(() => 1, () => 0) + if (!repo.includes('/')) + throw 'malformed repo' + const test = await httpString(`https://github.com/${repo}/archive/refs/heads/main.zip`, { method: 'HEAD' }).then(() => 1, (err) => { + if (err?.cause?.statusCode !== 404) + throw err + return 0 + }) return test ? 'main' : (await getRepoInfo(repo))?.default_branch as string } @@ -44,8 +53,8 @@ export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }= console.log('downloading plugin', repo) downloadProgress(repo, true) try { + const pl = findPluginByRepo(repo) if (repo.includes('//')) { // custom repo - const pl = findPluginByRepo(repo) if (!pl) throw new ApiError(HTTP_BAD_REQUEST, "bad repo") const customRepo = ((pl as any).getData?.() || pl).repo @@ -60,9 +69,9 @@ export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }= const short = repo.split('/')[1] // second part, repo without the owner if (!short) throw new ApiError(HTTP_BAD_REQUEST, "bad repo") - const folder = overwrite ? _.findKey(getFolder2repo(), x => x===repo)! // use existing folder - : getFolder2repo().hasOwnProperty(short) ? repo.replace('/','-') // longer form only if another plugin is using short form, to avoid overwriting - : short + const folder = overwrite && pl?.id // use existing folder + || (getFolder2repo().hasOwnProperty(short) ? repo.replace('/','-') // longer form only if another plugin is using short form, to avoid overwriting + : short) const GITHUB_ZIP_ROOT = short + '-' + branch // GitHub puts everything within this folder return await go(`https://github.com/${repo}/archive/refs/heads/${branch}.zip`, folder, GITHUB_ZIP_ROOT + '/' + DIST_ROOT) @@ -101,8 +110,9 @@ export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }= await rename(tempInstallPath, installPath) .catch(e => { throw e.code !== 'ENOENT' ? e : new ApiError(HTTP_NOT_ACCEPTABLE, "missing main file") }) if (wasEnabled) - void startPlugin(folder) // don't wait, in case it fails to start + void startPlugin(folder) // don't wait, in case it fails to start. We still use startPlugin instead of enablePlugin, as it will take care of disabling other themes. .catch(() => {}) // it will possibly fail (with 'miss') because the plugin has probably not been loaded yet. + events.emit('pluginDownloaded', { id: folder, repo }) return folder } } @@ -213,11 +223,29 @@ export async function searchPlugins(text='', { skipRepos=[''] }={}) { })) } +export const alerts = storedMap.singleSync('alerts', []) // centralized hosted information, to be used as little as possible const FN = 'central.json' let builtIn = JSON.parse(readFileSync(join(__dirname, '..', FN), 'utf8')) export const getProjectInfo = debounceAsync( () => readGithubFile(`${HFS_REPO}/${HFS_REPO_BRANCH}/${FN}`) .then(JSON.parse, () => null) - .then(x => Object.assign({ ...builtIn }, DEV ? null : x) ), // fall back to built-in - 0, { retain: DAY, retainFailure: 60_000 } ) \ No newline at end of file + .then(o => { + o = Object.assign({ ...builtIn }, o) // fall back to built-in + // merge byVersions info in the main object, but collect alerts separately, to preserve multiple instances + const allAlerts: string[] = [o.alert] + for (const [ver, more] of Object.entries(popKey(o, 'byVersion') || {})) + if (VERSION.match(new RegExp(ver))) { + allAlerts.push((more as any).alert) + Object.assign(o, more) + } + _.remove(allAlerts, x => !x) + alerts.set(was => { + if (!_.isEqual(was, allAlerts)) + for (const a of allAlerts) + console.log("ALERT:", a) + return allAlerts + }) + return o + }), + { retain: DAY, retainFailure: 60_000 }) \ No newline at end of file diff --git a/src/lang.ts b/src/lang.ts index a9d4901f5..35b4a5873 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -17,7 +17,7 @@ export function code2file(code: string) { } export function file2code(fn: string) { - return fn.slice(PREFIX.length, -SUFFIX.length) + return fn.replace(PREFIX, '').replace(SUFFIX, '') } export async function getLangData(ctx: Koa.Context) { diff --git a/src/langs/hfs-lang-de.json b/src/langs/hfs-lang-de.json index 6e010b25d..54cf2702e 100644 --- a/src/langs/hfs-lang-de.json +++ b/src/langs/hfs-lang-de.json @@ -1,139 +1,166 @@ { - "author": "kumpu", - "version": 1.0, - "hfs_version": "0.47.2", + "author": "Uwe Wennmann", + "version": 0.1, + "hfs_version": "0.54.0", "translate": { "Select": "Auswählen", "n_files": "{n,plural,one{# Datei} other{# Dateien}}", "n_folders": "{n,plural,one{# Ordner} other{# Ordner}}", "filter_count": "{n,plural, one{# gefiltert} other{# gefiltert}}", "select_count": "{n,plural, one{# ausgewählt} other{# ausgewählt}}", - "filter_placeholder": "Filter...", - "Select some files": "Dateien auswählen", - "zip_checkboxes": "Nutzen Sie die Kästchen um Dateien auszuwählen, danach können sie die Zip-Funktion wieder benutzen", - "zip_tooltip_selected": "Ausgewählte Elemente als einzelne Zip-Datei herunterladen", - "zip_tooltip_whole": "Die ganze Liste (ungefiltert) als einzelne Zip-Datei herunterladen. Wenn Sie einige Elemente auswählen, werden nur diese herunterladen.", - "zip_confirm_search": "ALLE Ergebnisse dieser Suche als Zip-Datei herunterladen?", - "zip_confirm_folder": "GANZEN Ordner als Zip-Datei herunterladen?", - "select_tooltip": "Auswahl wird benutzt von \"Zip\" und \"Löschen\" (wenn verfügbar), aber Sie können die Liste auch filtern", - "delete_hint": "Clicken sie erst \"Auswählen\", um zu löschen", - "delete_confirm": "Lösche {n,plural, one{# Element} other{# Elemente}}?", - "delete_completed": "Löschen: {n} abgeschlossen", + "filter_placeholder": "Tippen Sie hier, um die Liste unten zu filtern", + "Select some files": "Wählen Sie einige Dateien aus", + "zip_checkboxes": "Verwenden Sie die Kontrollkästchen, um Dateien auszuwählen, dann können Sie Zip erneut verwenden.", + "zip_tooltip_selected": "Ausgewählte Elemente als einzelne ZIP-Datei herunterladen", + "zip_tooltip_whole": "Gesamte Liste (ungefiltert) als einzelne ZIP-Datei herunterladen. Wenn Sie Elemente auswählen, werden nur diese heruntergeladen.", + "zip_confirm_search": "ALLE Suchergebnisse als ZIP-Archiv herunterladen?", + "zip_confirm_folder": "Den GESAMTEN Ordner als ZIP-Archiv herunterladen?", + "select_tooltip": "Auswahl gilt für \"Zip\" und \"Löschen\" (wenn verfügbar), Sie können die Liste aber auch filtern.", + "delete_hint": "Zum Löschen zuerst auf Auswählen klicken.", + "delete_confirm": "{n,plural, one{# Element} other{# Elemente}} löschen?", + "delete_completed": "Löschvorgang: {n} abgeschlossen", "delete_failed": ", {n,plural, one{# fehlgeschlagen} other{# fehlgeschlagen}}", - "delete_select": "Wählen sie Elemente zum Löschen aus", + "delete_select": "Wählen Sie etwas zum Löschen aus.", "Delete": "Löschen", "Options": "Optionen", - "Search": "Suche", + "Search": "Suchen", "Zip": "Zip", - "search_msg": "Ordner und Unterordner durchsuchen", - "Searching": "Suche", - "Searched": "Gesucht", - "Clear search": "Ergebnisse löschen", - "Interrupted": "Abgebrochen", - "stopped_before": "Gestoppt bevor etwas gefunden wurde", - "empty_list": "Keine Treffer", - "filter_none": "Keine Treffer für Filter", - + "search_msg": "Diesen Ordner und Unterordner durchsuchen.", + "Searching": "Suche läuft", + "Searched": "Durchsucht", + "Clear search": "Suche löschen", + "Interrupted": "Unterbrochen", + "stopped_before": "Gestoppt, bevor etwas gefunden wurde.", + "empty_list": "Nichts vorhanden", + "filter_none": "Keine Übereinstimmung für diesen Filter.", "Admin-panel": "Admin-Panel", - "Login": "Login", - "Username": "Benutzer", + "Login": "Anmelden", + "Username": "Benutzername", "Password": "Passwort", - "login_untrusted": "Login abgebrochen: Serveridentität kann nicht vertraut werden", + "login_untrusted": "Anmeldung abgebrochen: Der Serveridentität kann nicht vertraut werden.", "login_bad_credentials": "Ungültige Anmeldedaten", - "login_bad_cookies": "Cookies funktionieren nicht - Login gescheitert", - "User panel": "User-Panel", - "Change password": "Passwort ändern", - "enter_pass": "Neues Passwort eingeben", - "enter_pass2": "Neues Passwort erneut eingeben", - "pass2_mismatch": "Die Passwörter stimmen nicht überein. Vorgang abgebrochen.", - "password_changed": "Passwort geändert", - "Logout": "Logout", + "login_bad_cookies": "Cookies funktionieren nicht - Anmeldung fehlgeschlagen", + "User panel": "Benutzer-Panel", + "Change password": "Das Passwort ändern.", + "enter_pass": "Neues Passwort eingeben.", + "enter_pass2": "Neues Passwort ERNEUT eingeben.", + "pass2_mismatch": "Das zweite eingegebene Passwort stimmt nicht mit dem ersten überein. Vorgang abgebrochen.", + "password_changed": "Passwort geändert.", + "Logout": "Abmelden", "connection error": "Verbindungsfehler", - "Full timestamp:": "Ganzer Zeitstempel:", - "Search was interrupted": "Suche wurde abgebrochen", - "Stop list": "Suche abbrechen", - - "download_starting": "Ihr Download sollte jetzt starten", - "wrong_account": "Account {u} hat keinen Zugriff, versuchen Sie einen anderen", - "no_upload_here": "Keine Uploadberechtigung für den momentanen Ordner", + "Full timestamp:": "Vollständiger Zeitstempel:", + "Search was interrupted": "Suche wurde unterbrochen", + "Stop list": "Liste stoppen", + "download_starting": "Ihr Download sollte jetzt beginnen.", + "wrong_account": "Konto {u} hat keinen Zugriff, versuchen Sie ein anderes.", + "no_upload_here": "Keine Upload-Berechtigung für den aktuellen Ordner.", "Create folder": "Ordner erstellen", "Pick files": "Dateien auswählen", "Pick folder": "Ordner auswählen", - "send_files": "Uploade {n,plural,one{# Datei} other{# Dateien}}, {size}", - "Clear": "Upload abbrechen", - "failed_upload": "Konnte {name} nicht hochladen", + "send_files": "{n,plural,one{# Datei} other{# Dateien}} senden, {size}", + "Clear": "Löschen", + "failed_upload": "Konnte {name} nicht hochladen.", "confirm_resume": "Upload fortsetzen?", - "file too large": "Datei zu groß", + "file too large": "Datei zu groß.", "Enter folder name": "Ordnername eingeben", - "Successfully created": "Erstellung erfolgreich", - "enter_folder": "Ordner öffnen", - "folder_exists": "Ein Ordner mit diesem Namen existiert bereits", - + "Successfully created": "Erfolgreich erstellt", + "enter_folder": "In den Ordner wechseln", + "folder_exists": "Ordner mit gleichem Namen existiert bereits", "Sort by:": "Sortieren nach: {by}", "name": "Name", - "extension": "Dateityp", + "extension": "Erweiterung", "size": "Größe", - "time": "Datum", - "Invert order": "Umgekehrte Reihenfolge", + "time": "Zeit", + "Invert order": "Reihenfolge umkehren", "Folders first": "Ordner zuerst", "Numeric names": "Numerische Namen", - "theme:": "Theme:", - "auto": "Auto", - "light": "Hell", - "dark": "Dunkel", + "theme:": "Design:", + "auto": "automatisch", + "light": "hell", + "dark": "dunkel", "parent folder": "Übergeordneter Ordner", - "home": "Home", - + "home": "Start", "Continue": "Fortfahren", "Confirm": "Bestätigen", - "Don't": "Abbrechen", + "Don't": "Nicht", "Warning": "Warnung", "Error": "Fehler", "Info": "Info", - "Unauthorized": "Nicht autorisiert", "Forbidden": "Verboten", "Not found": "Nicht gefunden", - "Server error": "Server Fehler", - - "Upload": "Upload", + "Server error": "Serverfehler", + "Upload": "Hochladen", "upload_concluded": "Upload abgeschlossen:", - "upload_finished": "{n} abgeschlossen ({size})", + "upload_finished": "{n} fertig ({size})", "upload_errors": "{n} fehlgeschlagen", "upload_file_rejected": "Einige Dateien wurden nicht akzeptiert", - "File menu": "Dateimenü", "Folder menu": "Ordnermenü", "Name": "Name", "file_open": "Öffnen", - "Download": "Download", + "Download": "Herunterladen", "Missing permission": "Fehlende Berechtigung", "Reload": "Neu laden", - "Get list": "Liste anzeigen", - "Skip existing files": "Überspringe existierende Dateien ", + "Get list": "Liste abrufen", + "Skip existing files": "Existierende Dateien überspringen", "Size": "Größe", - "Timestamp": "Datum", + "Timestamp": "Zeitstempel", "Show": "Anzeigen", "Loading failed": "Laden fehlgeschlagen", "Rename": "Umbenennen", - "Tiles mode:": "Kachelmodus:", - "off": "Aus", - "Operation successful": "Operation erfolgreich", + "Tiles mode:": "Kachel-Modus:", + "off": "aus", + "Operation successful": "Vorgang erfolgreich", "Uploader": "Uploader", - "Download counter": "Downloadzähler", - "Switch zoom mode": "Zoom Modus wechseln", + "Download counter": "Download-Zähler", + "Switch zoom mode": "Zoom-Modus wechseln", "Full screen": "Vollbild", - - "File Show help": "Dateianzeige - Hilfe", - "showHelpMain": "Tastaturbefehle:", + "File Show help": "Dateianzeige-Hilfe", + "showHelpMain": "Sie können die Tastatur für einige Aktionen verwenden:", "showHelp_←/→": "←/→", "showHelp_↑/↓": "↑/↓", "showHelp_space": "Leertaste", - "showHelp_←/→_body": "vorherige/nächste Datei", + "showHelp_←/→_body": "Zur vorherigen/nächsten Datei gehen.", "showHelp_↑/↓_body": "Hohe Bilder scrollen", - "showHelp_space_body": "Auswählen", - "showHelp_D_body": "Download", - "showHelp_Z_body": "Zoom Modus", - "showHelp_F_body": "Vollbildmodus" + "Destination": "Ziel", + "in_queue": "{n} in Warteschlange", + "enter_comment": "Kommentar für {name}", + "Comment": "Kommentar", + "upload_dd_hint": "Sie können Dateien per Drag&Drop in die Dateiliste hochladen", + "Upload not available": "Upload nicht verfügbar", + "Cut": "Ausschneiden", + "n_items": "{n,plural, one{# Element} other{# Elemente}}", + "good_bad": "{good} verschoben, {bad} fehlgeschlagen", + "after_cut": "Ihre Auswahl befindet sich jetzt in der Zwischenablage.\nGehen Sie zum Zielordner zum Einfügen.", + "Cancel clipboard": "Zwischenablage leeren", + "to_clipboard_source": "Zurück zum Quellordner", + "Paste": "Einfügen", + "clipboard_list": "Elemente in der Zwischenablage:", + "Close": "Schließen", + "Folder": "Ordner", + "Web page": "Webseite", + "Link": "Link", + "Auto-play": "Automatische Wiedergabe", + "autoplay_seconds": "Sekunden zum Warten bei Bildern", + "Select all": "Alles auswählen", + "go_first": "Zum ersten Element", + "go_last": "Zum letzten Element", + "Shuffle": "Zufällige Wiedergabe", + "Repeat": "Wiederholen", + "showHelpListShortcut": "In der Dateiliste, {key} gedrückt halten beim Klicken für schnelle Anzeige.", + "Invalid value": "Ungültiger Wert", + "upload_skipped": "{n} übersprungen", + "Overwrite policy": "Überschreibungsrichtlinie", + "Rename to avoid overwriting": "Umbenennen, um Überschreiben zu vermeiden.", + "Overwrite existing files": "Existierende Dateien überschreiben.", + "Menu": "Menü", + "clipboard": "Zwischenablage ({content})", + "to_clipboard_source_tooltip": "Zum Ordner gehen, in dem sich der Inhalt der Zwischenablage befindet.", + "more_items": "{n} weitere Element(e)", + "Show details": "Details anzeigen", + "upload_conflict": "existiert bereits", + "Logged in": "Angemeldet", + "Logged out": "Abgemeldet" } -} +} \ No newline at end of file diff --git a/src/langs/hfs-lang-en.json b/src/langs/hfs-lang-en.json index b334efe79..7d2ad8772 100644 --- a/src/langs/hfs-lang-en.json +++ b/src/langs/hfs-lang-en.json @@ -46,7 +46,7 @@ "enter_pass": "Enter new password", "enter_pass2": "RE-enter same new password", "pass2_mismatch": "The second password you entered did not match the first. Procedure aborted.", - "password_changed": "password_changed", + "password_changed": "Password changed", "Logout": "Logout", "connection error": "connection error", "Full timestamp:": "Full timestamp:", @@ -171,6 +171,9 @@ "Show details": "Show details", "upload_conflict": "already exists", "Logged in": "Logged in", - "Logged out": "Logged out" + "Logged out": "Logged out", + + "Cancel": "Cancel", + "allow_session_ip_change": "Allow IP change during this session" } } diff --git a/src/langs/hfs-lang-fi.json b/src/langs/hfs-lang-fi.json index 49fa36001..4ab828eef 100644 --- a/src/langs/hfs-lang-fi.json +++ b/src/langs/hfs-lang-fi.json @@ -1,7 +1,7 @@ { "author": "Pultsari & Win_7", - "version": 1.2, - "hfs_version": "0.54.0a1", + "version": 1.21, + "hfs_version": "0.54.0", "translate": { "Select": "Valitse", "n_files": "{n,plural,one{# tiedosto} other{# tiedostoa}}", @@ -175,6 +175,9 @@ "Show details": "Näytä yksityiskohdat", "upload_conflict": "on jo olemassa", "Logged in": "Kirjauduttu sisään", - "Logged out": "Kirjauduttu ulos" + "Logged out": "Kirjauduttu ulos", + + "Cancel": "Peruuta", + "allow_session_ip_change": "Salli IP vaihto tämän istunnon aikana" } } diff --git a/src/langs/hfs-lang-it.json b/src/langs/hfs-lang-it.json index 816086c74..8b32d8ad4 100644 --- a/src/langs/hfs-lang-it.json +++ b/src/langs/hfs-lang-it.json @@ -163,6 +163,9 @@ "Show details": "Mostra dettagli", "upload_conflict": "esiste già", "Logged in": "Benvenuto", - "Logged out": "Arrivederci" + "Logged out": "Arrivederci", + + "Cancel": "Annulla", + "allow_session_ip_change": "Permetti cambio IP in questa sessione" } } diff --git a/src/langs/hfs-lang-ja.json b/src/langs/hfs-lang-ja.json index 073bd9e43..9264c6df9 100644 --- a/src/langs/hfs-lang-ja.json +++ b/src/langs/hfs-lang-ja.json @@ -1,22 +1,22 @@ { "author": "Kuruton8", - "version": 1.0, - "hfs_version": "0.50.0", + "version": 1.1, + "hfs_version": "0.54.0", "translate": { "Select": "選択", "n_files": "{n,plural,one{# ファイル} other{# ファイル}}", "n_folders": "{n,plural,one{# フォルダ} other{# フォルダ}}", "filter_count": "{n,plural, one{# 絞り込み} other{# 絞り込み}}", "select_count": "{n,plural, one{# 選択} other{# 選択}}", - "filter_placeholder": "リストを絞り込む", - "Select some files": "ファイルを選択する", + "filter_placeholder": "絞り込み", + "Select some files": "ファイルを選択", "zip_checkboxes": "チェックボックスにチェックを入れてから、もう一度ZIPをクリックしてください", "zip_tooltip_selected": "選択されているファイルをZIP形式でダウンロード", "zip_tooltip_whole": "ZIP形式ですべてのリストをダウンロード なにか選択されている場合は選択済みのみダウンロードされます", "zip_confirm_search": "すべての検索結果をZIP形式でダウンロードしますか?", "zip_confirm_folder": "すべてのフォルダをZIP形式でダウンロードしますか?", "select_tooltip": "選択は \"ZIP\" と \"削除\" (可能な場合) に適用されますが、リストを絞り込むこともできます", - "delete_hint": "削除するには、選択をクリックしてください", + "delete_hint": "削除するには、選択してください", "delete_confirm": "{n,plural, one{# アイテム} other{# アイテム}}を削除しますか?", "delete_completed": "削除: {n} 個完了", "delete_failed": ", {n,plural, one{# 失敗} other{# 失敗}}", @@ -31,14 +31,14 @@ "Clear search": "検索をクリア", "Interrupted": "中断されました", "stopped_before": "発見する前に停止しました", - "empty_list": "ありません", + "empty_list": "なし", "filter_none": "フィルタに合致するものはありません", "Admin-panel": "管理者ページ", "Login": "ログイン", "Username": "ユーザ名", "Password": "パスワード", - "login_untrusted": "ログインを中断: 信用できないサーバ証明書です", + "login_untrusted": "ログインを中断: 信用できない証明書です", "login_bad_credentials": "無効な資格です", "login_bad_cookies": "クッキーが無効です ログイン失敗", "User panel": "ユーザページ", @@ -51,7 +51,7 @@ "connection error": "接続エラー", "Full timestamp:": "全タイムスタンプ:", "Search was interrupted": "検索は中断されました", - "Stop list": "リストを停止", + "Stop list": "表示を停止", "download_starting": "ダウンロードはまもなく開始します", "wrong_account": "アカウント {u}はアクセスできません 他のアカウントで試してください", @@ -125,21 +125,55 @@ "Full screen": "フルスクリーン", "File Show help": "ファイル表示のヘルプ", - "showHelpMain": "次のようなアクションをキーボードで行なえます:", + "showHelpMain": "次のようなアクションをキーで行なえます:", "showHelp_←/→": "←/→", "showHelp_↑/↓": "↑/↓", "showHelp_space": "スペース", "showHelp_←/→_body": "前/後のファイルへ移動", "showHelp_↑/↓_body": "縦画像のスクロール", - "showHelp_space_body": "選択", - "showHelp_D_body": "ダウンロード", - "showHelp_Z_body": "ズームモード", - "showHelp_F_body": "フルスクリーン", "Destination": "宛先", "in_queue": "{n}個キュー", - "enter_comment": "このファイルにコメントを残す", + "enter_comment": "{name}にコメント", "Comment": "コメント", - "upload_dd_hint": "ファイルのリスト上にドラッグ&ドロップでファイルをアップロードすることができます", - "Upload not available": "アップロードはできません" + "upload_dd_hint": "ドラッグ&ドロップでファイルをアップロードすることができます", + "Upload not available": "アップロード不可", + "Cut": "切り取り", + "n_items": "{n,plural, one{# item} other{# items}}", + "good_bad": "{good} 完了, {bad} 失敗", + "after_cut": "選択したファイルがクリップボードにあります。\n宛先のフォルダに貼付けすることができます。", + "Cancel clipboard": "クリップボードを清掃", + "to_clipboard_source": "元のフォルダに移動", + "Paste": "貼り付け", + "clipboard_list": "クリップボード中のファイル:", + + "Close": "閉じる", + "Folder": "フォルダ", + "Web page": "Webページ", + "Link": "リンク", + "Auto-play": "自動再生", + "autoplay_seconds": "写真の表示時間", + "Select all": "すべて選択", + "go_first": "最初の項目に移動", + "go_last": "最後の項目に移動", + "Shuffle": "シャッフル", + "Repeat": "リピート", + "showHelpListShortcut": "{key} を押しながらクリックするとファイルの内容を確認することができます", + "Invalid value": "無効な値です", + "upload_skipped": "{n}個スキップ", + "Overwrite policy": "上書きの設定", + "Rename to avoid overwriting": "上書きを防ぐため、名前を変更する", + "Overwrite existing files": "既にあるファイルを上書きする", + "Menu": "メニュー", + + "clipboard": "クリップボード ({content})", + "to_clipboard_source_tooltip": "クリップボード中のファイルのあるフォルダに移動", + "more_items": "更に{n}個のファイル", + "Show details": "詳細を表示", + "upload_conflict": "既に存在します", + "Logged in": "ログインしました", + "Logged out": "ログアウトしました", + + "Cancel": "キャンセル", + "allow_session_ip_change": "このセッション中にIPアドレスの変更を許可する" } -} +} \ No newline at end of file diff --git a/src/langs/hfs-lang-nl.json b/src/langs/hfs-lang-nl.json index def85dc88..305a1a52b 100644 --- a/src/langs/hfs-lang-nl.json +++ b/src/langs/hfs-lang-nl.json @@ -1,7 +1,7 @@ { "author": "H.F.P. Pit - PH5HP", "version": 1.0, - "hfs_version": "0.53.0", + "hfs_version": "0.54.0", "translate": { "Select": "Selecteer", "n_files": "{n,plural,one{# bestand} other{# bestanden}}", @@ -156,6 +156,9 @@ "to_clipboard_source_tooltip": "Ga naar de map waar de klembord inhoud is geplaatst", "more_items": "{n} meer item(s)", "Show details": "Laat details zien", - "upload_conflict": "bestaat al" + "upload_conflict": "bestaat al", + + "Cancel": "Cancel", + "allow_session_ip_change": "Sta toe dat het IP-adres veranderd gedurende deze sessie" } } \ No newline at end of file diff --git a/src/langs/hfs-lang-ru.json b/src/langs/hfs-lang-ru.json index 827653626..53e0301f6 100644 --- a/src/langs/hfs-lang-ru.json +++ b/src/langs/hfs-lang-ru.json @@ -174,6 +174,9 @@ "Show details": "Показать детали", "upload_conflict": "уже существует", "Logged in": "Вход выполнен", - "Logged out": "Выход выполнен" + "Logged out": "Выход выполнен", + + "Cancel": "Отмена", + "allow_session_ip_change": "Разрешить смену IP в этой сессии" } } diff --git a/src/listen.ts b/src/listen.ts index 051acaedd..4bb28c9fd 100644 --- a/src/listen.ts +++ b/src/listen.ts @@ -8,7 +8,7 @@ import { watchLoad } from './watchLoad' import { networkInterfaces } from 'os'; import { newConnection } from './connections' import open from 'open' -import { debounceAsync, ipForUrl, makeNetMatcher, MINUTE, objSameKeys, onlyTruthy, prefix, runAt, wait, } from './misc' +import { debounceAsync, ipForUrl, makeNetMatcher, MINUTE, objSameKeys, onlyTruthy, prefix, runAt, wait, xlate } from './misc' import { PORT_DISABLED, ADMIN_URI, argv, DEV, IS_WINDOWS } from './const' import findProcess from 'find-process' import { anyAccountCanLoginAdmin } from './adminApis' @@ -168,10 +168,18 @@ export const httpsPortCfg = defineConfig('https_port', PORT_DISABLED) httpsPortCfg.sub(considerHttps) listenInterface.sub(considerHttps) +function renderHost(host: string) { + return xlate(host, { + '0.0.0.0': "any IPv4", + '::': "any IPv6", + '': "any network", + }) +} + interface StartServer { port: number, host?:string } export function startServer(srv: typeof httpSrv, { port, host }: StartServer) { return new Promise(async resolve => { - if (!srv) return 0 + if (!srv) return resolve(0) try { if (port === PORT_DISABLED || !host && !await testIpV4()) // !host means ipV4+6, and if v4 port alone is busy we won't be notified of the failure, so we'll first test it on its own return resolve(0) @@ -179,7 +187,7 @@ export function startServer(srv: typeof httpSrv, { port, host }: StartServer) { srv.on('checkContinue', (req, res) => srv.emit('request', req, res)) port = await listen(host) if (port) - console.log(srv.name, "serving on", host||"any network", ':', port) + console.log(srv.name, "serving on", renderHost(host || ''), ':', port) resolve(port) } catch(e) { @@ -273,8 +281,9 @@ const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|a const isLinkLocal = makeNetMatcher('169.254.0.0/16|FE80::/10') export async function getIps(external=true) { + const only = { '0.0.0.0': 'IPv4', '::' : 'IPv6' }[listenInterface.get()] || '' const ips = onlyTruthy(Object.entries(networkInterfaces()).flatMap(([name, nets]) => - nets && !ignore.test(name) && nets.map(net => !net.internal && net.address) + nets && !ignore.test(name) && nets.map(net => !net.internal && (!only || only === net.family) && net.address) )) const e = external && defaultBaseUrl.externalIp if (e && !ips.includes(e)) @@ -290,7 +299,7 @@ export async function getIps(external=true) { export async function getUrls() { const on = listenInterface.get() - const ips = on ? [on] : await getIps() + const ips = on === renderHost(on) ? [on] : await getIps() return Object.fromEntries(onlyTruthy([httpSrv, httpsSrv].map(srv => { if (!srv?.listening) return false diff --git a/src/log.ts b/src/log.ts index 6e37c4f0f..e476d9139 100644 --- a/src/log.ts +++ b/src/log.ts @@ -68,7 +68,7 @@ const dontLogNet = defineConfig(CFG.dont_log_net, '127.0.0.1|::1', v => makeNetM const logUA = defineConfig(CFG.log_ua, false) const logSpam = defineConfig(CFG.log_spam, false) -const debounce = _.debounce(cb => cb(), 1000) +const debounce = _.debounce(cb => cb(), 1000) // with this technique, i'll be able to debounce some code respecting the references in its closure export const logMw: Koa.Middleware = async (ctx, next) => { const now = new Date() @@ -119,7 +119,7 @@ export const logMw: Koa.Middleware = async (ctx, next) => { const length = ctx.state.length ?? ctx.length const uri = ctx.originalUrl ctx.logExtra(ctx.state.includesLastByte && ctx.vfsNode && ctx.res.finished && { dl: 1 } - || ctx.state.op === 'upload' && { size: ctx.state.opTotal, ul: ctx.state.uploads }) + || ctx.state.uploadPath && { size: ctx.state.opTotal, ul: ctx.state.uploads }) if (conn?.country) ctx.logExtra({ country: conn.country }) if (logUA.get()) @@ -151,6 +151,7 @@ declare module "koa" { logExtra?: object completed?: Promise spam?: boolean // this request was marked as spam + considerAsGui?: boolean } } diff --git a/src/makeQ.ts b/src/makeQ.ts new file mode 100644 index 000000000..13ebcbf76 --- /dev/null +++ b/src/makeQ.ts @@ -0,0 +1,25 @@ +export function makeQ(parallelization=1) { + const running = new Set>() + const queued: Array<() => Promise> = [] + return { + add(toAdd: typeof queued[0]) { + queued.push(toAdd) + setTimeout(startNextIfPossible) // avoid nesting/stacking of jobs + }, + isWorking() { return running.size > 0 }, + isFree() { return running.size < parallelization }, + } + function startNextIfPossible() { + while (running.size < parallelization) { + const job = queued.pop() + if (!job) break // finished + const working = job() // start the job + if (!working) continue // it was canceled + running.add(working) + working.then(() => { + running.delete(working) + startNextIfPossible() + }) + } + } +} \ No newline at end of file diff --git a/src/middlewares.ts b/src/middlewares.ts index f6b8f91c9..a2ebd1c9c 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -3,7 +3,7 @@ import compress from 'koa-compress' import Koa from 'koa' import { API_URI, DEV, HTTP_FOOL } from './const' -import { DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from './misc' +import { CFG, DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from './misc' import { Readable } from 'stream' import { applyBlock } from './block' import { Account, accountCanLogin, getAccount } from './perm' @@ -16,8 +16,10 @@ import session from 'koa-session' import { app } from './index' import events from './events' +const allowSessionIpChange = defineConfig(CFG.allow_session_ip_change, false) const forceHttps = defineConfig('force_https', true) const ignoreProxies = defineConfig('ignore_proxies', false) +const allowAuthorizationHeader = defineConfig('authorization_header', true) export const sessionDuration = defineConfig('session_duration', Number(process.env.SESSION_DURATION) || DAY/1000, v => v * 1000) @@ -46,11 +48,12 @@ export const headRequests: Koa.Middleware = async (ctx, next) => { } let proxyDetected: undefined | Koa.Context +export let cloudflareDetected: undefined | Date export const someSecurity: Koa.Middleware = (ctx, next) => { ctx.request.ip = normalizeIp(ctx.ip) - // don't allow sessions to change ip const ss = ctx.session - if (ss?.username) + const allowIpChange = ss?.[allowSessionIpChange.key()] ?? allowSessionIpChange.get() // session can override server setting + if (ss?.username && (!allowIpChange || !ctx.secure && allowIpChange === 'https')) if (!ss.ip) ss.ip = ctx.ip else if (ss.ip !== ctx.ip) { @@ -70,6 +73,8 @@ export const someSecurity: Koa.Middleware = (ctx, next) => { proxyDetected = ctx ctx.state.whenProxyDetected = new Date() } + if (ctx.get('cf-ray')) + cloudflareDetected = new Date() } catch { return ctx.status = HTTP_FOOL @@ -112,22 +117,22 @@ export const prepareState: Koa.Middleware = async (ctx, next) => { if (!login) return const [u, p] = splitAt(':', String(login)) ctx.redirect(ctx.originalUrl.slice(0, -ctx.querystring.length-1)) // redirect to hide credentials - return doLogin(u, p) + return doLogin(u, p, 'url') } function getHttpAccount() { - const b64 = ctx.get('authorization')?.split(' ')[1] + const b64 = allowAuthorizationHeader.get() && ctx.get('authorization')?.split(' ')[1] if (!b64) return try { const [u, p] = atob(b64).split(':') - return doLogin(u!, p||'') + return doLogin(u!, p||'', 'header') } catch {} } - async function doLogin(u: string, p: string) { + async function doLogin(u: string, p: string, via: string) { if (!u || u === ctx.session?.username) return // providing credentials, but not needed - await events.emitAsync('attemptingLogin', ctx) + if ((await events.emitAsync('attemptingLogin', { ctx, username: u, via }))?.isDefaultPrevented()) return const a = await srpCheck(u, p) if (a) { await setLoggedIn(ctx, a.username) diff --git a/src/misc.ts b/src/misc.ts index df0001e35..b41664068 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -146,7 +146,7 @@ export async function deleteNode(ctx: Koa.Context, node: VfsNode, uri: string) { if (statusCodeForMissingPerm(node, 'can_delete', ctx)) return ctx.status try { - if (await events.emitAsync('deleting', { node, ctx }).isDefaultPrevented()) + if ((await events.emitAsync('deleting', { node, ctx }))?.isDefaultPrevented()) return null // stop ctx.logExtra(null, { target: decodeURI(uri) }) await rm(source, { recursive: true }) diff --git a/src/nat.ts b/src/nat.ts index f5ccc9434..e323c19ab 100644 --- a/src/nat.ts +++ b/src/nat.ts @@ -1,7 +1,7 @@ import { proxy } from 'valtio' import { Client } from 'nat-upnp-rejetto' import { debounceAsync } from './debounceAsync' -import { haveTimeout, HOUR, inCommon, ipForUrl, MINUTE, promiseBestEffort, repeat, try_, wantArray } from './cross' +import { haveTimeout, HOUR, inCommon, ipForUrl, MINUTE, promiseBestEffort, repeat, wantArray } from './cross' import { getProjectInfo } from './github' import _ from 'lodash' import { httpString } from './util-http' @@ -28,9 +28,9 @@ export const defaultBaseUrl = proxy({ export const upnpClient = new Client({ timeout: 4_000 }) const originalMethod = upnpClient.getGateway // other client methods call getGateway too, so this will ensure they reuse this same result -upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), 0, { retain: HOUR, retainFailure: 30_000 }) +upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), { retain: HOUR, retainFailure: 30_000 }) upnpClient.getGateway().then(res => { - console.debug('upnp', res.gateway.description) + console.log('upnp', res.gateway.description) }, e => console.debug('upnp failed:', e.message || String(e))) // poll external ip @@ -60,16 +60,16 @@ export const getPublicIps = debounceAsync(async () => { return validIps }) ))) return defaultBaseUrl.publicIps = _.uniq(ips.flat()) -}, 0, { retain: 10 * MINUTE }) +}, { retain: 10 * MINUTE }) export const getNatInfo = debounceAsync(async () => { + const gatewayIpPromise = findGateway().catch(() => undefined) const res = await haveTimeout(10_000, upnpClient.getGateway()).catch(() => null) const status = await getServerStatus() const mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null) - console.debug("mappings found", mappings?.map(x => x.description) || "none") - const gatewayIp = res && try_(() => new URL(res.gateway.description).hostname, () => console.debug('unexpected upnp gw', res.gateway?.description)) - || await findGateway().catch(() => undefined) + console.debug("mappings found", mappings?.map(x => x.description).join(', ') || "none") const localIps = await getIps(false) + const gatewayIp = await gatewayIpPromise const localIp = res?.address || (gatewayIp ? _.maxBy(localIps, x => inCommon(x, gatewayIp)) : localIps[0]) const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort) diff --git a/src/perm.ts b/src/perm.ts index d0eb6abb4..e87258e57 100644 --- a/src/perm.ts +++ b/src/perm.ts @@ -59,7 +59,7 @@ createAdminConfig.sub(v => { export async function createAdmin(password: string, username='admin') { const acc = await addAccount(username, { admin: true, password }, true) - console.log(acc ? "account admin created" : "something went wrong") + console.log(acc ? "account admin set" : "something went wrong") } const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters()) @@ -188,7 +188,8 @@ export function accountCanLogin(account: Account) { function allDisabled(account: Account): boolean { return Boolean(account.disabled || account.expire as any < Date.now() - || account.belongs?.length && account.belongs.map(u => getAccount(u, false)).every(a => a && allDisabled(a))) // every() returns true on empty arrays + || account.belongs?.length // don't every() on empty array, as it returns true + && account.belongs.map(u => getAccount(u, false)).every(a => a && allDisabled(a)) ) } export function accountCanLoginAdmin(account: Account) { diff --git a/src/persistence.ts b/src/persistence.ts index ab1977fae..362c6af2a 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -6,7 +6,8 @@ export const storedMap = new KvStorage({ defaultPutDelay: 5000, maxPutDelay: MINUTE, maxPutDelayCreate: 1000, - rewriteLater: true + rewriteLater: true, + bucketThreshold: 10_000, }) storedMap.open('data.kv') onProcessExit(() => storedMap.flush()) diff --git a/src/plugins.ts b/src/plugins.ts index 2a1da1305..86dea76e0 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -11,6 +11,7 @@ import { adjustStaticPathForGlob, callable, Callback, debounceAsync, Dict, getOrSet, objSameKeys, onlyTruthy, PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir } from './misc' +import * as misc from './misc' import { defineConfig, getConfig } from './config' import { DirEntry } from './api.get_file_list' import { VfsNode } from './vfs' @@ -25,6 +26,7 @@ import { KvStorage, KvStorageOptions } from '@rejetto/kvstorage' import { onProcessExit } from './first' import { notifyClient } from './frontEndApis' import { app } from './index' +import { addBlock } from './block' export const PATH = 'plugins' export const DISABLING_SUFFIX = '-disabled' @@ -113,6 +115,8 @@ async function initPlugin(pl: any, morePassedToInit?: T) { getHfsConfig: getConfig, customApiCall, notifyClient, + addBlock, + misc, ...morePassedToInit })) } @@ -137,7 +141,7 @@ export const pluginsMiddleware: Koa.Middleware = async (ctx, next) => { lastStatus = ctx.status lastBody = ctx.body } - if (res === true && !ctx.isStopped) { // legacy pre-0.53 + if (res === true && !ctx.isStopped) { //legacy pre-0.53 ctx.stop() warnOnce(`plugin ${id} is using deprecated API (return true on middleware) and may not work with future versions (check for an update to "${id}")`) } @@ -278,6 +282,20 @@ export function mapPlugins(cb:(plugin:Readonly, pluginName:string)=> }).filter(x => x !== undefined) as Exclude[] } +export function firstPlugin(cb:(plugin:Readonly, pluginName:string)=> T, includeServerCode=true) { + for (const [plName,pl] of Object.entries(plugins)) { + if (!includeServerCode && plName === SERVER_CODE_ID) continue + try { + const ret = cb(pl,plName) + if (ret !== undefined) + return ret + } + catch(e) { + console.log('plugin error', plName, String(e)) + } + } +} + type PluginMiddleware = (ctx:Koa.Context) => Promisable type Stop = true type CallMeAfter = ()=>any @@ -306,7 +324,7 @@ export function getAvailablePlugins() { return Object.values(availablePlugins) } -const rescanAsap = debounceAsync(rescan, 1000) +const rescanAsap = debounceAsync(rescan, { wait: 1000 }) if (!existsSync(PATH)) try { mkdirSync(PATH) } catch {} diff --git a/src/selfCheck.ts b/src/selfCheck.ts index fd5574fa7..75b8d9409 100644 --- a/src/selfCheck.ts +++ b/src/selfCheck.ts @@ -16,7 +16,6 @@ export const selfCheckMiddleware: Middleware = (ctx, next) => { ctx.state.skipFilters = true } - declare module "koa" { interface DefaultState { skipFilters?: boolean @@ -41,11 +40,12 @@ export async function selfCheck(url: string) { selfChecking = true for (const services of _.chunk(_.shuffle(prjInfo.selfCheckServices), 2)) { try { - return await Promise.any(services.map(async (svc) => { + return await Promise.any(services.map(async svc => { if (!svc.url || svc.type) throw 'unsupported ' + svc.type // only default type supported for now let { url: serviceUrl, body, regexpSuccess, regexpFailure, ...rest } = svc const service = new URL(serviceUrl).hostname console.log('trying external service', service) + console.debug(svc) body = applySymbols(body) serviceUrl = applySymbols(serviceUrl)! const res = await haveTimeout(6_000, httpString(serviceUrl, { family, ...rest, body })) diff --git a/src/serveFile.ts b/src/serveFile.ts index 3b915c630..1af52a9cc 100644 --- a/src/serveFile.ts +++ b/src/serveFile.ts @@ -15,11 +15,23 @@ import { getConnection, updateConnection } from './connections' import { getCurrentUsername } from './auth' import { sendErrorPage } from './errorPages' import { Readable } from 'stream' +import { createHash } from 'crypto' +import iconv from 'iconv-lite' const allowedReferer = defineConfig('allowed_referer', '') -const limitDownloads = downloadLimiter(defineConfig(CFG.max_downloads, 0), () => true) -const limitDownloadsPerIp = downloadLimiter(defineConfig(CFG.max_downloads_per_ip, 0), ctx => ctx.ip) -const limitDownloadsPerAccount = downloadLimiter(defineConfig(CFG.max_downloads_per_account, 0), ctx => getCurrentUsername(ctx) || undefined) +const maxDownloads = downloadLimiter(defineConfig(CFG.max_downloads, 0), () => true) +const maxDownloadsPerIp = downloadLimiter(defineConfig(CFG.max_downloads_per_ip, 0), ctx => ctx.ip) +const maxDownloadsPerAccount = downloadLimiter(defineConfig(CFG.max_downloads_per_account, 0), ctx => getCurrentUsername(ctx) || undefined) + +function toAsciiEquivalent(s: string) { + return iconv.encode(iconv.decode(Buffer.from(s), 'utf-8'), 'ascii').toString().replaceAll('?', '') +} + +export function forceDownload(ctx: Koa.Context, name='') { + // ctx.attachment is not working well on Windows. Eg: for file "èÖ.txt" it is producing `Content-Disposition: attachment; filename="??.txt"`. Koa uses module content-disposition, that actually produces a better result anyway: `` + ctx.set('Content-Disposition', 'attachment' + + (name && `; filename="${toAsciiEquivalent(name)}"; filename*=UTF-8''${encodeURI(name).replace(/#/g, '%23')}`)) +} export async function serveFileNode(ctx: Koa.Context, node: VfsNode) { const { source, mime } = node @@ -28,7 +40,7 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) { : _.find(mime, (val,mask) => matches(name, mask)) if (allowedReferer.get()) { const ref = /\/\/([^:/]+)/.exec(ctx.get('referer'))?.[1] // extract host from url - if (ref && ref !== host() // automatic accept if referer is basically the hosting domain + if (ref && ref !== host() // automatically accept if referer is basically the hosting domain && !matches(ref, allowedReferer.get())) return ctx.status = HTTP_FORBIDDEN } @@ -36,13 +48,13 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) { ctx.vfsNode = // legacy pre-0.51 (download-quota) ctx.state.vfsNode = node // useful to tell service files from files shared by the user if ('dl' in ctx.query) // please, download - ctx.attachment(name) + forceDownload(ctx, name) else if (ctx.get('referer')?.endsWith('/') && with_(ctx.get('accept'), x => x && !x.includes('text'))) ctx.state.considerAsGui = true await serveFile(ctx, source||'', mimeString) - if (await limitDownloadsPerAccount(ctx) === undefined) // returning false will not execute other limits - await limitDownloads(ctx) || await limitDownloadsPerIp(ctx) + if (await maxDownloadsPerAccount(ctx) === undefined) // returning false will not execute other limits + await maxDownloads(ctx) || await maxDownloadsPerIp(ctx) function host() { const s = ctx.host @@ -50,18 +62,20 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) { } } -const mimeCfg = defineConfig, (name: string) => string | undefined>('mime', { '*': MIME_AUTO }, obj => { +const mimeCfg = defineConfig, (name: string) => string | undefined>('mime', {}, obj => { const matchers = Object.keys(obj).map(k => makeMatcher(k)) const values = Object.values(obj) return (name: string) => values[matchers.findIndex(matcher => matcher(name))] }) +// after this number of seconds, the browser should check the server to see if there's a newer version of the file +const cacheControlDiskFiles = defineConfig('cache_control_disk_files', 5) + export async function serveFile(ctx: Koa.Context, source:string, mime?:string, content?: string | Buffer) { if (!source) return - const fn = basename(source) - mime = mime ?? mimeCfg.compiled()(fn) - if (!mime || mime === MIME_AUTO) + mime ??= mimeCfg.compiled()(basename(source)) + if (mime === undefined || mime === MIME_AUTO) mime = mimetypes.lookup(source) || '' if (mime) ctx.type = mime @@ -76,20 +90,23 @@ export async function serveFile(ctx: Koa.Context, source:string, mime?:string, c const stats = await promisify(stat)(source) // using fs's function instead of fs/promises, because only the former is supported by pkg if (!stats.isFile()) return ctx.status = HTTP_METHOD_NOT_ALLOWED - ctx.set('Last-Modified', stats.mtime.toUTCString()) - ctx.fileSource = // legacy pre-0.51 + const t = stats.mtime.toUTCString() + ctx.set('Last-Modified', t) + ctx.set('Etag', createHash('md5').update(source).update(t).digest('hex')) ctx.state.fileSource = source - ctx.fileStats = // legacy pre-0.51 ctx.state.fileStats = stats ctx.status = HTTP_OK if (ctx.fresh) return ctx.status = HTTP_NOT_MODIFIED if (content !== undefined) return ctx.body = content + const cc = cacheControlDiskFiles.get() + if (_.isNumber(cc)) + ctx.set('Cache-Control', `max-age=${cc}`) const { size } = stats const range = applyRange(ctx, size) ctx.body = createReadStream(source, range) - if (ctx.vfsNode) + if (ctx.state.vfsNode) monitorAsDownload(ctx, size, range?.start) } catch (e: any) { @@ -104,7 +121,6 @@ export function monitorAsDownload(ctx: Koa.Context, size?: number, offset?: numb ctx.body.on('end', () => updateConnection(conn, {}, { opProgress: 1 }) ) updateConnection(conn, {}, { - op: 'download', opProgress: 0, opTotal: size, opOffset: size && offset && (offset / size), @@ -153,7 +169,7 @@ declare module "koa" { function downloadLimiter(configMax: { get: () => number | undefined }, cbKey: (ctx: Koa.Context) => T | undefined) { const map = new Map() return (ctx: Koa.Context) => { - if (!ctx.body || ctx.state.considerAsGui) return // no file sent, cache hit + if (!ctx.body || ctx.state.considerAsGui) return // !body = no file sent, cache hit const k = cbKey(ctx) if (k === undefined) return // undefined = skip limit const max = configMax.get() diff --git a/src/serveGuiAndSharedFiles.ts b/src/serveGuiAndSharedFiles.ts index 35a7ad6b1..31ea4b6b6 100644 --- a/src/serveGuiAndSharedFiles.ts +++ b/src/serveGuiAndSharedFiles.ts @@ -96,15 +96,21 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => { return } const { get } = ctx.query - if (node.default && path.endsWith('/') && !get) // final/ needed on browser to make resource urls correctly with html pages - node = await urlToNode(node.default, ctx, node) ?? node + if (node.default && path.endsWith('/') && !get) { // final/ needed on browser to make resource urls correctly with html pages + const found = await urlToNode(node.default, ctx, node) + if (found && /\.html?/i.test(node.default)) + ctx.state.considerAsGui = true + node = found ?? node + } + if (get === 'icon') + return serveFile(ctx, node.icon || '|') // pipe to cause not-found if (!await nodeIsDirectory(node)) return node.url ? ctx.redirect(node.url) : !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED) // !dir && !source is not supported at this moment : !statusCodeForMissingPerm(node, 'can_read', ctx) ? serveFileNode(ctx, node) // all good : ctx.status !== HTTP_UNAUTHORIZED ? null // all errors don't need extra handling, except unauthorized : detectBasicAgent(ctx) ? (ctx.set('WWW-Authenticate', 'Basic'), sendErrorPage(ctx)) - : (ctx.state.serveApp = true) && serveFrontendFiles(ctx, next) // this is necessary to support standard urls with credentials, as chrome125 will send provided credentials only after attempt a GET without them, and after this error + : ctx.query.dl === undefined && (ctx.state.serveApp = true) && serveFrontendFiles(ctx, next) if (!path.endsWith('/')) return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl.replace(/(\?|$)/, '/$1')) // keep query-string, if any if (statusCodeForMissingPerm(node, 'can_list', ctx)) { @@ -119,10 +125,9 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => { return serveFrontendFiles(ctx, next) } ctx.set({ server: `HFS ${VERSION} ${BUILD_TIMESTAMP}` }) - if (basicWeb(ctx, node)) return return get === 'zip' ? zipStreamFromFolder(node, ctx) : get === 'list' ? sendFolderList(node, ctx) - : serveFrontendFiles(ctx, next) + : (basicWeb(ctx, node) || serveFrontendFiles(ctx, next)) } async function sendFolderList(node: VfsNode, ctx: Koa.Context) { diff --git a/src/serveGuiFiles.ts b/src/serveGuiFiles.ts index 0681e616e..242e11cc4 100644 --- a/src/serveGuiFiles.ts +++ b/src/serveGuiFiles.ts @@ -18,6 +18,7 @@ import { defineConfig, getConfig } from './config' import { getLangData } from './lang' import { dontOverwriteUploading } from './upload' +const splitUploads = defineConfig(CFG.split_uploads, 0) export const logGui = defineConfig(CFG.log_gui, false) _.each(FRONTEND_OPTIONS, (v,k) => defineConfig(k, v)) // define default values @@ -65,7 +66,7 @@ function adjustBundlerLinks(ctx: Koa.Context, uri: string, data: string | Buffer const getFaviconTimestamp = debounceAsync(async () => { const f = favicon.get() return !f ? 0 : fs.stat(f).then(x => x?.mtimeMs || 0, () => 0) -}, 0, { retain: 5_000 }) +}, { retain: 5_000 }) async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) { const session = await refresh_session({}, ctx) @@ -96,10 +97,6 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) { const isOpen = !isClose if (isHead && isOpen) return all + ` - ${isFrontend && ` - ${title.get()} - - ` + getSection('htmlHead')} + ${isFrontend && ` + ${title.get()} + + ${getSection('htmlHead')}`} ` if (isBody && isOpen) return `${all} @@ -129,7 +131,7 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) { return typeof v === 'string' && `\n--${pluginName}-${k}: ${v};` }).filter(Boolean).join('')).join('')} } - ${getSection('style')} + ${isFrontend && getSection('style')} ${isFrontend && mapPlugins((plug,id) => plug.frontend_css?.map(f => diff --git a/src/srp.ts b/src/srp.ts index 7247c02bd..3673d0fba 100644 --- a/src/srp.ts +++ b/src/srp.ts @@ -2,18 +2,18 @@ import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a' -export async function srpClientSequence(username:string, password:string, apiCall: (cmd:string, params:any) => any) { +export async function srpClientSequence(username:string, password:string, apiCall: (cmd:string, params:any) => any, extra?: object) { const { pubKey, salt } = await apiCall('loginSrp1', { username }) if (!salt) throw Error('salt') const client = await srpClientPart(username, password, salt, pubKey) - const res = await apiCall('loginSrp2', { pubKey: String(client.A), proof: String(client.M1) }) // bigint-s must be cast to string to be json-ed + const res = await apiCall('loginSrp2', { pubKey: String(client.A), proof: String(client.M1), ...extra }) // bigint-s must be cast to string to be json-ed await client.step3(BigInt(res.proof)).catch(() => Promise.reject('trust')) return res } export async function srpClientPart(username: string, password: string, salt: string, pubKey: string) { const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters()) - const srp = new SRPClientSession(srp6aNimbusRoutines); - const res = await srp.step1(username, password) + const srpClient = new SRPClientSession(srp6aNimbusRoutines); + const res = await srpClient.step1(username, password) return await res.step2(BigInt(salt), BigInt(pubKey)) } \ No newline at end of file diff --git a/src/update.ts b/src/update.ts index 8588fe442..e7d29bb8a 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,11 +1,11 @@ // This file is part of HFS - Copyright 2021-2023, Massimo Melina - License https://www.gnu.org/licenses/gpl-3.0.txt -import { getRepoInfo } from './github' -import { argv, HFS_REPO, IS_BINARY, IS_WINDOWS, RUNNING_BETA } from './const' +import { getProjectInfo, getRepoInfo } from './github' +import { ARGS_FILE, argv, HFS_REPO, IS_BINARY, IS_WINDOWS, RUNNING_BETA } from './const' import { dirname, join } from 'path' import { spawn, spawnSync } from 'child_process' -import { exists, httpStream, prefix, unzip, xlate } from './misc' -import { createReadStream, renameSync, unlinkSync } from 'fs' +import { DAY, exists, debounceAsync, httpStream, unzip, prefix, xlate, HOUR } from './misc' +import { createReadStream, renameSync, unlinkSync, writeFileSync } from 'fs' import { pluginsWatcher } from './plugins' import { chmod, stat } from 'fs/promises' import { Readable } from 'stream' @@ -13,29 +13,59 @@ import open from 'open' import { currentVersion, defineConfig, versionToScalar } from './config' import { cmdEscape, RUNNING_AS_SERVICE } from './util-os' import { onProcessExit } from './first' +import { storedMap } from './persistence' +import _ from 'lodash' const updateToBeta = defineConfig('update_to_beta', false) +const autoCheckUpdate = defineConfig('auto_check_update', true) +const lastCheckUpdate = storedMap.singleSync('lastCheckUpdate', 0) +const AUTO_CHECK_EVERY = DAY -interface Release { +export const autoCheckUpdateResult = storedMap.singleSync('autoCheckUpdateResult', undefined) +autoCheckUpdateResult.ready().then(() => { + autoCheckUpdateResult.set(v => { + if (!v) return // refresh isNewer, as currentVersion may have changed + v.isNewer = currentVersion.olderThan(v.tag_name) + return v + }) +}) +setInterval(debounceAsync(async () => { + if (!autoCheckUpdate.get()) return + if (Date.now() < lastCheckUpdate.get() + AUTO_CHECK_EVERY) return + console.log("checking for updates") + try { + const u = (await getUpdates(true))[0] + if (u) console.log("new version available", u.name) + autoCheckUpdateResult.set(u) + lastCheckUpdate.set(Date.now()) + } + catch {} +}), HOUR) + +export type Release = { // not using interface, as it will not work with kvstorage.Jsonable prerelease: boolean, tag_name: string, name: string, - assets: any[], - isNewer: boolean // introduced by us + body: string, + assets: { name: string, browser_download_url: string }[], + // fields introduced by us + isNewer: boolean + versionScalar: number } +const ReleaseKeys = ['prerelease', 'tag_name', 'name', 'body', 'assets', 'isNewer', 'versionScalar'] satisfies (keyof Release)[] +const ReleaseAssetKeys = ['name', 'browser_download_url'] satisfies (keyof Release['assets'][0])[] export async function getUpdates(strict=false) { + getProjectInfo() // check for alerts const stable: Release = await getRepoInfo(HFS_REPO + '/releases/latest') - const verStable = ver(stable) - const ret = await getBetas() stable.isNewer = currentVersion.olderThan(stable.tag_name) + stable.versionScalar = versionToScalar(stable.name) + const ret = await getBetas() if (stable.isNewer || RUNNING_BETA) ret.push(stable) - return ret.filter(x => !strict || x.isNewer) - - function ver(x: any) { - return versionToScalar(x.name) - } + // prune a bit, as it will be serialized, but it has a lot of unused data + return _.sortBy(ret, x => -x.versionScalar).filter(x => !strict || x.isNewer).map(x => + Object.assign(_.pick(x, ReleaseKeys), { assets: x.assets.map(a => _.pick(a, ReleaseAssetKeys)) })) async function getBetas() { if (!updateToBeta.get() && !RUNNING_BETA) return [] @@ -47,9 +77,9 @@ export async function getUpdates(strict=false) { if (!res.length) break const curV = currentVersion.getScalar() for (const x of res) { - if (!x.prerelease) continue // prerelease are all the end - const v = ver(x) - if (v <= verStable) // prerelease-s are locally ordered, so as soon as we reach verStable we are done + if (!x.prerelease || x.name.endsWith('-ignore')) continue + const v = x.versionScalar = versionToScalar(x.name) + if (v < stable.versionScalar) // we don't consider betas before stable return ret if (v === curV) continue // skip current x.isNewer = v > curV // make easy to know what's newer @@ -72,8 +102,9 @@ export async function updateSupported() { export async function update(tagOrUrl: string='') { if (!await updateSupported()) throw "only binary versions supports automatic update for now" + let doingLocal = '' let updateSource: Readable | false = tagOrUrl.includes('://') ? await httpStream(tagOrUrl) - : await localUpdateAvailable() && createReadStream(LOCAL_UPDATE) + : await localUpdateAvailable() && createReadStream(doingLocal=LOCAL_UPDATE) if (!updateSource) { if (/^\d/.test(tagOrUrl)) // work even if the tag is passed without the initial 'v' (useful for console commands) tagOrUrl = 'v' + tagOrUrl @@ -117,7 +148,10 @@ export async function update(tagOrUrl: string='') { catch {} renameSync(bin, oldBin) console.log("launching new version in background", newBinFile) - launch(newBin, ['--updating', binFile], { sync: true }) // sync necessary to work on mac by double-click + if (doingLocal) + try { renameSync(doingLocal, 'old-' + doingLocal) } + catch(e) { console.warn(e) } + launch(newBin, ['--updating', binFile, '--cwd .'], { sync: true }) // sync necessary to work on Mac by double-click }) console.log("quitting") setTimeout(() => process.exit()) // give time to return (and caller to complete, eg: rest api to reply) @@ -141,9 +175,15 @@ if (argv.updating) { // we were launched with a temporary name, restore original // be sure to test launching both double-clicking and in a terminal if (IS_WINDOWS) // this method on Mac works only once, and without console onProcessExit(() => - launch(dest, ['--updated']) ) // launch+sync here would cause old process to stay open, locking ports - else + launch(dest, ['--updated', '--cwd .']) ) // launch+sync here would cause old process to stay open, locking ports + else { + /* open() is the only consistent way that i could find working on Mac that preserved console input/output over relaunching, + * but I couldn't find a way to pass parameters, at least on Linux. The workaround I'm using is to write them to a temp file, that's read and deleted at restart. + * For the record, on mac you can: write "./hfs arg1 arg2" to /tmp/tmp.sh with 0o700, and then spawn "open -a Terminal /tmp/tmp.sh" + */ + try { writeFileSync(ARGS_FILE, JSON.stringify(['--updated', '--cwd', process.cwd().replaceAll(' ', '\\ ')])) } + catch {} void open(dest) - + } process.exit() } diff --git a/src/upload.ts b/src/upload.ts index 9f6a78b7b..7b7b0b8e0 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -1,13 +1,11 @@ -import { getNodeByName, hasPermission, statusCodeForMissingPerm, VfsNode } from './vfs' +import { getNodeByName, statusCodeForMissingPerm, VfsNode } from './vfs' import Koa from 'koa' -import { HTTP_CONFLICT, HTTP_FOOL, HTTP_PAYLOAD_TOO_LARGE, HTTP_RANGE_NOT_SATISFIABLE, HTTP_SERVER_ERROR, - HTTP_BAD_REQUEST } from './const' +import { + HTTP_CONFLICT, HTTP_FOOL, HTTP_PAYLOAD_TOO_LARGE, HTTP_RANGE_NOT_SATISFIABLE, HTTP_SERVER_ERROR, HTTP_BAD_REQUEST +} from './const' import { basename, dirname, extname, join } from 'path' import fs from 'fs' -import { - Callback, dirTraversal, loadFileAttr, pendingPromise, storeFileAttr, try_, - createStreamLimiter -} from './misc' +import { Callback, dirTraversal, loadFileAttr, pendingPromise, storeFileAttr, try_, createStreamLimiter } from './misc' import { notifyClient } from './frontEndApis' import { defineConfig } from './config' import { getDiskSpaceSync } from './util-os' @@ -89,6 +87,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { if (ctx.query.existing === 'skip' && fs.existsSync(fullPath)) return fail(HTTP_CONFLICT, 'exists') openFiles.add(fullPath) + let overwriteRequestedButForbidden = false try { // if upload creates a folder, then add meta to it too if (!dir.endsWith(':\\') && fs.mkdirSync(dir, { recursive: true })) @@ -150,8 +149,13 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { const sec = deleteUnfinishedUploadsAfter.get() return _.isNumber(sec) && delayedDelete(tempName, sec) } + if (ctx.query.partial) return // this upload is partial, and we are supposed to leave the upload as unfinished, with the temp name let dest = fullPath if (dontOverwriteUploading.get() && !await overwriteAnyway() && fs.existsSync(dest)) { + if (overwriteRequestedButForbidden) { + await rm(tempName) + return fail() + } const ext = extname(dest) const base = dest.slice(0, -ext.length || Infinity) let i = 1 @@ -190,7 +194,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { let lastGot = 0 let lastGotTime = 0 const opTotal = reqSize + resume - Object.assign(ctx.state, { op: 'upload', opTotal, opOffset: resume / opTotal, opProgress: 0 }) + Object.assign(ctx.state, { opTotal, opOffset: resume / opTotal, opProgress: 0 }) const conn = updateConnectionForCtx(ctx) if (!conn) return const h = setInterval(() => { @@ -210,10 +214,11 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { } async function overwriteAnyway() { - if (ctx.query.overwrite === undefined // legacy pre-0.52 - && ctx.query.existing !== 'overwrite') return + if (ctx.query.existing !== 'overwrite') return false const n = await getNodeByName(path, base) - return n && hasPermission(n, 'can_delete', ctx) + if (n && !statusCodeForMissingPerm(n, 'can_delete', ctx)) return true + overwriteRequestedButForbidden = true + return false } function delayedDelete(path: string, secs: number, cb?: Callback) { diff --git a/src/util-files.ts b/src/util-files.ts index 28556e472..f61ce2138 100644 --- a/src/util-files.ts +++ b/src/util-files.ts @@ -6,8 +6,8 @@ import { createWriteStream, mkdirSync, watch } from 'fs' import { basename, dirname } from 'path' import glob from 'fast-glob' import { IS_WINDOWS } from './const' -import { runCmd } from './util-os' import { once, Readable } from 'stream' +import { createDirStream, DirStreamEntry } from './dirStream' // @ts-ignore import unzipper from 'unzip-stream' @@ -71,36 +71,13 @@ export function adjustStaticPathForGlob(path: string) { return glob.escapePath(path.replace(/\\/g, '/')) } -export async function* dirStream(path: string, { depth=0, onlyFiles=false, onlyFolders = false }={}) { +export async function* dirStream(path: string, { depth=0, onlyFiles=false, onlyFolders = false, hidden=true }={}) { if (!await isDirectory(path)) throw Error('ENOTDIR') - const dirStream = glob.stream(depth ? '**/*' : '*', { - cwd: path, - dot: true, - deep: depth + 1, - onlyFiles, - onlyDirectories: onlyFolders, - suppressErrors: true, - objectMode: true, - unique: false, - }) - const skip = await getItemsToSkip(path) - for await (const entry of dirStream) { - let { path, dirent } = entry as any - const isDir = dirent.isDirectory() - if (!isDir && !dirent.isFile()) continue - path = String(path) - if (!skip?.includes(path)) - yield [path, isDir] as const - } - - async function getItemsToSkip(path: string) { - if (!IS_WINDOWS) return - const winPath = path.replace(/\//g, '\\') - const out = await runCmd('dir', ['/ah', '/b', depth ? '/s' : '/c', winPath]) // cannot pass '', so we pass /c as a noop parameter - .catch(()=>'') // error in case of no matching file - return out.split('\n').map(x => - x.slice(!depth ? 0 : winPath.length + 1).trim().replace(/\\/g, '/')); + for await (const entry of createDirStream(path, { depth, hidden })) { + const dirent = entry as DirStreamEntry + if (dirent.isDirectory() ? onlyFiles : (onlyFolders || !dirent.isFile())) continue + yield dirent } } diff --git a/src/util-http.ts b/src/util-http.ts index 28971dfe9..b6f8f48c7 100644 --- a/src/util-http.ts +++ b/src/util-http.ts @@ -5,6 +5,7 @@ import http, { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' import _ from 'lodash' import { text as stream2string, buffer } from 'node:stream/consumers' +import { stringAfter } from './cross' export { stream2string } export async function httpString(url: string, options?: XRequestOptions): Promise { @@ -54,8 +55,16 @@ export function httpStream(url: string, { body, jar, noRedirect, httpThrow, ...o } if (!res.statusCode || (httpThrow ?? true) && res.statusCode >= 400) return reject(new Error(String(res.statusCode), { cause: res })) - if (res.headers.location && !noRedirect) - return resolve(httpStream(res.headers.location, options)) + let r = res.headers.location + if (r && !noRedirect) { + if (r.startsWith('/')) // relative + r = /(.+)\b\/(\b|$)/.exec(url)?.[1] + r + const stack = ((options as any)._stack ||= []) + if (stack.length > 20 || stack.includes(r)) + return reject(new Error('endless http redirection')) + stack.push(r) + return resolve(httpStream(r, options)) + } resolve(res) }).on('error', e => { reject((req as any).res || e) diff --git a/src/util-os.ts b/src/util-os.ts index 42c1e730e..77fc5d344 100644 --- a/src/util-os.ts +++ b/src/util-os.ts @@ -80,7 +80,11 @@ async function getWindowsServicePids() { return _.uniq(res.split('\n').slice(1).map(x => Number(x.trim()))) } -export const RUNNING_AS_SERVICE = IS_WINDOWS && getWindowsServicePids().then(x => x.includes(pid), e => { +export const RUNNING_AS_SERVICE = IS_WINDOWS && getWindowsServicePids().then(x => { + const ret = x.includes(pid) + if (ret) console.log("running as service") + return ret +}, e => { console.log("couldn't determine if we are running as a service") console.debug(e) }) diff --git a/src/vfs.ts b/src/vfs.ts index ba3ea8af9..3a6d187ee 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -3,23 +3,8 @@ import fs from 'fs/promises' import { basename, dirname, join, resolve } from 'path' import { - dirStream, - getOrSet, - isDirectory, - makeMatcher, - setHidden, - onlyTruthy, - isValidFileName, - throw_, - VfsPerms, - Who, - isWhoObject, - WHO_ANY_ACCOUNT, - defaultPerms, - PERM_KEYS, - removeStarting, - HTTP_SERVER_ERROR, - try_ + dirStream, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who, + isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting, HTTP_SERVER_ERROR, try_ } from './misc' import Koa from 'koa' import _ from 'lodash' @@ -28,6 +13,10 @@ import { HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, IS_MAC, IS_WINDOWS, MIME_AUTO } from import events from './events' import { expandUsername } from './perm' import { getCurrentUsername } from './auth' +import { Stats } from 'node:fs' +import fswin from 'fswin' + +const showHiddenFiles = defineConfig('show_hidden_files', false) type Masks = Record @@ -42,12 +31,15 @@ export interface VfsNodeStored extends VfsPerms { rename?: Record masks?: Masks // express fields for descendants that are not in the tree accept?: string + comment?: string + icon?: string } export interface VfsNode extends VfsNodeStored { // include fields that are only filled at run-time isTemp?: true // this node doesn't belong to the tree and was created by necessity original?: VfsNode // if this is a temp node but reflecting an existing node parent?: VfsNode // available when original is available isFolder?: boolean + stats?: Stats } export function permsFromParent(parent: VfsNode, child: VfsNode) { @@ -117,13 +109,13 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode= const ret = await getNodeByName(name, parent) if (!ret) return - if (parent.default) // web folders have this default setting to ensure a standard behavior - inheritFromParent({ mime: { '*': MIME_AUTO } }, ret) if (rest || ret?.original) return urlToNode(rest, ctx, ret, getRest) if (ret.source) try { - const st = await fs.stat(ret.source) // check existence + if (!showHiddenFiles.get() && await isHiddenFile(ret.source)) + throw 'hiddenFile' + const st = ret.stats || await fs.stat(ret.source) // check existence ret.isFolder = st.isDirectory() } catch { @@ -136,6 +128,11 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode= return ret } +async function isHiddenFile(path: string) { + return IS_WINDOWS ? new Promise(res => fswin.getAttributes(path, x => res(x?.IS_HIDDEN))) + : path[0] === '.' +} + export async function getNodeByName(name: string, parent: VfsNode) { // does the tree node have a child that goes by this name, otherwise attempt disk const child = parent.children?.find(isSameFilenameAs(name)) || childFromDisk() @@ -194,13 +191,13 @@ export function getNodeName(node: VfsNode) { export async function nodeIsDirectory(node: VfsNode) { if (node.isFolder !== undefined) return node.isFolder - const isFolder = Boolean(node.children?.length || !nodeIsLink(node) && (!node.source || await isDirectory(node.source))) + const isFolder = Boolean(node.children?.length || !nodeIsLink(node) && (node.stats?.isDirectory() ?? (!node.source || await isDirectory(node.source)))) setHidden(node, { isFolder }) // don't make it to the storage (a node.isTemp doesn't need it to be hidden) return isFolder } export async function hasDefaultFile(node: VfsNode, ctx: Koa.Context) { - return node.default && await urlToNode(node.default, ctx, node) || undefined + return node.default && await nodeIsDirectory(node) && await urlToNode(node.default, ctx, node) || undefined } export function nodeIsLink(node: VfsNode) { @@ -220,7 +217,7 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerms, ct return ret function getCode() { - if (!node.source && perm === 'can_upload') // Upload possible only if we know where to store. First check node.source because is supposedly faster. + if (!node.source && (perm === 'can_upload' || perm === 'can_delete')) // Upload possible only if we know where to store. First check node.source because is supposedly faster. return HTTP_FORBIDDEN // calculate value of permission resolving references to other permissions, avoiding infinite loop let who: Who | undefined @@ -290,13 +287,15 @@ export async function* walkNode(parent: VfsNode, { && !masksCouldGivePermission(parent.masks, requiredPerm)) return + let n = 0 try { let lastDir = prefixPath.slice(0, -1) || '.' parentsCache.set(lastDir, parent) - // it's important to keep using dirStream in deep-mode, as it is manyfold faster (it parallelizes) - for await (const [path, isFolder] of dirStream(source, { depth, onlyFolders })) { + for await (const entry of dirStream(source, { depth, onlyFolders, hidden: showHiddenFiles.get() })) { if (ctx?.req.aborted) return + const {path} = entry + const isFolder = entry.isDirectory() const name = prefixPath + (parent.rename?.[path] || path) if (took?.has(normalizeFilename(name))) continue if (depth) { @@ -315,11 +314,14 @@ export async function* walkNode(parent: VfsNode, { parentsCache.set(name, item) if (await canSee(item)) yield item + entry.closingBranch?.then(p => + parentsCache.delete(p || '.')) } } catch(e) { - console.debug('glob', source, e) // ENOTDIR, or lacking permissions + console.debug('walkNode', source, e) // ENOTDIR, or lacking permissions } + parentsCache.clear() // hoping for faster GC // item will be changed, so be sure to pass a temp node async function canSee(item: VfsNode) { diff --git a/src/watchLoad.ts b/src/watchLoad.ts index 0b50d833f..4afa497bd 100644 --- a/src/watchLoad.ts +++ b/src/watchLoad.ts @@ -13,7 +13,7 @@ interface WatchLoadReturn { unwatch:WatchLoadCanceller, save: WriteFile, getText export function watchLoad(path:string, parser:(data:any)=>void|Promise, { failedOnFirstAttempt, immediateFirst }:Options={}): WatchLoadReturn { let doing = false let watcher: FSWatcher | undefined - const debounced = debounceAsync(load, 500, { maxWait: 1000 }) + const debounced = debounceAsync(load, { wait: 500, maxWait: 1000 }) let retry: NodeJS.Timeout let last: string | undefined install(true) diff --git a/src/zip.ts b/src/zip.ts index 992f34638..40eac11cf 100644 --- a/src/zip.ts +++ b/src/zip.ts @@ -8,18 +8,18 @@ import { createReadStream } from 'fs' import fs from 'fs/promises' import { defineConfig } from './config' import { basename, dirname } from 'path' -import { applyRange, monitorAsDownload } from './serveFile' +import { applyRange, forceDownload, monitorAsDownload } from './serveFile' import { HTTP_OK } from './const' // expects 'node' to have had permissions checked by caller export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) { - if (statusCodeForMissingPerm(node, 'can_archive', ctx)) return + const list = wantArray(ctx.query.list)[0]?.split('*') // we are using * as separator because it cannot be used in a file name and doesn't need url encoding + if (!list && statusCodeForMissingPerm(node, 'can_archive', ctx)) return ctx.status = HTTP_OK ctx.mime = 'zip' // ctx.query.list is undefined | string | string[] - const list = wantArray(ctx.query.list)[0]?.split('*') // we are using * as separator because it cannot be used in a file name and doesn't need url encoding const name = list?.length === 1 ? safeDecodeURIComponent(basename(list[0]!)) : getNodeName(node) - ctx.attachment((isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip') + forceDownload(ctx, (isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip') const filter = pattern2filter(String(ctx.query.search||'')) const walker = !list ? walkNode(node, { ctx, requiredPerm: 'can_archive' }) : (async function*(): AsyncIterableIterator { @@ -28,7 +28,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) { if (!subNode) continue if (await nodeIsDirectory(subNode)) { // a directory needs to walked - if (hasPermission(subNode, 'can_list',ctx)) { + if (hasPermission(subNode, 'can_list', ctx) && hasPermission(subNode, 'can_archive', ctx)) { yield subNode // it could be empty yield* walkNode(subNode, { ctx, prefixPath: decodeURI(uri) + '/', requiredPerm: 'can_archive' }) } @@ -50,7 +50,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) { if (el.isFolder) return { path: name + '/' } if (!source) return - const st = await fs.stat(source) + const st = el.stats || await fs.stat(source) if (!st || !st.isFile()) return return { diff --git a/tests/config.yaml b/tests/config.yaml index 5047b1fe4..564aa669d 100644 --- a/tests/config.yaml +++ b/tests/config.yaml @@ -138,7 +138,7 @@ accounts: disabled: true admins: admin: true -version: 0.53.2 +version: 0.54.0-rc12 max_downloads_per_account: 2 max_downloads: 1 roots: diff --git a/todo.md b/todo.md deleted file mode 100644 index 27947d37e..000000000 --- a/todo.md +++ /dev/null @@ -1,30 +0,0 @@ -# To do -- admin: warn in case of items with same name -- frontend search supporting masks -- plugin: list of IPs seen -- admin/fs: check if source exists when set -- plugins: after installing, switch to installed (and perhaps highlight new one) -- plugins' log, accessible in admin -- frontend: hide closer button on login dialog accessing a protected resource, as it's no use -- generation of links to give access to a file without password -- offer ddns registration/update -- admin/fs: sort items -- admin/config: hide advanced settings -- admin/fs: support insert/delete key -- admin/monitor: show some info on what folder is browsing -- admin/fs: navigate file picker with keyboard -- move files #203 -- admin: in a group, show linked accounts -- command line help --help -- plugin download-counter: expose results on admin -- plugin to show country by ip in admin/monitor -- log filter plugin -- admin: improve masks editor -- admin: warn before changing page if we have unsaved changes -- plugin: upload quota per-account (possibly inheriting), and a default -- config: max connections/downloads (total/per-ip) -- webdav? -- log: ip2name -- search operators (size, type?) -- ability to install as service in Windows - - an application to control the service as tray icon