From 09e67daa9c06ebec3c2528b38dbe52831eb3e6b0 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 20 Jun 2024 23:20:52 +0200 Subject: [PATCH 001/234] allow zipping of files when it's forbidden on folders --- src/api.get_file_list.ts | 4 ++-- src/zip.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api.get_file_list.ts b/src/api.get_file_list.ts index 395bd124e..74449731c 100644 --- a/src/api.get_file_list.ts +++ b/src/api.get_file_list.ts @@ -38,9 +38,9 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, 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: 'x' }, node) // can we delete children 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) diff --git a/src/zip.ts b/src/zip.ts index 992f34638..f9b0c2ccc 100644 --- a/src/zip.ts +++ b/src/zip.ts @@ -13,11 +13,11 @@ 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') const filter = pattern2filter(String(ctx.query.search||'')) @@ -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' }) } From 525aa683e1170b027aeeb17faf10c17ea170ba83 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 20 Jun 2024 10:03:05 +0200 Subject: [PATCH 002/234] auto_check_update --- admin/src/HomePage.ts | 63 ++++++++++++++++++------------------ mui-grid-form/misc-fields.ts | 8 +++-- shared/api.ts | 4 +-- src/adminApis.ts | 17 +++++----- src/persistence.ts | 3 +- src/update.ts | 38 +++++++++++++++++++--- 6 files changed, 83 insertions(+), 50 deletions(-) diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index 7fe8568c8..63e480cb1 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -15,24 +15,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'] }) @@ -92,7 +83,16 @@ 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(', ')), + h(ConfigForm, { + gridProps: { sx: { columns: '13em 2', gap: 0, display: 'block', mt: 0, '&>div.MuiGrid-item': { pt: 0 }, '.MuiCheckbox-root': { pl: '2px' } } }, + saveOnChange: true, + form: { fields: [ + { k: 'auto_check_update', comp: CheckboxField, label: "Check updates daily" }, + { k: 'update_to_beta', comp: CheckboxField, label: "Include beta versions" }, + ] } + }), status.updatePossible === 'local' ? h(Btn, { icon: UpdateIcon, onClick: () => update() @@ -103,7 +103,7 @@ export default function HomePage() { icon: UpdateIcon, onClick() { setCheckPlugins(true) - return apiCall('check_update').then(x => setUpdates(x.options), alertDialog) + return apiCall('check_update').then(x => setUpdates(x.options), alertDialog) }, async onContextMenu(ev) { ev.preventDefault() @@ -115,33 +115,32 @@ export default function HomePage() { }, 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)) - )), - )), - )), + updates.map((x: any) => h(Update, { info: x })) )), h(SwitchThemeBtn, { variant: 'outlined' }), ) } +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, { onText: s => replaceStringToReact(s, /(?<=^|\W)#(\d+)\b|(https:.*\S+)/g, m => // link issues and urls diff --git a/mui-grid-form/misc-fields.ts b/mui-grid-form/misc-fields.ts index 2a456e566..09ee2e27a 100644 --- a/mui-grid-form/misc-fields.ts +++ b/mui-grid-form/misc-fields.ts @@ -52,14 +52,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) { @@ -72,6 +72,10 @@ export function BoolField({ label='', value, onChange, setApi, helperText, error ) } +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/shared/api.ts b/shared/api.ts index 7a0fc65b2..e193fd4f3 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 ? R : T> }, err => { stop?.() if (err?.message?.includes('fetch')) { @@ -86,7 +86,7 @@ export function useApi(cmd: string | Falsy, params?: object, options: Api const [forcer, setForcer] = useStateMounted(0) const loadingRef = useRef>() const reloadingRef = useRef() - const dataRef = useRef() + const dataRef = useRef() useEffect(() => { loadingRef.current?.abort() setData(undefined) diff --git a/src/adminApis.ts b/src/adminApis.ts index 5fb3e3173..e02cb5323 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,7 +20,7 @@ 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' @@ -29,7 +29,7 @@ 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' @@ -37,7 +37,7 @@ import { roots } from './roots' import { SendListReadable } from './SendList' import { get_dynamic_dns_error } from './ddns' -export const adminApis: ApiHandlers = { +export const adminApis = { ...vfsApis, ...accountsApis, @@ -119,6 +119,7 @@ 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 proxyDetected: getProxyDetected(), frpDetected: localhostAdmin.get() && !getProxyDetected() && getConnections().every(isLocalHost) @@ -135,10 +136,10 @@ export const adminApis: ApiHandlers = { return files }, -} +} 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 +148,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/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/update.ts b/src/update.ts index 8588fe442..90bff69fc 100644 --- a/src/update.ts +++ b/src/update.ts @@ -4,7 +4,7 @@ import { getRepoInfo } from './github' import { 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 { DAY, MINUTE, exists, debounceAsync, httpStream, unzip, prefix, xlate } from './misc' import { createReadStream, renameSync, unlinkSync } from 'fs' import { pluginsWatcher } from './plugins' import { chmod, stat } from 'fs/promises' @@ -13,16 +13,42 @@ 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") + const u = (await getUpdates(true))[0] + if (u) console.log("new version available", u.name) + autoCheckUpdateResult.set(u) + lastCheckUpdate.set(Date.now()) +}), MINUTE / 30) + +export type Release = { // not using interface, as it will not work with kvstorage.Jsonable prerelease: boolean, tag_name: string, name: string, - assets: any[], + body: string, + assets: { name: string, browser_download_url: string }[], isNewer: boolean // introduced by us } +const ReleaseKeys = ['prerelease', 'tag_name', 'name', 'body', 'assets', 'isNewer'] satisfies (keyof Release)[] +const ReleaseAssetKeys = ['name', 'browser_download_url'] satisfies (keyof Release['assets'][0])[] export async function getUpdates(strict=false) { const stable: Release = await getRepoInfo(HFS_REPO + '/releases/latest') @@ -31,7 +57,9 @@ export async function getUpdates(strict=false) { stable.isNewer = currentVersion.olderThan(stable.tag_name) if (stable.isNewer || RUNNING_BETA) ret.push(stable) - return ret.filter(x => !strict || x.isNewer) + // prune a bit, as it will be serialized, but it has a lot of unused data + return ret.filter(x => !strict || x.isNewer).map(x => + Object.assign(_.pick(x, ReleaseKeys), { assets: x.assets.map(a => _.pick(a, ReleaseAssetKeys)) })) function ver(x: any) { return versionToScalar(x.name) @@ -117,7 +145,7 @@ 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 + launch(newBin, ['--updating', binFile], { 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) From a2e147d8aaa9482121dd059ddb0e1da41b277431 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 23 Jun 2024 00:07:45 +0200 Subject: [PATCH 003/234] admin/options: support long wrapping block-ip rules --- admin/src/ArrayField.ts | 13 ++++++++----- admin/src/FileField.ts | 3 +-- admin/src/OptionsPage.ts | 4 ++-- mui-grid-form/StringField.ts | 5 ++++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/admin/src/ArrayField.ts b/admin/src/ArrayField.ts index 3e0415f83..e6dd065f8 100644 --- a/admin/src/ArrayField.ts +++ b/admin/src/ArrayField.ts @@ -11,8 +11,8 @@ import { DateTimeField } from './DateTimeField' import _ from 'lodash' import { Center, IconBtn } from './mui' -type ArrayFieldProps = FieldProps & { fields: FieldDescriptor[], height?: number, reorder?: boolean, prepend?: boolean } -export function ArrayField({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, ...rest }: ArrayFieldProps) { +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) { const rows = useMemo(() => (value||[]).map((x,$idx) => setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })), [JSON.stringify(value)]) //eslint-disable-line @@ -27,7 +27,10 @@ export function ArrayField({ label, helperText, fields, value, h(Box, { ...rest }, h(DataGrid, { rows, - sx: { '.MuiDataGrid-virtualScroller': { minHeight: '3em' } }, + ...autoRowHeight && { getRowHeight: () => 'auto' as const }, + sx: { '.MuiDataGrid-virtualScroller': { minHeight: '3em' }, + ...autoRowHeight && { '.MuiDataGrid-cell': { minHeight: '52px !important' } } + }, hideFooterSelectedRowCount: true, hideFooter: true, slots: { @@ -42,7 +45,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, @@ -52,7 +55,7 @@ export function ArrayField({ label, helperText, fields, value, ...def, ...f.$width ? { [f.$width >= 8 ? 'width' : 'flex']: f.$width } : (!def?.width && !def?.flex && { flex: 1 }), ...f.$column, - }) + } }), { field: '', 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/OptionsPage.ts b/admin/src/OptionsPage.ts index 04434b296..670733416 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -139,9 +139,9 @@ export default function OptionsPage() { }, { k: 'allowed_referer', placeholder: "any", label: "Links from other websites", comp: AllowedReferer, }, - { 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: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: true, helperText: h(WildcardsSupported) }, { k: 'expire', $type: 'dateTime', minDate: new Date(), sm: 6, helperText: "Leave empty for no expiration" }, { k: 'disabled', 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 Date: Sun, 23 Jun 2024 00:10:14 +0200 Subject: [PATCH 004/234] admin/logs: block-ip will append to existing "from log" rule (if any), and same does monitoring --- admin/src/OptionsPage.ts | 1 + admin/src/useBlockIp.ts | 10 ++++------ src/adminApis.ts | 15 +++++++++++++++ src/perm.ts | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index 670733416..eab90f1b8 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -147,6 +147,7 @@ export default function OptionsPage() { 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, diff --git a/admin/src/useBlockIp.ts b/admin/src/useBlockIp.ts index e478739ee..3c66cd7c0 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, comment, merge: { comment } }) + .then(reload).then(() => toast("Blocked", 'success')) + }, }), } } diff --git a/src/adminApis.ts b/src/adminApis.ts index e02cb5323..1d0002fd1 100644 --- a/src/adminApis.ts +++ b/src/adminApis.ts @@ -36,6 +36,7 @@ import { ip2country } from './geo' import { roots } from './roots' import { SendListReadable } from './SendList' import { get_dynamic_dns_error } from './ddns' +import { block, BlockingRule } from './block' export const adminApis = { @@ -136,6 +137,20 @@ export const adminApis = { return files }, + async add_block({ merge, ip, expire, comment }: BlockingRule & { merge?: Partial }) { + apiAssertTypes({ + string: { ip }, + string_undefined: { comment, expire }, + object_undefined: { merge }, + }) + block.set(was => { + const found = merge && _.findIndex(was, merge) + return found ? was.map((x, i) => i === found ? { ...x, ip: `${x.ip}|${ip}` } : x) + : [...was, { ip, expire, comment }] + }) + return {} + } + } satisfies ApiHandlers for (const [k, was] of typedEntries(adminApis)) diff --git a/src/perm.ts b/src/perm.ts index 6fd0228f0..9481dbc5d 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()) From 823501739ed99a7e1302b673876116cd7f087d48 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Fri, 21 Jun 2024 12:17:15 +0200 Subject: [PATCH 005/234] plugins: frontend event 'showPlay' --- dev-plugins.md | 5 +++++ frontend/src/show.ts | 20 ++++++++++++++------ shared/api.ts | 6 +++--- src/const.ts | 4 ++-- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index d9135a796..58933817f 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -354,6 +354,9 @@ This is a list of available frontend-events, with respective object parameter an - you receive an entry of the list, and optionally produce React Component for visualization. - parameter `{ entry: Entry }` (refer above for Entry object) - output `ReactComponent` +- `showPlay` + - emitted on each file played inside file-show. Use setCover if you want to customize the background picture. + - parameter `{ entry: Entry, setCover(uri: string), meta: { title, album, artist, year } }` - `menuZip` - parameter `{ def: ReactNode }` - output `Html` @@ -610,6 +613,8 @@ If you want to override a text regardless of the language, use the special langu ## API version history +- 8.9 (v0.54.0) + - frontend event: showPlay - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/src/show.ts b/frontend/src/show.ts index 0f76c2d70..dab1f2c80 100644 --- a/frontend/src/show.ts +++ b/frontend/src/show.ts @@ -140,7 +140,7 @@ 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('div', { className: 'cover ' + (cover ? '' : 'none'), style: { backgroundImage: `url("${cover}")`, } }), h(getShowType(cur) || Fragment, { src: cur.uri, className: 'showing', @@ -152,19 +152,27 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { 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' }, diff --git a/shared/api.ts b/shared/api.ts index e193fd4f3..16be3062c 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 Awaited infer R ? R : T> + return result as Awaited infer R ? Awaited : T> }, err => { stop?.() if (err?.message?.includes('fetch')) { @@ -81,7 +81,7 @@ 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>() @@ -95,7 +95,7 @@ export function useApi(cmd: string | Falsy, params?: object, options: Api 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), err => { if (aborted) return setError(err) setData(dataRef.current = undefined) diff --git a/src/const.ts b/src/const.ts index d6e149ae3..0c4cae9b6 100644 --- a/src/const.ts +++ b/src/const.ts @@ -7,7 +7,7 @@ import { mkdirSync } from 'fs' import { basename, dirname, join } from 'path' export * from './cross-const' -export const API_VERSION = 8.891 +export const API_VERSION = 8.9 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' @@ -51,5 +51,5 @@ console.log('cwd', process.cwd()) if (APP_PATH !== process.cwd()) console.log('app', APP_PATH) console.log('node', process.version) -console.log('platform', process.platform, process.arch) +console.log('platform', process.platform) console.log('pid', process.pid) From 24fcee813b64a613985ba698d9af1fd0de8dd33f Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 09:40:57 +0200 Subject: [PATCH 006/234] show: smaller font for folder-path (if any) --- frontend/src/show.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/show.ts b/frontend/src/show.ts index dab1f2c80..009db90d8 100644 --- a/frontend/src/show.ts +++ b/frontend/src/show.ts @@ -90,6 +90,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 +106,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), @@ -150,7 +151,6 @@ 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|front|albumart.*)\.jpe?g$/i)) setCover(pathEncode(_.maxBy(covers, 's')?.n || '')) From 693b93bd43efab1b582b4ab5d9ead4b2384e5bfd Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 09:53:43 +0200 Subject: [PATCH 007/234] plugins: api.addBlock --- admin/src/useBlockIp.ts | 2 +- dev-plugins.md | 10 +++++++++- src/adminApis.ts | 8 ++------ src/block.ts | 9 +++++++++ src/plugins.ts | 2 ++ 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/admin/src/useBlockIp.ts b/admin/src/useBlockIp.ts index 3c66cd7c0..eade02404 100644 --- a/admin/src/useBlockIp.ts +++ b/admin/src/useBlockIp.ts @@ -16,7 +16,7 @@ export function useBlockIp() { ...isBlocked(ip) && { disabled: true, title: "Blocked" }, ...options, onClick() { - return apiCall('add_block', { ip, comment, merge: { comment } }) + return apiCall('add_block', { ip, merge: { comment } }) .then(reload).then(() => toast("Blocked", 'success')) }, }), diff --git a/dev-plugins.md b/dev-plugins.md index 58933817f..44eb46012 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -171,6 +171,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. @@ -614,7 +621,8 @@ If you want to override a text regardless of the language, use the special langu ## API version history - 8.9 (v0.54.0) - - frontend event: showPlay + - frontend event: showPlay + - api.addBlock - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/src/adminApis.ts b/src/adminApis.ts index 1d0002fd1..ae29028b0 100644 --- a/src/adminApis.ts +++ b/src/adminApis.ts @@ -36,7 +36,7 @@ import { ip2country } from './geo' import { roots } from './roots' import { SendListReadable } from './SendList' import { get_dynamic_dns_error } from './ddns' -import { block, BlockingRule } from './block' +import { addBlock, BlockingRule } from './block' export const adminApis = { @@ -143,11 +143,7 @@ export const adminApis = { string_undefined: { comment, expire }, object_undefined: { merge }, }) - block.set(was => { - const found = merge && _.findIndex(was, merge) - return found ? was.map((x, i) => i === found ? { ...x, ip: `${x.ip}|${ip}` } : x) - : [...was, { ip, expire, comment }] - }) + addBlock({ ip, expire, comment }, merge) return {} } diff --git a/src/block.ts b/src/block.ts index 90bfddb04..7aa01158c 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 found = merge && _.findIndex(was, merge) + return found ? was.map((x, i) => i === found ? { ...x, ...rule, ip: `${x.ip}|${rule.ip}` } : x) + : [...was, { ...merge, ...rule }] + }) +} \ No newline at end of file diff --git a/src/plugins.ts b/src/plugins.ts index 2a1da1305..cf9f45055 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -25,6 +25,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 +114,7 @@ async function initPlugin(pl: any, morePassedToInit?: T) { getHfsConfig: getConfig, customApiCall, notifyClient, + addBlock, ...morePassedToInit })) } From 3d1045223e96095d5c1d197aa2401d5f7316668c Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 15:44:56 +0200 Subject: [PATCH 008/234] ux: clearer description --- admin/src/MonitorPage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/MonitorPage.ts b/admin/src/MonitorPage.ts index 4fe60eeb1..fd9e40fdd 100644 --- a/admin/src/MonitorPage.ts +++ b/admin/src/MonitorPage.ts @@ -116,7 +116,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, { From 1fce87e0d73280b023f94dd15c69f5fa3ee03e3f Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 15:46:04 +0200 Subject: [PATCH 009/234] better code --- src/api.monitor.ts | 4 ++-- src/log.ts | 2 +- src/serveFile.ts | 3 +-- src/upload.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) 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/log.ts b/src/log.ts index 6e37c4f0f..24c71baaf 100644 --- a/src/log.ts +++ b/src/log.ts @@ -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()) diff --git a/src/serveFile.ts b/src/serveFile.ts index 2fe90b142..728d7ef25 100644 --- a/src/serveFile.ts +++ b/src/serveFile.ts @@ -89,7 +89,7 @@ export async function serveFile(ctx: Koa.Context, source:string, mime?:string, c 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 +104,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), diff --git a/src/upload.ts b/src/upload.ts index 033d238d7..cb3e79406 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -183,7 +183,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(() => { From 0e2291abec1d091771abcfcfb9d8750fc49b48ad Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 15:48:33 +0200 Subject: [PATCH 010/234] admin/monitoring: ask confirmation to reset stats --- admin/src/MonitorPage.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/admin/src/MonitorPage.ts b/admin/src/MonitorPage.ts index fd9e40fdd..c6d451d65 100644 --- a/admin/src/MonitorPage.ts +++ b/admin/src/MonitorPage.ts @@ -16,7 +16,7 @@ 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' export default function MonitorPage() { return h(Fragment, {}, @@ -43,8 +43,9 @@ 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' }), From af545e25fb4aad2ec80a4c17b1fee5432b956ead Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 22:13:07 +0200 Subject: [PATCH 011/234] better code --- src/acme.ts | 4 ++-- src/debounceAsync.ts | 8 ++++---- src/github.ts | 2 +- src/nat.ts | 4 ++-- src/plugins.ts | 2 +- src/serveGuiFiles.ts | 2 +- src/watchLoad.ts | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/acme.ts b/src/acme.ts index 638a17448..2184cfaf9 100644 --- a/src/acme.ts +++ b/src/acme.ts @@ -106,7 +106,7 @@ 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) +}) const acmeDomain = defineConfig('acme_domain', '') const acmeEmail = defineConfig('acme_email', '') @@ -126,5 +126,5 @@ const renewCert = debounceAsync(async () => { 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 }) +}, { retain: DAY, retainFailure: HOUR }) diff --git a/src/debounceAsync.ts b/src/debounceAsync.ts index b32d3a202..cb69e8280 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? @@ -21,7 +21,7 @@ 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/github.ts b/src/github.ts index fab7058aa..569bf297c 100644 --- a/src/github.ts +++ b/src/github.ts @@ -219,4 +219,4 @@ 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 + { retain: DAY, retainFailure: 60_000 }) \ No newline at end of file diff --git a/src/nat.ts b/src/nat.ts index 8385331ba..e1c4c8522 100644 --- a/src/nat.ts +++ b/src/nat.ts @@ -28,7 +28,7 @@ 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) }, e => console.debug('upnp failed:', e.message || String(e))) @@ -60,7 +60,7 @@ 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 res = await haveTimeout(10_000, upnpClient.getGateway()).catch(() => null) diff --git a/src/plugins.ts b/src/plugins.ts index cf9f45055..9fce9a245 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -308,7 +308,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/serveGuiFiles.ts b/src/serveGuiFiles.ts index 0681e616e..c98c154b0 100644 --- a/src/serveGuiFiles.ts +++ b/src/serveGuiFiles.ts @@ -65,7 +65,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) 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) From 04f02a724b739bb5aa5488adb2b3672525de6573 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 25 Jun 2024 22:51:07 +0200 Subject: [PATCH 012/234] alerts --- admin/src/HomePage.ts | 9 +++++++-- admin/src/index.scss | 4 ---- frontend/src/index.scss | 4 ---- shared/_main.scss | 5 +++++ shared/md.ts | 4 ++-- src/adminApis.ts | 6 ++++++ src/cross.ts | 7 +++++++ src/github.ts | 29 +++++++++++++++++++++++++---- src/update.ts | 3 ++- 9 files changed, 54 insertions(+), 17 deletions(-) diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index 63e480cb1..605d54211 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -51,6 +51,7 @@ 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) @@ -102,7 +103,8 @@ export default function HomePage() { variant: 'outlined', icon: UpdateIcon, onClick() { - setCheckPlugins(true) + 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) { @@ -143,6 +145,7 @@ function Update({ info, title, bodyCollapsed }: { title?: ReactNode, info: Relea 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] ) @@ -182,7 +185,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: '1s blink' } : undefined }, + ...content) + ) } function fsLink(text=`File System page`) { 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/frontend/src/index.scss b/frontend/src/index.scss index d4e120792..6f760e32a 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -213,10 +213,6 @@ kbd { .ani-working { animation:1s blink infinite } -@keyframes blink { - 0% {opacity: 1} - 50% {opacity: 0.2} -} @keyframes spin { 100% { transform: rotate(360deg); } } diff --git a/shared/_main.scss b/shared/_main.scss index ab688415b..4fc272d86 100644 --- a/shared/_main.scss +++ b/shared/_main.scss @@ -3,3 +3,8 @@ clip-path: rect(1px 1px 1px 1px); clip: rect(1px, 1px, 1px, 1px); // legacy browsers } + +@keyframes blink { + 0% {opacity: 1} + 50% {opacity: 0.2} +} 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/adminApis.ts b/src/adminApis.ts index ae29028b0..faa00d962 100644 --- a/src/adminApis.ts +++ b/src/adminApis.ts @@ -37,6 +37,7 @@ 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' export const adminApis = { @@ -79,6 +80,10 @@ export const adminApis = { 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)) @@ -121,6 +126,7 @@ export const adminApis = { 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(), frpDetected: localhostAdmin.get() && !getProxyDetected() && getConnections().every(isLocalHost) diff --git a/src/cross.ts b/src/cross.ts index fe6fbbb4c..78ba0c6e7 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -467,6 +467,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/github.ts b/src/github.ts index 569bf297c..a86d5e4f6 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,18 +1,21 @@ // 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' @@ -212,11 +215,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 + .then(o => { + o = Object.assign({ ...builtIn }, DEV ? null : 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/update.ts b/src/update.ts index 90bff69fc..e59ee9e87 100644 --- a/src/update.ts +++ b/src/update.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 { getRepoInfo } from './github' +import { getProjectInfo, getRepoInfo } from './github' import { argv, HFS_REPO, IS_BINARY, IS_WINDOWS, RUNNING_BETA } from './const' import { dirname, join } from 'path' import { spawn, spawnSync } from 'child_process' @@ -51,6 +51,7 @@ const ReleaseKeys = ['prerelease', 'tag_name', 'name', 'body', 'assets', 'isNewe 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() From 367097876e2abf22294707f6cb04218c609e6db7 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 27 Jun 2024 12:57:47 +0200 Subject: [PATCH 013/234] plugins: api.misc --- dev-plugins.md | 9 ++++++++- src/plugins.ts | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/dev-plugins.md b/dev-plugins.md index 44eb46012..9c95e685c 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -129,7 +129,9 @@ Currently, these properties are supported: - `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` @@ -216,6 +218,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. @@ -623,6 +629,7 @@ If you want to override a text regardless of the language, use the special langu - 8.9 (v0.54.0) - frontend event: showPlay - api.addBlock + - api.misc - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/src/plugins.ts b/src/plugins.ts index 9fce9a245..a3f1aa312 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' @@ -115,6 +116,7 @@ async function initPlugin(pl: any, morePassedToInit?: T) { customApiCall, notifyClient, addBlock, + misc, ...morePassedToInit })) } From e8af7debebe209ede781abe3a1dbc20f0c700e50 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 29 Jun 2024 15:40:34 +0200 Subject: [PATCH 014/234] admin/plugins/updates: mark downgrades --- admin/src/InstalledPlugins.ts | 1 + src/api.plugins.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/admin/src/InstalledPlugins.ts b/admin/src/InstalledPlugins.ts index a5fb649be..a9782625d 100644 --- a/admin/src/InstalledPlugins.ts +++ b/admin/src/InstalledPlugins.ts @@ -138,6 +138,7 @@ export default function InstalledPlugins({ updates }: { updates?: true }) { 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) : !repo ? value diff --git a/src/api.plugins.ts b/src/api.plugins.ts index c33731270..ae5a717ef 100644 --- a/src/api.plugins.ts +++ b/src/api.plugins.ts @@ -42,6 +42,8 @@ const apis: ApiHandlers = { 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 + if (online.version! < disk.version) + (online as any).downgrade = true list.add(online) } } catch (err: any) { From 856c4c667146f4502cd1c86ec6b6c08323a6a13b Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 4 Jul 2024 11:53:39 +0200 Subject: [PATCH 015/234] close toasts notifications by clicking --- frontend/src/toasts.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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) ) From fa002756a7827486faf3de8b3e35bc204a32de4c Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 4 Jul 2024 16:41:38 +0200 Subject: [PATCH 016/234] better code --- frontend/src/useFetchList.ts | 6 ++---- src/serveGuiFiles.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/useFetchList.ts b/frontend/src/useFetchList.ts index ef5af6e38..460ac1453 100644 --- a/frontend/src/useFetchList.ts +++ b/frontend/src/useFetchList.ts @@ -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() diff --git a/src/serveGuiFiles.ts b/src/serveGuiFiles.ts index c98c154b0..81f5f6117 100644 --- a/src/serveGuiFiles.ts +++ b/src/serveGuiFiles.ts @@ -115,7 +115,7 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) { ...newObj(FRONTEND_OPTIONS, (v, k) => getConfig(k)), lang }, null, 4).replace(/<(\/script)/g, '<"+"$1') /*avoid breaking our script container*/} - document.documentElement.setAttribute('ver', '${VERSION.split('-')[0] /*for style selectors*/}') + document.documentElement.setAttribute('ver', HFS.VERSION.split('-')[0]) ` if (isBody && isOpen) From 95a16d1df9c140027a7852154c1f5a2573f1922e Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 2 Jul 2024 20:40:44 +0200 Subject: [PATCH 017/234] search: wildcards as an option #658 --- frontend/src/dialog.ts | 18 +++++++++++++++ frontend/src/index.scss | 2 ++ frontend/src/menu.ts | 44 ++++++++++++++++++++++++++++-------- frontend/src/state.ts | 2 ++ frontend/src/useFetchList.ts | 3 ++- src/api.get_file_list.ts | 6 +++-- 6 files changed, 63 insertions(+), 12 deletions(-) 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/index.scss b/frontend/src/index.scss index 6f760e32a..da8b23665 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -497,6 +497,8 @@ button .icon + .label { } } +form label+input { margin-top: .2em; } + .miss-perm { margin: 0 0.3em } .popup-menu-button { diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts index 3cd26594c..f383f8c1e 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' @@ -16,10 +18,10 @@ import { apiCall } from '@hfs/shared/api' import { reloadList } from './useFetchList' import { t, useI18N } from './i18n' import { cut } from './clip' -import { Btn, BtnProps, CustomCode } from './components' +import { Btn, BtnProps, CustomCode, iconBtn } 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(() => { @@ -143,11 +145,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/state.ts b/frontend/src/state.ts index 1ed0eab66..2dae0a9f0 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -36,7 +36,9 @@ export const state = proxy({ + searchOptions: { wild: true }, uploadOnExisting: getHFS().dontOverwriteUploading ? 'rename' : 'skip', uri: '', canChangePassword: false, diff --git a/frontend/src/useFetchList.ts b/frontend/src/useFetchList.ts index 460ac1453..972cb93dd 100644 --- a/frontend/src/useFetchList.ts +++ b/frontend/src/useFetchList.ts @@ -45,7 +45,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 diff --git a/src/api.get_file_list.ts b/src/api.get_file_list.ts index 74449731c..d606181f9 100644 --- a/src/api.get_file_list.ts +++ b/src/api.get_file_list.ts @@ -19,7 +19,7 @@ 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 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,7 +34,9 @@ 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) From b9c9b58786888188df6a3963c4b2f7f179aa0e37 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 4 Jul 2024 18:46:16 +0200 Subject: [PATCH 018/234] ux: admin/monitoring: make it clear that "IPs" is for currently connected --- admin/src/MonitorPage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/MonitorPage.ts b/admin/src/MonitorPage.ts index c6d451d65..a013a566a 100644 --- a/admin/src/MonitorPage.ts +++ b/admin/src/MonitorPage.ts @@ -47,9 +47,9 @@ function MoreInfo() { .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, { From 56eec6285f1792ea787436de29bed5a412771c2f Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Fri, 5 Jul 2024 00:43:41 +0200 Subject: [PATCH 019/234] show: update shuffle as the list continue loading --- frontend/src/show.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/show.ts b/frontend/src/show.ts index 009db90d8..59a518c24 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' @@ -11,6 +11,7 @@ import { t, useI18N } from './i18n' import { alertDialog } from './dialog' import _ from 'lodash' import { getId3Tags } from './id3' +import { subscribeKey } from 'valtio/utils' enum ZoomMode { fullWidth, @@ -28,12 +29,18 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { 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 === 'ArrowLeft') return goPrev() From 89611ac172e6cd7e242c6dfb740cca9752f28268 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Fri, 5 Jul 2024 17:41:50 +0200 Subject: [PATCH 020/234] show: transparent audio player when a cover is available --- frontend/src/index.scss | 7 ++++--- frontend/src/show.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/index.scss b/frontend/src/index.scss index da8b23665..8152ec5e3 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -708,12 +708,13 @@ form label+input { margin-top: .2em; } 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/show.ts b/frontend/src/show.ts index 59a518c24..d5bdc3594 100644 --- a/frontend/src/show.ts +++ b/frontend/src/show.ts @@ -148,7 +148,7 @@ 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("${cover}")`, } }), + h('div', { className: 'cover ' + (cover ? '' : 'none'), style: { backgroundImage: cover && `url("${cover}")` } }), h(getShowType(cur) || Fragment, { src: cur.uri, className: 'showing', From 97eca25b209260fc6580daa62242c1e619da18b3 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 7 Jul 2024 01:15:06 +0200 Subject: [PATCH 021/234] ux: admin/fs: for folders, better 'see' description, and also moved after 'list', to hopefully lessen confusion --- admin/src/FileForm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/src/FileForm.ts b/admin/src/FileForm.ts index a8160ae32..b5b9253ca 100644 --- a/admin/src/FileForm.ts +++ b/admin/src/FileForm.ts @@ -153,11 +153,11 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }: }, { k: 'id', comp: LinkField, statusApi, xs: 12 }, 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, From d741b617715674d8af850b468158851d4563f953 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 7 Jul 2024 01:27:25 +0200 Subject: [PATCH 022/234] admin/fs: comment #662 --- admin/src/FileForm.ts | 7 ++++--- src/api.get_file_list.ts | 4 ++-- src/api.vfs.ts | 2 +- src/vfs.ts | 1 + 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/admin/src/FileForm.ts b/admin/src/FileForm.ts index b5b9253ca..d3ae0cea7 100644 --- a/admin/src/FileForm.ts +++ b/admin/src/FileForm.ts @@ -171,17 +171,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/src/api.get_file_list.ts b/src/api.get_file_list.ts index d606181f9..538d133e1 100644 --- a/src/api.get_file_list.ts +++ b/src/api.get_file_list.ts @@ -45,7 +45,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search 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 comment = node.comment ?? await getCommentFor(node.source) const props = { can_archive, can_upload, can_delete, can_overwrite, can_comment, comment, accept: node.accept } ctx.state.browsing = uri.replace(/\/{2,}/g, '/') updateConnectionForCtx(ctx) @@ -126,7 +126,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search 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), web: await hasDefaultFile(node, ctx) ? true : undefined, } } diff --git a/src/api.vfs.ts b/src/api.vfs.ts index b5da899f7..3b87ca5a8 100644 --- a/src/api.vfs.ts +++ b/src/api.vfs.ts @@ -23,7 +23,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', ...PERM_KEYS] const apis: ApiHandlers = { diff --git a/src/vfs.ts b/src/vfs.ts index 12c46d6f3..391c0ef6b 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -42,6 +42,7 @@ export interface VfsNodeStored extends VfsPerms { rename?: Record masks?: Masks // express fields for descendants that are not in the tree accept?: string + comment?: 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 From 997da1d526054b417b2119205771f0a19d31be07 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 7 Jul 2024 01:53:41 +0200 Subject: [PATCH 023/234] admin/options: listen-interface "any IPv4" and "any IPv6" #611 --- admin/src/OptionsPage.ts | 14 +++++++++++++- src/listen.ts | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index eab90f1b8..39f506a26 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -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" }, diff --git a/src/listen.ts b/src/listen.ts index 1b8eb7494..61dad9ffb 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,6 +168,14 @@ 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 => { @@ -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) { @@ -272,8 +280,9 @@ const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|a const isLinkLocal = makeNetMatcher('169.254.0.0/16|FE80::/16') 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)) @@ -289,7 +298,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 From a891c025ed4486b845ce8fc108e9d79e1b2f0fc0 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 7 Jul 2024 10:31:17 +0200 Subject: [PATCH 024/234] removed some legacy code --- admin/src/LogsPage.ts | 2 +- frontend/src/useFetchList.ts | 1 - plugins/download-counter/plugin.js | 15 --------------- src/plugins.ts | 2 +- src/serveFile.ts | 2 -- src/upload.ts | 3 +-- 6 files changed, 3 insertions(+), 22 deletions(-) diff --git a/admin/src/LogsPage.ts b/admin/src/LogsPage.ts index 9fe9b7bc0..4537b1bc9 100644 --- a/admin/src/LogsPage.ts +++ b/admin/src/LogsPage.ts @@ -272,7 +272,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)] diff --git a/frontend/src/useFetchList.ts b/frontend/src/useFetchList.ts index 972cb93dd..88fa67020 100644 --- a/frontend/src/useFetchList.ts +++ b/frontend/src/useFetchList.ts @@ -37,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) { diff --git a/plugins/download-counter/plugin.js b/plugins/download-counter/plugin.js index b0906ec22..391d87af9 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/src/plugins.ts b/src/plugins.ts index a3f1aa312..f69ea4f17 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -141,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}")`) } diff --git a/src/serveFile.ts b/src/serveFile.ts index 728d7ef25..2d8b751de 100644 --- a/src/serveFile.ts +++ b/src/serveFile.ts @@ -77,9 +77,7 @@ export async function serveFile(ctx: Koa.Context, source:string, mime?:string, c if (!stats.isFile()) return ctx.status = HTTP_METHOD_NOT_ALLOWED ctx.set('Last-Modified', stats.mtime.toUTCString()) - ctx.fileSource = // legacy pre-0.51 ctx.state.fileSource = source - ctx.fileStats = // legacy pre-0.51 ctx.state.fileStats = stats ctx.status = HTTP_OK if (ctx.fresh) diff --git a/src/upload.ts b/src/upload.ts index cb3e79406..cea6fc20c 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -203,8 +203,7 @@ 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 const n = await getNodeByName(path, base) return n && hasPermission(n, 'can_delete', ctx) } From 26c6afae2b239443cb7e40f1ab0939bbc51c2803 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 8 Jul 2024 10:57:34 +0200 Subject: [PATCH 025/234] admin/home: animated switch-theme --- admin/src/App.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 }) } From 30b85ed5f14b78fd974159821adfabfcabb73d79 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 8 Jul 2024 17:41:50 +0200 Subject: [PATCH 026/234] fix: icons-emoji fallback wasn't working for audio/video/image files --- frontend/src/icons.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 71554df05..1a1690d49 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -45,6 +45,9 @@ const SYS_ICONS: Record = { // fals repeat: ['🔁', 'reload'], success: ['👍', 'check'], warning: ['⚠️', false], + audio: ['🎧'], + video: ['🎥'], + image: ['📸'], } const documentComplete = document.readyState === 'complete' ? Promise.resolve() From 5199d153359a2df20cde6e65211f83a6fcecf127 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 10 Jul 2024 12:22:11 +0200 Subject: [PATCH 027/234] =?UTF-8?q?consider=20=E2=AC=87=EF=B8=8F=20as=20va?= =?UTF-8?q?lid=20emoji=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/icons.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 1a1690d49..b333640d4 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -68,7 +68,7 @@ export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => { const { iconsReady } = useSnapState() className += ' icon' const nameIsTheIcon = name.length === 1 || - name.match(/^[\uD800-\uDFFF\u2600-\u27BF\u2B50-\u2BFF\u3030-\u303F\u3297\u3299\u00A9\u00AE\u200D\u20E3\uFE0F\u2190-\u21FF\u2300-\u23FF\u2400-\u243F\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF]*$/) + 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 isFontIcon = iconsReady && clazz className += nameIsFile ? ' file-icon' : isFontIcon ? ` fa-${clazz}` : ' emoji-icon' From 31893da99bcd4325464c051b11b523b57d9593d9 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 7 Jul 2024 11:21:04 +0200 Subject: [PATCH 028/234] admin/fs: customizable icon #657 --- admin/index.html | 2 ++ admin/src/FileForm.ts | 26 ++++++++++++++++-- admin/src/OptionsPage.ts | 4 +-- frontend/src/icons.ts | 50 ++--------------------------------- frontend/src/index.scss | 6 ----- frontend/src/state.ts | 9 ++++--- frontend/src/sysIcons.ts | 47 ++++++++++++++++++++++++++++++++ shared/_main.scss | 6 +++++ shared/index.ts | 2 ++ src/api.get_file_list.ts | 9 +++++-- src/api.vfs.ts | 2 +- src/cross.ts | 2 +- src/serveGuiAndSharedFiles.ts | 2 ++ src/vfs.ts | 1 + 14 files changed, 102 insertions(+), 66 deletions(-) create mode 100644 frontend/src/sysIcons.ts diff --git a/admin/index.html b/admin/index.html index 17e80b6b1..d224062c5 100644 --- a/admin/index.html +++ b/admin/index.html @@ -7,6 +7,8 @@ HFS Admin-panel + +
diff --git a/admin/src/FileForm.ts b/admin/src/FileForm.ts index d3ae0cea7..69e92e7fc 100644 --- a/admin/src/FileForm.ts +++ b/admin/src/FileForm.ts @@ -9,9 +9,9 @@ import { apiCall, UseApi } from './api' import { basename, defaultPerms, formatBytes, formatTimestamp, isEqualLax, isWhoObject, newDialog, objSameKeys, onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, HTTP_MESSAGES, xlate, md, Callback, - useRequestRender, splitAt + useRequestRender, splitAt, IMAGE_FILEMASK } from './misc' -import { Btn, IconBtn, LinkBtn, modifiedProps, useBreakpoint, wikiLink } from './mui' +import { Btn, Flex, IconBtn, LinkBtn, modifiedProps, useBreakpoint, wikiLink } from './mui' import { reloadVfs, VfsNode } from './VfsPage' import _ from 'lodash' import FileField from './FileField' @@ -22,6 +22,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 +74,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) { @@ -152,6 +156,24 @@ 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_archive', "Should this be included when user downloads as ZIP", { lg: isDir ? 6 : 12 }), perm('can_list', "Permission to requests the list of a folder. The list will include only things you can see.", { contentText: "subfolders" }), diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index 39f506a26..a4fa380d1 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -7,7 +7,7 @@ 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' + CFG, md, IMAGE_FILEMASK } from './misc' import { iconTooltip, InLink, LinkBtn, modifiedProps, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported } from './mui' import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form'; import { ArrayField } from './ArrayField' @@ -182,7 +182,7 @@ export default function OptionsPage() { { 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" }), 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 8152ec5e3..74e3832ee 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; diff --git a/frontend/src/state.ts b/frontend/src/state.ts index 2dae0a9f0..cacdbed9a 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, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc' +import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list' export const state = proxyvoid, @@ -90,14 +91,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 @@ -146,7 +147,7 @@ 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'))) } } export type DirList = DirEntry[] diff --git a/frontend/src/sysIcons.ts b/frontend/src/sysIcons.ts new file mode 100644 index 000000000..9be6cc012 --- /dev/null +++ b/frontend/src/sysIcons.ts @@ -0,0 +1,47 @@ +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: ['📸'], +} + diff --git a/shared/_main.scss b/shared/_main.scss index 4fc272d86..f7d6cc68c 100644 --- a/shared/_main.scss +++ b/shared/_main.scss @@ -3,6 +3,12 @@ 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} diff --git a/shared/index.ts b/shared/index.ts index 16afb06a6..09f4daf33 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -30,6 +30,8 @@ Object.assign(HFS, { cpuSpeedIndex, }) +export const IMAGE_FILEMASK = '*.jpg|*.jpeg|*.gif|*.svg' + //@ts-ignore if (import.meta.env.PROD) { const was = console.debug diff --git a/src/api.get_file_list.ts b/src/api.get_file_list.ts index 538d133e1..19ef0ca0e 100644 --- a/src/api.get_file_list.ts +++ b/src/api.get_file_list.ts @@ -17,7 +17,7 @@ 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, wild, c, onlyFolders, admin }, ctx) => { const node = await urlToNode(uri, ctx) @@ -46,7 +46,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search const can_comment = can_upload && areCommentsEnabled() const can_overwrite = can_upload && (can_delete || !dontOverwriteUploading.get()) const comment = node.comment ?? await getCommentFor(node.source) - const props = { can_archive, can_upload, can_delete, can_overwrite, can_comment, comment, accept: node.accept } + 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) @@ -103,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) @@ -127,6 +131,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search s: isFolder ? undefined : st?.size, p: (pr + pl + pd + pa) || undefined, comment: node.comment ?? await getCommentFor(source), + icon: getNodeIcon(node), web: await hasDefaultFile(node, ctx) ? true : undefined, } } diff --git a/src/api.vfs.ts b/src/api.vfs.ts index 3b87ca5a8..84eb9ffdc 100644 --- a/src/api.vfs.ts +++ b/src/api.vfs.ts @@ -23,7 +23,7 @@ async function urlToNodeOriginal(uri: string) { return n?.isTemp ? n.original : n } -const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url','target','comment', ...PERM_KEYS] +const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url','target','comment','icon', ...PERM_KEYS] const apis: ApiHandlers = { diff --git a/src/cross.ts b/src/cross.ts index 78ba0c6e7..fbc67c115 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -32,7 +32,7 @@ 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 diff --git a/src/serveGuiAndSharedFiles.ts b/src/serveGuiAndSharedFiles.ts index 35a7ad6b1..a5f97ea73 100644 --- a/src/serveGuiAndSharedFiles.ts +++ b/src/serveGuiAndSharedFiles.ts @@ -98,6 +98,8 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => { 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 (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 diff --git a/src/vfs.ts b/src/vfs.ts index 391c0ef6b..930a29772 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -43,6 +43,7 @@ export interface VfsNodeStored extends VfsPerms { 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 From c24f642dbe24f843160c2299b3d210bc36dc252e Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 10 Jul 2024 13:01:15 +0200 Subject: [PATCH 029/234] dev: don't consider betas if they have -ignore in the name, in case i'm reconsidering a release without deleting it --- src/update.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/update.ts b/src/update.ts index e59ee9e87..f1c3d6c8a 100644 --- a/src/update.ts +++ b/src/update.ts @@ -76,9 +76,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 + if (!x.prerelease || x.name.endsWith('-ignore')) continue const v = ver(x) - if (v <= verStable) // prerelease-s are locally ordered, so as soon as we reach verStable we are done + if (v < verStable) // 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 From 05c5cf0bde5ac59e1a81edfb26d638056bcdc222 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 20 Jul 2024 13:46:23 +0200 Subject: [PATCH 030/234] don't trust upnp on who's the internet gateway #685 --- src/nat.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nat.ts b/src/nat.ts index e1c4c8522..af579b63f 100644 --- a/src/nat.ts +++ b/src/nat.ts @@ -63,13 +63,13 @@ export const getPublicIps = debounceAsync(async () => { }, { 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)) - const gatewayIp = res && try_(() => new URL(res.gateway.description).hostname, () => console.debug('unexpected upnp gw', res.gateway?.description)) - || await findGateway().catch(() => undefined) 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) From 6a1c985cb03918170c15c9db5816a8086ef869ad Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 4 Jul 2024 17:36:41 +0200 Subject: [PATCH 031/234] version 0.54.0-alpha1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e91860a13..86a89081f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hfs", - "version": "0.53.0", + "version": "0.54.0-alpha0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hfs", - "version": "0.53.0", + "version": "0.54.0-alpha0.1", "license": "GPL-3.0", "workspaces": [ "admin", diff --git a/package.json b/package.json index 6eeee1dc1..0a57cafde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hfs", - "version": "0.53.0", + "version": "0.54.0-alpha1", "description": "HTTP File Server", "keywords": [ "file server", From 03d4e69f51983227f4bf8eaf0ac75e2e76b97f4d Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 20 Jul 2024 23:00:37 +0200 Subject: [PATCH 032/234] ux: admin/home: clearer "auto check" option --- admin/src/HomePage.ts | 57 ++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index 605d54211..9782ed20c 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -87,42 +87,37 @@ export default function HomePage() { !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(', ')), h(ConfigForm, { - gridProps: { sx: { columns: '13em 2', gap: 0, display: 'block', mt: 0, '&>div.MuiGrid-item': { pt: 0 }, '.MuiCheckbox-root': { pl: '2px' } } }, + gridProps: { sx: { columns: '13em 3', gap: 0, display: 'block', mt: 0, '&>div.MuiGrid-item': { pt: 0 }, '.MuiCheckbox-root': { pl: '2px' } } }, saveOnChange: true, form: { fields: [ - { k: 'auto_check_update', comp: CheckboxField, label: "Check updates daily" }, + 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" }, ] } }), - 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() { - 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"), - ) - : 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 })) )), + 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' }), ) } From d168467c166a46572d3463586ef5e8b09f4156e4 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 23 Jul 2024 11:37:21 +0200 Subject: [PATCH 033/234] better code: clearer names, to tell server from client srp --- frontend/src/menu.ts | 2 +- src/api.auth.ts | 6 +++--- src/auth.ts | 10 +++++----- src/debounceAsync.ts | 2 +- src/srp.ts | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts index f383f8c1e..7bd08e77d 100644 --- a/frontend/src/menu.ts +++ b/frontend/src/menu.ts @@ -18,7 +18,7 @@ import { apiCall } from '@hfs/shared/api' import { reloadList } from './useFetchList' import { t, useI18N } from './i18n' import { cut } from './clip' -import { Btn, BtnProps, CustomCode, iconBtn } from './components' +import { Btn, BtnProps, CustomCode } from './components' export function MenuPanel() { const { showFilter, remoteSearch, stopSearch, searchManuallyInterrupted, selected, props, searchOptions } = useSnapState() diff --git a/src/api.auth.ts b/src/api.auth.ts index 5a233d678..f0a74f703 100644 --- a/src/api.auth.ts +++ b/src/api.auth.ts @@ -6,7 +6,7 @@ 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' @@ -26,9 +26,9 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => { 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 diff --git a/src/auth.ts b/src/auth.ts index c2048e4dd..25b9af3a1 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -9,15 +9,15 @@ 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 } diff --git a/src/debounceAsync.ts b/src/debounceAsync.ts index cb69e8280..a8da7c9c6 100644 --- a/src/debounceAsync.ts +++ b/src/debounceAsync.ts @@ -15,7 +15,7 @@ export function debounceAsync Date: Thu, 1 Aug 2024 16:50:31 -0500 Subject: [PATCH 034/234] fix: errors when offline with auto-check-update --- src/update.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/update.ts b/src/update.ts index f1c3d6c8a..f09bdfdca 100644 --- a/src/update.ts +++ b/src/update.ts @@ -4,7 +4,7 @@ import { getProjectInfo, getRepoInfo } from './github' import { argv, HFS_REPO, IS_BINARY, IS_WINDOWS, RUNNING_BETA } from './const' import { dirname, join } from 'path' import { spawn, spawnSync } from 'child_process' -import { DAY, MINUTE, exists, debounceAsync, httpStream, unzip, prefix, xlate } from './misc' +import { DAY, exists, debounceAsync, httpStream, unzip, prefix, xlate, HOUR } from './misc' import { createReadStream, renameSync, unlinkSync } from 'fs' import { pluginsWatcher } from './plugins' import { chmod, stat } from 'fs/promises' @@ -33,11 +33,14 @@ setInterval(debounceAsync(async () => { if (!autoCheckUpdate.get()) return if (Date.now() < lastCheckUpdate.get() + AUTO_CHECK_EVERY) return console.log("checking for updates") - const u = (await getUpdates(true))[0] - if (u) console.log("new version available", u.name) - autoCheckUpdateResult.set(u) - lastCheckUpdate.set(Date.now()) -}), MINUTE / 30) + 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, From c38be5541331eb5b094144d75f158d8601646fed Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 1 Aug 2024 16:52:17 -0500 Subject: [PATCH 035/234] fix: admin/internet: failed port-mapping didn't show an error --- src/api.net.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api.net.ts b/src/api.net.ts index 6765725d1..5aabdcdae 100644 --- a/src/api.net.ts +++ b/src/api.net.ts @@ -54,6 +54,9 @@ const apis: ApiHandlers = { catch (e: any) { return new ApiError(HTTP_SERVER_ERROR, 'removeMapping failed: ' + String(e) ) } if (external) // must use the object form of 'public' to work around a bug of the library await upnpClient.createMapping({ private: internal || internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 }) + .catch(res => { + throw new ApiError(res.errorCode, res.errorCode === 718 ? "Port not available" : res.errorDescription) + }) return {} }, From f5b0827c3d8b135478c4e9cb46fd133a55ac33df Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 5 Aug 2024 14:00:41 -0500 Subject: [PATCH 036/234] fix: admin/fs: "system integration" button shouldn't be displayed on non-windows systems #696 --- admin/src/VfsMenuBar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/VfsMenuBar.ts b/admin/src/VfsMenuBar.ts index 4f5cc23bc..95b0fed7c 100644 --- a/admin/src/VfsMenuBar.ts +++ b/admin/src/VfsMenuBar.ts @@ -43,7 +43,7 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined }) const isWindows = platform === 'win32' const { data: integrated, reload } = useApi(isWindows && 'windows_integrated') const sm = useBreakpoint('sm') - return h(Btn, { + return !isWindows ? null : h(Btn, { icon: Microsoft, variant: 'outlined', doneMessage: true, From 706eb5093e13447df796d8bf20b090423870db82 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 5 Aug 2024 16:24:11 -0500 Subject: [PATCH 037/234] command 'exit' alias for 'quit' #695 --- src/commands.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index fc1877cf4..c557f2550 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: '', From c7f51ba3ab33cdf5ccfe594553837c64a90c072a Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 10 Aug 2024 22:14:57 -0500 Subject: [PATCH 038/234] fix: admin/fs: blank screen #698 --- src/cross.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cross.ts b/src/cross.ts index fbc67c115..6fa741e68 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -367,7 +367,7 @@ 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]) => Object.hasOwn(a, k) || isEqualLax(v, a[k])) ) + && Object.entries(b).every(([k, v]) => k in a || isEqualLax(v, a[k])) ) } export function xlate(input: any, table: Record) { From f8bf9425e386cca244bf34b094cb33c0b6da6e0b Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 15 Aug 2024 18:03:19 +0200 Subject: [PATCH 039/234] fix: inconsistent state for select-all after deleting #700 --- frontend/src/FilterBar.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/FilterBar.ts b/frontend/src/FilterBar.ts index 33e72e473..8e5ea040f 100644 --- a/frontend/src/FilterBar.ts +++ b/frontend/src/FilterBar.ts @@ -17,6 +17,10 @@ export function FilterBar() { 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, { From 676d49e105ec4e2d31d0764c708d350c641f3dd0 Mon Sep 17 00:00:00 2001 From: Win_7 <90262580+W-i-n-7@users.noreply.github.com> Date: Fri, 16 Aug 2024 19:53:23 +0300 Subject: [PATCH 040/234] langs/fi updated #704 --- src/langs/hfs-lang-fi.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/langs/hfs-lang-fi.json b/src/langs/hfs-lang-fi.json index 83fe1722f..49fa36001 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.53.0b8", + "hfs_version": "0.54.0a1", "translate": { "Select": "Valitse", "n_files": "{n,plural,one{# tiedosto} other{# tiedostoa}}", @@ -152,7 +152,7 @@ "Close": "Sulje", "Folder": "Kansio", - "Web page": "Verkko sivu", + "Web page": "Verkkosivu", "Link": "Linkki", "Auto-play": "Auto-toisto", "autoplay_seconds": "Sekunteja odottaa kuvissa", @@ -173,6 +173,8 @@ "to_clipboard_source_tooltip": "Mene kansioon jossa leikepöydän kohteet sijaitsevat", "more_items": "{n} lisää kohdetta", "Show details": "Näytä yksityiskohdat", - "upload_conflict": "on jo olemassa" + "upload_conflict": "on jo olemassa", + "Logged in": "Kirjauduttu sisään", + "Logged out": "Kirjauduttu ulos" } } From 75e4232f8c1b0f3b622c92c6bde4a87b7e4284cd Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Fri, 16 Aug 2024 19:10:32 +0200 Subject: [PATCH 041/234] langs/ro #705 --- src/langs/hfs-lang-ro.json | 176 +++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/langs/hfs-lang-ro.json diff --git a/src/langs/hfs-lang-ro.json b/src/langs/hfs-lang-ro.json new file mode 100644 index 000000000..81389a518 --- /dev/null +++ b/src/langs/hfs-lang-ro.json @@ -0,0 +1,176 @@ +{ + "author": "Ovidiu", + "version": 1.0, + "hfs_version": "0.53.0", + "translate": { + "Select": "Selectează", + "n_files": "{n,plural,one{# fișier} other{# fișiere}}", + "n_folders": "{n,plural,one{# folder} other{# foldere}}", + "filter_count": "{n,plural, one{# filtrat} other{# filtrate}}", + "select_count": "{n,plural, one{# selectat} other{# selectate}}", + "filter_placeholder": "Tastează aici pentru a filtra lista de mai jos (caută după cuvinte cheie)", + "Select some files": "Selectează câteva fișiere", + "zip_checkboxes": "Folosește căsuțele de selectare pentru a alege fișierele, apoi poți folosi Zip din nou", + "zip_tooltip_selected": "Descarcă elementele selectate ca un singur fișier zip", + "zip_tooltip_whole": "Descarcă întreaga listă (nefiltrată) ca un singur fișier zip. Dacă selectezi câteva elemente, doar acelea vor fi descărcate.", + "zip_confirm_search": "Ești sigur că vrei să descarci TOATE rezultatele acestei căutări sub formă de arhivă ZIP?", + "zip_confirm_folder": "Ești sigur că vrei să descarci ÎNTREGUL folder sub formă de arhivă ZIP?", + "select_tooltip": "Selecția se aplică pentru \"Zip\" și \"Șterge\" (când sunt disponibile), dar poți, de asemenea, filtra lista", + "delete_hint": "Pentru a șterge, prima dată fă clic pe Selectează", + "delete_confirm": "Ștergi {n,plural, one{# element} other{# elemente}}?", + "delete_completed": "Ștergere: {n} completată", + "delete_failed": ", {n,plural, one{# eșuat} other{# eșuate}}", + "delete_select": "Selectează ceva de șters", + "Delete": "Șterge", + "Options": "Opțiuni", + "Search": "Căutare", + "Zip": "Zip", + "search_msg": "Caută în acest folder și în sub-foldere", + "Searching": "Căutare", + "Searched": "Căutat", + "Clear search": "Șterge căutarea", + "Interrupted": "Intrerupt", + "stopped_before": "Oprit înainte de a găsi ceva", + "empty_list": "Lista este goală. Nu este nimic aici", + "filter_none": "Niciun rezultat pentru filtrul aplicat", + + "Admin-panel": "Panoul de administrare", + "Login": "Autentificare", + "Username": "Nume utilizator", + "Password": "Parolă", + "login_untrusted": "Autentificare anulată: identitatea serverului nu poate fi de încredere", + "login_bad_credentials": "Numele de utilizator sau parola sunt incorecte. Te rugăm să re-efectuezi logarea.", + "login_bad_cookies": "Cookies nefuncționale - autentificare eșuată", + "User panel": "Panoul utilizatorului", + "Change password": "Schimbă parola", + "enter_pass": "Introdu noua parolă", + "enter_pass2": "RE-introdu aceeași parolă nouă", + "pass2_mismatch": "A doua parolă introdusă nu se potrivește cu prima. Procedura a fost anulată.", + "password_changed": "Parola a fost schimbată cu succes", + "Logout": "Deconectare", + "connection error": "eroare de conexiune", + "Full timestamp:": "Marcaj complet de timp:", + "Search was interrupted": "Căutarea a fost întreruptă", + "Stop list": "Oprire listă", + + "download_starting": "Descărcarea ar trebui să înceapă acum", + "wrong_account": "Contul {u} nu are acces, încearcă alt cont care are drepturi.", + "no_upload_here": "Nicio permisiune de încărcare pentru folderul curent", + "Create folder": "Creează folder", + "Pick files": "Selectează fișiere", + "Pick folder": "Selectează folder", + "send_files": "Trimite {n,plural,one{# fișier} other{# fișiere}}, {size}", + "Clear": "Șterge", + "failed_upload": "Nu s-a putut încărca {name}", + "confirm_resume": "Continuă încărcarea?", + "file too large": "Fișierul este prea mare", + "Enter folder name": "Introdu numele folderului", + "Successfully created": "Creat cu succes", + "enter_folder": "Intră în folder", + "folder_exists": "Există deja un folderul cu același nume", + + "Sort by:": "Sortați după: {by}", + "name": "nume", + "extension": "extensie", + "size": "dimensiune", + "time": "dată", + "Invert order": "Inversează ordinea", + "Folders first": "Folderele mai întâi", + "Numeric names": "Nume numerice", + "theme:": "temă:", + "auto": "automat", + "light": "luminos", + "dark": "întunecat", + "parent folder": "Folder sursă", + "home": "acasă", + + "Continue": "Continuă", + "Confirm": "Confirmă", + "Don't": "Nu", + "Warning": "Atenție", + "Error": "Eroare", + "Info": "Informații", + + "Unauthorized": "Neautorizat", + "Forbidden": "Interzis", + "Not found": "Nu a fost găsit", + "Server error": "Eroare de server", + + "Upload": "Încărcare", + "upload_concluded": "Încărcare încheiată:", + "upload_finished": "{n} terminat ({size})", + "upload_errors": "{n} eșuat", + "upload_file_rejected": "Unele fișiere nu au fost acceptate", + + "File menu": "Meniu fișier", + "Folder menu": "Meniu folder", + "Name": "Nume", + "file_open": "Deschide", + "Download": "Descarcă", + "Missing permission": "Permisiune lipsă", + "Reload": "Reîncarcă", + "Get list": "Obține lista", + "Skip existing files": "Sari peste fișierele deja existente", + "Size": "Dimensiune", + "Timestamp": "Data încărcării", + "Show": "Arată", + "Loading failed": "Încărcare eșuată", + "Rename": "Redenumește", + "Tiles mode:": "Dimensiunea iconițelor:", + "off": "0", + "Operation successful": "Operațiune reușită", + "Uploader": "Încărcător", + "Download counter": "Contor descărcări", + "Switch zoom mode": "Comută pe modul zoom", + "Full screen": "Ecran complet", + + "File Show help": "Ajutor afișare fișier", + "showHelpMain": "Poți folosi tastatura pentru anumite acțiuni:", + "showHelp_←/→": "←/→", + "showHelp_↑/↓": "↑/↓", + "showHelp_space": "spațiu", + "showHelp_←/→_body": "Mergi la fișierul anterior/următor", + "showHelp_↑/↓_body": "Derulează imagini înalte", + "Destination": "Destinație", + "in_queue": "{n} în așteptare", + "enter_comment": "Comentariu pentru {name}", + "Comment": "Comentariu", + "upload_dd_hint": "Poți încărca fișiere făcând drag&drop pe lista de fișiere", + "Upload not available": "Încărcarea nu este disponibilă", + "Cut": "Decupează", + "n_items": "{n,plural, one{# element} other{# elemente}}", + "good_bad": "{good} mutat, {bad} eșuat", + "after_cut": "Selecția ta este acum în clipboard.\nMergi în folderul destinație pentru a lipi.", + "Cancel clipboard": "Anulează clipboard", + "to_clipboard_source": "Înapoi la folderul sursă", + "Paste": "Lipește", + "clipboard_list": "Elemente în clipboard:", + + "Close": "Închide", + "Folder": "Folder", + "Web page": "Pagini web", + "Link": "Link", + "Auto-play": "Redare automată", + "autoplay_seconds": "Redare automată pe interval de secunde", + "Select all": "Selectează tot", + "go_first": "Mergi la primul element", + "go_last": "Mergi la ultimul element", + "Shuffle": "Amestecă", + "Repeat": "Repetă", + "showHelpListShortcut": "Din lista de fișiere, fă clic ținând apăsat {key} pentru a arăta rapid", + "Invalid value": "Valoare invalidă", + "upload_skipped": "{n} sărit", + "Overwrite policy": "Politică de suprascriere", + "Rename to avoid overwriting": "Redenumește pentru a evita suprascrierea", + "Overwrite existing files": "Suprascrie fișierele existente", + "Menu": "Meniu", + + "clipboard": "Clipboard ({content})", + "to_clipboard_source_tooltip": "Mergi la folderul unde se află conținutul clipboard-ului", + "more_items": "{n} mai multe element(e)", + "Show details": "Arată detalii", + "upload_conflict": "deja există", + "Logged in": "Autentificat cu succes", + "Logged out": "Deconectat cu succes" + } +} From c2a3332e20119a513c093bb8737389bb43bed347 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 17 Aug 2024 12:32:51 +0200 Subject: [PATCH 042/234] fix: admin/accounts: filling and emptying a text field left the "modified" visual clue on --- admin/src/AccountForm.ts | 8 ++++++-- admin/src/ConfigForm.ts | 4 ++-- admin/src/FileForm.ts | 7 ++++--- admin/src/OptionsPage.ts | 4 ++-- admin/src/mui.ts | 5 +++-- src/cross.ts | 11 ++++++----- src/listen.ts | 2 +- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/admin/src/AccountForm.ts b/admin/src/AccountForm.ts index c2f64009e..8d1a14eb0 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' @@ -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/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/FileForm.ts b/admin/src/FileForm.ts index 69e92e7fc..fe85a2cc5 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, IMAGE_FILEMASK } from './misc' -import { Btn, Flex, 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' @@ -134,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 diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index a4fa380d1..4ab277702 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -8,7 +8,7 @@ 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, IMAGE_FILEMASK } from './misc' -import { iconTooltip, InLink, LinkBtn, modifiedProps, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported } from './mui' +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' @@ -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: [ diff --git a/admin/src/mui.ts b/admin/src/mui.ts index c541bb635..b4eff6b1a 100644 --- a/admin/src/mui.ts +++ b/admin/src/mui.ts @@ -99,7 +99,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 } @@ -145,7 +146,7 @@ export const Btn = forwardRef(({ icon, title, onClick, disabled, progress, 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, diff --git a/src/cross.ts b/src/cross.ts index 6fa741e68..deaaf8c07 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -363,11 +363,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) { diff --git a/src/listen.ts b/src/listen.ts index 61dad9ffb..63f41d800 100644 --- a/src/listen.ts +++ b/src/listen.ts @@ -179,7 +179,7 @@ function renderHost(host: string) { 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) From 97cb729da2127630a2c1ef318c9489f9dc439cd5 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 17 Aug 2024 12:41:15 +0200 Subject: [PATCH 043/234] fix: a failed 'set comment' was not displaying any error --- frontend/src/fileMenu.ts | 48 ++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 51dab5cda..a7aad5258 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -120,8 +120,13 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe async onClick(ev: MouseEvent) { if (!entry.href) ev.preventDefault() - if (false !== await entry.onClick?.(ev)) - close() + try { + if (false !== await entry.onClick?.(ev)) + close() + } + catch(e: any) { + alertDialog(e) + } } }, hIcon(entry.icon || 'file'), @@ -139,29 +144,24 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe async function rename(entry: DirEntry) { const dest = await promptDialog(t`Name`, { value: entry.name, title: t`Rename` }) if (!dest) return - try { - 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) + '/') - }) - } - catch(e: any) { - await alertDialog(e) + 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) + '/') + }) } async function editComment(entry: DirEntry) { From d67653fced89e598389b5de89c5cc3b3feebe6eb Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 17 Aug 2024 13:14:35 +0200 Subject: [PATCH 044/234] fix: files with "\" in the name couldn't be interacted with #688 --- src/util-files.ts | 2 +- src/vfs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util-files.ts b/src/util-files.ts index 520a61766..28556e472 100644 --- a/src/util-files.ts +++ b/src/util-files.ts @@ -148,7 +148,7 @@ export function createFileWithPath(path: string, options?: Parameters|\\]/.test(name) && !dirTraversal(name) + return !(IS_WINDOWS ? /[/:"*?<>|\\]/ : /\//).test(name) && !dirTraversal(name) } export function exists(path: string) { diff --git a/src/vfs.ts b/src/vfs.ts index 930a29772..aba10503d 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -139,7 +139,6 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode= } export async function getNodeByName(name: string, parent: VfsNode) { - if (!isValidFileName(name)) return // does the tree node have a child that goes by this name, otherwise attempt disk const child = parent.children?.find(isSameFilenameAs(name)) || childFromDisk() return child && applyParentToChild(child, parent, name) @@ -156,6 +155,7 @@ export async function getNodeByName(name: string, parent: VfsNode) { } ret.rename = renameUnderPath(parent.rename, name) } + if (!isValidFileName(onDisk)) return ret.source = join(parent.source, onDisk) ret.original = undefined // overwrite in applyParentToChild, so we know this is not part of the vfs return ret From 7f303d4b19951474bd73e62f023bc929555b69fe Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 17 Aug 2024 18:47:14 +0200 Subject: [PATCH 045/234] fix: opening folder-menu clicking popup button, left the button visible after closing the menu --- frontend/src/fileMenu.ts | 1 + shared/dialogs.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index a7aad5258..cb956b8d5 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -94,6 +94,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe icon: () => ico, position: Math.min(innerWidth, innerHeight) < 800 ? undefined : [ev.pageX, ev.pageY - scrollY] as [number, number], + restoreFocus: ev.screenY || ev.screenX ? false : undefined, Content() { const {t} = useI18N() const details = useApi('get_file_details', { uris: [entry.uri] }).data?.details?.[0] diff --git a/shared/dialogs.ts b/shared/dialogs.ts index d0c0ef482..d35656a66 100644 --- a/shared/dialogs.ts +++ b/shared/dialogs.ts @@ -23,12 +23,12 @@ export interface DialogOptions { $id?: number $opening?: NodeJS.Timeout ts?: number + restoreFocus?: any Container?: FunctionComponent } const dialogs = proxy([]) -const focusBak: (Element | null)[] = [] const { history } = window export const dialogsDefaults: Partial = { @@ -184,7 +184,8 @@ export function newDialog(options: DialogOptions) { const ts = performance.now() options.$id = $id // object identity is not working because of the proxy. This is a possible workaround options.ts = ts - focusBak.push(document.activeElement) // saving this inside options object doesn't work (didn't dig enough to say why) + if (document.activeElement) + options.restoreFocus ??= ref(document.activeElement) options = objSameKeys(options, x => isValidElement(x) ? ref(x) : x) as typeof options // encapsulate elements as react will try to write, but valtio makes them readonly options.$opening = setTimeout(() => { // in case dialogs were just closed, account for window.history delay. This should be harmless as ux is unaffected, and programmatically you already didn't expect this to happen immediately but at state change dialogs.push(options) @@ -223,7 +224,7 @@ export function closeDialog(v?:any, skipHistory=false) { function closeDialogAt(i: number, value?: any) { const [d] = dialogs.splice(i,1) - ;(focusBak.pop() as any)?.focus?.() // if element is not HTMLElement, it doesn't have focus method + d.restoreFocus?.focus?.() // if element is not HTMLElement, it doesn't have focus method d.closingValue = value d?.onClose?.(value) return d From 8a29d4866b742240634202c0b34ea4cd2d7599d4 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 17 Aug 2024 19:17:22 +0200 Subject: [PATCH 046/234] fix: (regression 0.52.4) show: pressing ESC after file-menu, caused the file-show to be closed as well --- shared/dialogs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/dialogs.ts b/shared/dialogs.ts index d35656a66..304dd0c99 100644 --- a/shared/dialogs.ts +++ b/shared/dialogs.ts @@ -176,6 +176,7 @@ export function componentOrNode(x: ReactNode | FunctionComponent) { function onKeyDown(ev:any) { if (ev.key === 'Escape') { closeDialog() + ev.stopPropagation() } } From 8a28f71d5e2f020b3be6266ea72cfdf17a06fdcc Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 17 Aug 2024 19:18:28 +0200 Subject: [PATCH 047/234] show: ESC twice to close (to avoid unwanted stops) --- frontend/src/show.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/show.ts b/frontend/src/show.ts index d5bdc3594..5a3ad830e 100644 --- a/frontend/src/show.ts +++ b/frontend/src/show.ts @@ -8,7 +8,7 @@ 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' @@ -20,9 +20,14 @@ enum ZoomMode { } export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { + let escOnce = false + let onClose: any const { close } = newDialog({ noFrame: true, className: 'file-show', + onClose() { + onClose?.() + }, Content() { const [cur, setCur] = useState(entry) const moving = useRef(0) @@ -42,7 +47,14 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { goTo(shuffle[0]) }, [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) From 092c4afcb1033303f16c3883b81c7e401ce78054 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 19 Aug 2024 00:06:16 +0200 Subject: [PATCH 048/234] an upload with ?existing=overwrite will return an error if the file exists and you don't have permission --- src/upload.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/upload.ts b/src/upload.ts index cea6fc20c..064d654e1 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -1,4 +1,4 @@ -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' @@ -15,7 +15,7 @@ import { getCurrentUsername } from './auth' import { setCommentFor } from './comments' import _ from 'lodash' import events from './events' -import { rename } from 'fs/promises' +import { rename, rm } from 'fs/promises' export const deleteUnfinishedUploadsAfter = defineConfig('delete_unfinished_uploads_after', 86_400) export const minAvailableMb = defineConfig('min_available_mb', 100) @@ -86,6 +86,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 (fs.mkdirSync(dir, { recursive: true })) @@ -146,6 +147,10 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { } 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 @@ -203,9 +208,11 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { } async function overwriteAnyway() { - if (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) { From 1b2fe718330d6ede31f5d7320b96e89aba0720db Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 19 Aug 2024 19:24:01 +0200 Subject: [PATCH 049/234] admin/fs: better icon for "system integration" --- admin/src/LogsPage.ts | 55 ++++++++++++++++++++++------------------- admin/src/VfsMenuBar.ts | 7 +++--- admin/src/mui.ts | 13 ++++++---- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/admin/src/LogsPage.ts b/admin/src/LogsPage.ts index 4537b1bc9..0f6944ec2 100644 --- a/admin/src/LogsPage.ts +++ b/admin/src/LogsPage.ts @@ -299,47 +299,52 @@ function LogFile({ file, addToFooter, hidden }: { hidden?: boolean, file: string } } +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/VfsMenuBar.ts b/admin/src/VfsMenuBar.ts index 95b0fed7c..2369e0a0b 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' @@ -43,8 +44,8 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined }) const isWindows = platform === 'win32' const { data: integrated, reload } = useApi(isWindows && 'windows_integrated') const sm = useBreakpoint('sm') - return !isWindows ? null : h(Btn, { - icon: Microsoft, + return h(Btn, { + icon: osIcon('win'), variant: 'outlined', doneMessage: true, ...(!integrated?.is ? { diff --git a/admin/src/mui.ts b/admin/src/mui.ts index b4eff6b1a..b50cbb0fd 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' @@ -123,7 +125,7 @@ export const IconBtn = forwardRef((props: IconBtnProps, ref: ForwardedRef { - icon?: SvgIconComponent + icon?: SvgIconComponent | ReactElement title?: ReactNode disabled?: boolean | string progress?: boolean | number @@ -160,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 @@ -175,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) From c8a2bdb7ab983b67c9039fa71cffd2cef909a100 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 24 Aug 2024 17:40:47 +0200 Subject: [PATCH 050/234] don't show uploader's ip if you are not admin --- frontend/src/fileMenu.ts | 9 ++++++++- plugins/list-uploader/public/main.js | 5 +++-- src/frontEndApis.ts | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index cb956b8d5..0690318b5 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -99,7 +99,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' }, @@ -200,4 +201,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/plugins/list-uploader/public/main.js b/plugins/list-uploader/public/main.js index 80b1443f6..efcfd1346 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/src/frontEndApis.ts b/src/frontEndApis.ts index 37fd3f4bd..d3db20602 100644 --- a/src/frontEndApis.ts +++ b/src/frontEndApis.ts @@ -15,6 +15,8 @@ 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 +36,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 +44,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 } })) } }, From 303c9da659a8e08dfa171b146b608e42705c6c27 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 24 Aug 2024 17:54:48 +0200 Subject: [PATCH 051/234] moved service installation instructions to wiki --- README.md | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 1bbe7efe8..085c84448 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ 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). + 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 +88,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. From 2f46be0b9597ef82ab2f9af177acc47f83073be5 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 25 Aug 2024 00:37:51 +0200 Subject: [PATCH 052/234] argument --cwd will now create a missing folder #690 --- src/const.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/const.ts b/src/const.ts index 0c4cae9b6..1f323c8b3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -36,10 +36,8 @@ console.log('started', HFS_STARTED.toLocaleString(), DEV) console.log('version', VERSION||'-') console.log('build', BUILD_TIMESTAMP||'-') const winExe = IS_WINDOWS && process.execPath.match(/(? Date: Sun, 25 Aug 2024 10:07:06 +0200 Subject: [PATCH 053/234] don't try to write to program-files folder --- src/const.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/const.ts b/src/const.ts index 1f323c8b3..4b9c126a9 100644 --- a/src/const.ts +++ b/src/const.ts @@ -36,7 +36,9 @@ console.log('started', HFS_STARTED.toLocaleString(), DEV) console.log('version', VERSION||'-') console.log('build', BUILD_TIMESTAMP||'-') const winExe = IS_WINDOWS && process.execPath.match(/(? Date: Sun, 25 Aug 2024 10:16:28 +0200 Subject: [PATCH 054/234] admin/monitoring: show ram --- admin/src/MonitorPage.ts | 12 +++++++----- admin/src/api.ts | 2 +- shared/api.ts | 3 +-- src/adminApis.ts | 1 + 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/admin/src/MonitorPage.ts b/admin/src/MonitorPage.ts index a013a566a..8aea3a4b3 100644 --- a/admin/src/MonitorPage.ts +++ b/admin/src/MonitorPage.ts @@ -17,6 +17,7 @@ import { agentIcons } from './LogsPage' import { state, useSnapState } from './state' import { useBlockIp } from './useBlockIp' 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', { @@ -52,7 +53,8 @@ function MoreInfo() { (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", @@ -71,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 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/shared/api.ts b/shared/api.ts index 16be3062c..9c44cf08e 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -89,13 +89,12 @@ export function useApi(cmd: string | Falsy, params?: object, options: Api 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 as any), err => { + .then(res => aborted || setData(dataRef.current = res as any) || setError(undefined), err => { if (aborted) return setError(err) setData(dataRef.current = undefined) diff --git a/src/adminApis.ts b/src/adminApis.ts index faa00d962..c082e92f3 100644 --- a/src/adminApis.ts +++ b/src/adminApis.ts @@ -128,6 +128,7 @@ export const adminApis = { autoCheckUpdateResult: autoCheckUpdateResult.get(), // in this form, we get the same type of the serialized json alerts: alerts.get(), proxyDetected: getProxyDetected(), + ram: process.memoryUsage.rss(), frpDetected: localhostAdmin.get() && !getProxyDetected() && getConnections().every(isLocalHost) && await frpDebounced(), From 3950c0a944d73e750259576d81ad95d148332a67 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 25 Aug 2024 16:38:08 +0200 Subject: [PATCH 055/234] dev: updated modules with security warnings --- package-lock.json | 796 +++++++++++++++++++++++++--------------------- package.json | 2 +- 2 files changed, 434 insertions(+), 364 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86a89081f..6d4fa4bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "picomatch": "^3.0.1", "qr-creator": "^1.0.0", "tssrp6a": "^3.0.0", - "unzip-stream": "^0.3.1", + "unzip-stream": "^0.3.4", "valtio": "^1.10.3", "yaml": "^2.0.0-10" }, @@ -2392,9 +2392,9 @@ "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -2408,9 +2408,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -2424,9 +2424,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -2440,9 +2440,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -2456,9 +2456,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -2472,9 +2472,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -2488,9 +2488,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -2504,9 +2504,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -2520,9 +2520,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -2536,9 +2536,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -2552,9 +2552,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -2568,9 +2568,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -2584,9 +2584,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -2600,9 +2600,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -2616,9 +2616,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -2632,9 +2632,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -2648,9 +2648,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -2664,9 +2664,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -2680,9 +2680,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -2696,9 +2696,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -2712,9 +2712,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -2728,9 +2728,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -2744,9 +2744,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -3733,9 +3733,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.0.tgz", + "integrity": "sha512-WTWD8PfoSAJ+qL87lE7votj3syLavxunWhzCnx3XFxFiI/BA/r3X7MUM8dVrH8rb2r4AiO8jJsr3ZjdaftmnfA==", "cpu": [ "arm" ], @@ -3746,9 +3746,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.0.tgz", + "integrity": "sha512-a1sR2zSK1B4eYkiZu17ZUZhmUQcKjk2/j9Me2IDjk1GHW7LB5Z35LEzj9iJch6gtUfsnvZs1ZNyDW2oZSThrkA==", "cpu": [ "arm64" ], @@ -3759,9 +3759,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.0.tgz", + "integrity": "sha512-zOnKWLgDld/svhKO5PD9ozmL6roy5OQ5T4ThvdYZLpiOhEGY+dp2NwUmxK0Ld91LrbjrvtNAE0ERBwjqhZTRAA==", "cpu": [ "arm64" ], @@ -3772,9 +3772,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.0.tgz", + "integrity": "sha512-7doS8br0xAkg48SKE2QNtMSFPFUlRdw9+votl27MvT46vo44ATBmdZdGysOevNELmZlfd+NEa0UYOA8f01WSrg==", "cpu": [ "x64" ], @@ -3785,9 +3785,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.0.tgz", + "integrity": "sha512-pWJsfQjNWNGsoCq53KjMtwdJDmh/6NubwQcz52aEwLEuvx08bzcy6tOUuawAOncPnxz/3siRtd8hiQ32G1y8VA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.0.tgz", + "integrity": "sha512-efRIANsz3UHZrnZXuEvxS9LoCOWMGD1rweciD6uJQIx2myN3a8Im1FafZBzh7zk1RJ6oKcR16dU3UPldaKd83w==", "cpu": [ "arm" ], @@ -3798,9 +3811,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.0.tgz", + "integrity": "sha512-ZrPhydkTVhyeGTW94WJ8pnl1uroqVHM3j3hjdquwAcWnmivjAwOYjTEAuEDeJvGX7xv3Z9GAvrBkEzCgHq9U1w==", "cpu": [ "arm64" ], @@ -3811,9 +3824,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.0.tgz", + "integrity": "sha512-cfaupqd+UEFeURmqNP2eEvXqgbSox/LHOyN9/d2pSdV8xTrjdg3NgOFJCtc1vQ/jEke1qD0IejbBfxleBPHnPw==", "cpu": [ "arm64" ], @@ -3823,10 +3836,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.0.tgz", + "integrity": "sha512-ZKPan1/RvAhrUylwBXC9t7B2hXdpb/ufeu22pG2psV7RN8roOfGurEghw1ySmX/CmDDHNTDDjY3lo9hRlgtaHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.0.tgz", + "integrity": "sha512-H1eRaCwd5E8eS8leiS+o/NqMdljkcb1d6r2h4fKSsCXQilLKArq6WS7XBLDu80Yz+nMqHVFDquwcVrQmGr28rg==", "cpu": [ "riscv64" ], @@ -3836,10 +3862,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.0.tgz", + "integrity": "sha512-zJ4hA+3b5tu8u7L58CCSI0A9N1vkfwPhWd/puGXwtZlsB5bTkwDNW/+JCU84+3QYmKpLi+XvHdmrlwUwDA6kqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.0.tgz", + "integrity": "sha512-e2hrvElFIh6kW/UNBQK/kzqMNY5mO+67YtEh9OA65RM5IJXYTWiXjX6fjIiPaqOkBthYF1EqgiZ6OXKcQsM0hg==", "cpu": [ "x64" ], @@ -3850,9 +3889,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.0.tgz", + "integrity": "sha512-1vvmgDdUSebVGXWX2lIcgRebqfQSff0hMEkLJyakQ9JQUbLDkEaMsPTLOmyccyC6IJ/l3FZuJbmrBw/u0A0uCQ==", "cpu": [ "x64" ], @@ -3863,9 +3902,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.0.tgz", + "integrity": "sha512-s5oFkZ/hFcrlAyBTONFY1TWndfyre1wOMwU+6KCpm/iatybvrRgmZVM+vCFwxmC5ZhdlgfE0N4XorsDpi7/4XQ==", "cpu": [ "arm64" ], @@ -3876,9 +3915,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.0.tgz", + "integrity": "sha512-G9+TEqRnAA6nbpqyUqgTiopmnfgnMkR3kMukFBDsiyy23LZvUCpiUwjTRx6ezYCjJODXrh52rBR9oXvm+Fp5wg==", "cpu": [ "ia32" ], @@ -3889,9 +3928,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.0.tgz", + "integrity": "sha512-2jsCDZwtQvRhejHLfZ1JY6w6kEuEtfF9nzYsZxzSlNVKDX+DpsDJ+Rbjkm74nvg2rdx0gwBS+IMdvwJuq3S9pQ==", "cpu": [ "x64" ], @@ -4480,9 +4519,9 @@ } }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -5395,9 +5434,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -5407,29 +5446,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -5491,17 +5530,17 @@ } }, "node_modules/fast-xml-parser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.0.tgz", - "integrity": "sha512-5Wln/SBrtlN37aboiNNFHfSALwLzpUx1vJhDgDVPKKG3JrNe8BWTUoNKqkeKk/HqNbKxC8nEAJaBydq30yHoLA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "funding": [ - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - }, { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" } ], "dependencies": { @@ -6600,11 +6639,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -7424,9 +7463,9 @@ } }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "dev": true, "funding": [ { @@ -7444,8 +7483,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8123,9 +8162,9 @@ } }, "node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.0.tgz", + "integrity": "sha512-vo+S/lfA2lMS7rZ2Qoubi6I5hwZwzXeUIctILZLbHI+laNtvhhOIon2S1JksA5UEDQ7l3vberd0fxK44lTYjbQ==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -8138,19 +8177,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", + "@rollup/rollup-android-arm-eabi": "4.21.0", + "@rollup/rollup-android-arm64": "4.21.0", + "@rollup/rollup-darwin-arm64": "4.21.0", + "@rollup/rollup-darwin-x64": "4.21.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.0", + "@rollup/rollup-linux-arm-musleabihf": "4.21.0", + "@rollup/rollup-linux-arm64-gnu": "4.21.0", + "@rollup/rollup-linux-arm64-musl": "4.21.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.0", + "@rollup/rollup-linux-riscv64-gnu": "4.21.0", + "@rollup/rollup-linux-s390x-gnu": "4.21.0", + "@rollup/rollup-linux-x64-gnu": "4.21.0", + "@rollup/rollup-linux-x64-musl": "4.21.0", + "@rollup/rollup-win32-arm64-msvc": "4.21.0", + "@rollup/rollup-win32-ia32-msvc": "4.21.0", + "@rollup/rollup-win32-x64-msvc": "4.21.0", "fsevents": "~2.3.2" } }, @@ -8340,9 +8382,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -8870,9 +8912,9 @@ } }, "node_modules/unzip-stream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.1.tgz", - "integrity": "sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.4.tgz", + "integrity": "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw==", "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" @@ -9002,14 +9044,14 @@ } }, "node_modules/vite": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", - "integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", + "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", "dev": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.41", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -9028,6 +9070,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -9045,6 +9088,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -10918,163 +10964,163 @@ "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "dev": true, "optional": true }, @@ -11694,93 +11740,114 @@ "integrity": "sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==" }, "@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.0.tgz", + "integrity": "sha512-WTWD8PfoSAJ+qL87lE7votj3syLavxunWhzCnx3XFxFiI/BA/r3X7MUM8dVrH8rb2r4AiO8jJsr3ZjdaftmnfA==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.0.tgz", + "integrity": "sha512-a1sR2zSK1B4eYkiZu17ZUZhmUQcKjk2/j9Me2IDjk1GHW7LB5Z35LEzj9iJch6gtUfsnvZs1ZNyDW2oZSThrkA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.0.tgz", + "integrity": "sha512-zOnKWLgDld/svhKO5PD9ozmL6roy5OQ5T4ThvdYZLpiOhEGY+dp2NwUmxK0Ld91LrbjrvtNAE0ERBwjqhZTRAA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.0.tgz", + "integrity": "sha512-7doS8br0xAkg48SKE2QNtMSFPFUlRdw9+votl27MvT46vo44ATBmdZdGysOevNELmZlfd+NEa0UYOA8f01WSrg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.0.tgz", + "integrity": "sha512-pWJsfQjNWNGsoCq53KjMtwdJDmh/6NubwQcz52aEwLEuvx08bzcy6tOUuawAOncPnxz/3siRtd8hiQ32G1y8VA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.0.tgz", + "integrity": "sha512-efRIANsz3UHZrnZXuEvxS9LoCOWMGD1rweciD6uJQIx2myN3a8Im1FafZBzh7zk1RJ6oKcR16dU3UPldaKd83w==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.0.tgz", + "integrity": "sha512-ZrPhydkTVhyeGTW94WJ8pnl1uroqVHM3j3hjdquwAcWnmivjAwOYjTEAuEDeJvGX7xv3Z9GAvrBkEzCgHq9U1w==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.0.tgz", + "integrity": "sha512-cfaupqd+UEFeURmqNP2eEvXqgbSox/LHOyN9/d2pSdV8xTrjdg3NgOFJCtc1vQ/jEke1qD0IejbBfxleBPHnPw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.0.tgz", + "integrity": "sha512-ZKPan1/RvAhrUylwBXC9t7B2hXdpb/ufeu22pG2psV7RN8roOfGurEghw1ySmX/CmDDHNTDDjY3lo9hRlgtaHg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.0.tgz", + "integrity": "sha512-H1eRaCwd5E8eS8leiS+o/NqMdljkcb1d6r2h4fKSsCXQilLKArq6WS7XBLDu80Yz+nMqHVFDquwcVrQmGr28rg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.0.tgz", + "integrity": "sha512-zJ4hA+3b5tu8u7L58CCSI0A9N1vkfwPhWd/puGXwtZlsB5bTkwDNW/+JCU84+3QYmKpLi+XvHdmrlwUwDA6kqw==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.0.tgz", + "integrity": "sha512-e2hrvElFIh6kW/UNBQK/kzqMNY5mO+67YtEh9OA65RM5IJXYTWiXjX6fjIiPaqOkBthYF1EqgiZ6OXKcQsM0hg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.0.tgz", + "integrity": "sha512-1vvmgDdUSebVGXWX2lIcgRebqfQSff0hMEkLJyakQ9JQUbLDkEaMsPTLOmyccyC6IJ/l3FZuJbmrBw/u0A0uCQ==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.0.tgz", + "integrity": "sha512-s5oFkZ/hFcrlAyBTONFY1TWndfyre1wOMwU+6KCpm/iatybvrRgmZVM+vCFwxmC5ZhdlgfE0N4XorsDpi7/4XQ==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.0.tgz", + "integrity": "sha512-G9+TEqRnAA6nbpqyUqgTiopmnfgnMkR3kMukFBDsiyy23LZvUCpiUwjTRx6ezYCjJODXrh52rBR9oXvm+Fp5wg==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.0.tgz", + "integrity": "sha512-2jsCDZwtQvRhejHLfZ1JY6w6kEuEtfF9nzYsZxzSlNVKDX+DpsDJ+Rbjkm74nvg2rdx0gwBS+IMdvwJuq3S9pQ==", "dev": true, "optional": true }, @@ -12304,9 +12371,9 @@ "dev": true }, "axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "requires": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -12947,34 +13014,34 @@ "dev": true }, "esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "escalade": { @@ -13018,9 +13085,9 @@ } }, "fast-xml-parser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.0.tgz", - "integrity": "sha512-5Wln/SBrtlN37aboiNNFHfSALwLzpUx1vJhDgDVPKKG3JrNe8BWTUoNKqkeKk/HqNbKxC8nEAJaBydq30yHoLA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "requires": { "strnum": "^1.0.5" } @@ -13838,11 +13905,11 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "dependencies": { @@ -14428,14 +14495,14 @@ } }, "postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "dev": true, "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" }, "dependencies": { "nanoid": { @@ -14955,24 +15022,27 @@ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" }, "rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.0.tgz", + "integrity": "sha512-vo+S/lfA2lMS7rZ2Qoubi6I5hwZwzXeUIctILZLbHI+laNtvhhOIon2S1JksA5UEDQ7l3vberd0fxK44lTYjbQ==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.21.0", + "@rollup/rollup-android-arm64": "4.21.0", + "@rollup/rollup-darwin-arm64": "4.21.0", + "@rollup/rollup-darwin-x64": "4.21.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.0", + "@rollup/rollup-linux-arm-musleabihf": "4.21.0", + "@rollup/rollup-linux-arm64-gnu": "4.21.0", + "@rollup/rollup-linux-arm64-musl": "4.21.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.0", + "@rollup/rollup-linux-riscv64-gnu": "4.21.0", + "@rollup/rollup-linux-s390x-gnu": "4.21.0", + "@rollup/rollup-linux-x64-gnu": "4.21.0", + "@rollup/rollup-linux-x64-musl": "4.21.0", + "@rollup/rollup-win32-arm64-msvc": "4.21.0", + "@rollup/rollup-win32-ia32-msvc": "4.21.0", + "@rollup/rollup-win32-x64-msvc": "4.21.0", "@types/estree": "1.0.5", "fsevents": "~2.3.2" } @@ -15096,9 +15166,9 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" }, "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true }, "source-map-support": { @@ -15501,9 +15571,9 @@ "dev": true }, "unzip-stream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.1.tgz", - "integrity": "sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.4.tgz", + "integrity": "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw==", "requires": { "binary": "^0.3.0", "mkdirp": "^0.5.1" @@ -15584,15 +15654,15 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", - "integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", + "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", "dev": true, "requires": { - "esbuild": "^0.19.3", + "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" + "postcss": "^8.4.41", + "rollup": "^4.20.0" } }, "walk-filtered": { diff --git a/package.json b/package.json index 0a57cafde..61a61d41e 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "qr-creator": "^1.0.0", "picomatch": "^3.0.1", "tssrp6a": "^3.0.0", - "unzip-stream": "^0.3.1", + "unzip-stream": "^0.3.4", "valtio": "^1.10.3", "yaml": "^2.0.0-10" }, From 6d53ea837006300941c303e5b9ee5390f8bfbbd1 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 26 Aug 2024 11:45:50 +0200 Subject: [PATCH 056/234] better code: event api to preventDefault and return value at the same time --- src/cross.ts | 1 - src/events.ts | 25 +++++++++++++++++-------- src/misc.ts | 2 +- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/cross.ts b/src/cross.ts index deaaf8c07..373cf74ab 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -403,7 +403,6 @@ export function pathEncode(s: string) { } //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 diff --git a/src/events.ts b/src/events.ts index 339ece971..143024d2c 100644 --- a/src/events.ts +++ b/src/events.ts @@ -6,7 +6,7 @@ const LISTENERS_SUFFIX = '\0listeners' export class BetterEventEmitter { protected listeners = new Map() - stop = Symbol() + preventDefault = Symbol() on(event: string | string[], listener: Listener, { warnAfter=10 }={}) { if (typeof event === 'string') event = [event] @@ -51,19 +51,28 @@ export class BetterEventEmitter { let cbs = this.listeners.get(event) if (!cbs?.size) return const ret: any[] = [] + let prevented = false + const extra = { + preventDefault() { prevented = true } + } for (const cb of cbs) { - const res = cb(...args) - if (res !== undefined) + const res = cb(...args, extra) + if (res === this.preventDefault) + extra.preventDefault() + else if (res !== undefined) ret.push(res) } return Object.assign(ret, { - isDefaultPrevented: () => ret.some(r => r === this.stop), + 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/misc.ts b/src/misc.ts index c1fa7e6dc..b4eb4e76c 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -145,7 +145,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 }) From 9a9c937486332d34e7927532d7a99fba59ca931d Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 26 Aug 2024 12:18:45 +0200 Subject: [PATCH 057/234] better code: future-proof arguments for frontend events, breaking for plugins using undocumented parameters (file-icons) --- dev-plugins.md | 2 +- frontend/src/misc.ts | 13 ++++++++----- src/const.ts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index 9c95e685c..9d1e09c0e 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -626,7 +626,7 @@ If you want to override a text regardless of the language, use the special langu ## API version history -- 8.9 (v0.54.0) +- 9 (v0.54.0) - frontend event: showPlay - api.addBlock - api.misc diff --git a/frontend/src/misc.ts b/frontend/src/misc.ts index 306c5d48b..da505614e 100644 --- a/frontend/src/misc.ts +++ b/frontend/src/misc.ts @@ -4,7 +4,7 @@ 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' @@ -54,8 +54,11 @@ 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 ev = new CustomEvent('hfs.'+name, { cancelable: true, detail: { params, output } }) + document.dispatchEvent(ev) + return Object.assign(output, { + isDefaultPrevent: () => ev.defaultPrevented, + }) } const tools = { @@ -74,14 +77,14 @@ Object.assign(getHFS(), { debounceAsync, useSnapState, 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[], 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) + const res = cb(params, { output, preventDefault: () => ev.preventDefault() }, output) // legacy pre-0.54, third parameter used by file-icons plugin if (res !== undefined && Array.isArray(output)) output.push(res) } diff --git a/src/const.ts b/src/const.ts index 4b9c126a9..daafb827d 100644 --- a/src/const.ts +++ b/src/const.ts @@ -7,7 +7,7 @@ import { mkdirSync } from 'fs' import { basename, dirname, join } from 'path' export * from './cross-const' -export const API_VERSION = 8.9 +export const API_VERSION = 9 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' From 5c11f889f58231d9e148fabb1df0306c12ba1874 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 26 Aug 2024 12:39:58 +0200 Subject: [PATCH 058/234] plugins: frontend event 'paste' --- dev-plugins.md | 1 + frontend/src/clip.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-plugins.md b/dev-plugins.md index 9d1e09c0e..9b492c2cf 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -630,6 +630,7 @@ If you want to override a text regardless of the language, use the special langu - frontend event: showPlay - api.addBlock - api.misc + - frontend event: paste - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip 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) From b82f27ea710ad441e0402ae3a7cc25ae8508e2f8 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 27 Aug 2024 00:36:58 +0200 Subject: [PATCH 059/234] plugins: HFS.customRestCall --- dev-plugins.md | 3 +++ frontend/src/misc.ts | 3 +++ src/apiMiddleware.ts | 7 +++++-- src/cross-const.ts | 1 + src/plugins.ts | 14 ++++++++++++++ 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index 9b492c2cf..466a6b0f0 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -120,6 +120,7 @@ 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` ### FieldDescriptor @@ -267,6 +268,7 @@ 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`. 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. @@ -631,6 +633,7 @@ If you want to override a text regardless of the language, use the special langu - api.addBlock - api.misc - frontend event: paste + - exports.customRest + HFS.customRestCall - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/src/misc.ts b/frontend/src/misc.ts index da505614e..d3b6d88c0 100644 --- a/frontend/src/misc.ts +++ b/frontend/src/misc.ts @@ -68,6 +68,9 @@ const tools = { watchState(k: string, cb: (v: any) => void) { const up = k.split('upload.')[1] return subscribeKey(up ? uploadState : state as any, up || k, cb, true) + }, + customRestCall(name: string, ...rest: any[]) { + return apiCall(cross.PLUGIN_CUSTOM_REST_PREFIX + name, ...rest) } } Object.assign(getHFS(), { diff --git a/src/apiMiddleware.ts b/src/apiMiddleware.ts index d987a68c8..f0d3eacb0 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) { @@ -31,7 +32,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/cross-const.ts b/src/cross-const.ts index a03412b39..27349d9b7 100644 --- a/src/cross-const.ts +++ b/src/cross-const.ts @@ -5,6 +5,7 @@ export const API_URI = SPECIAL_URI + 'api/' export const PLUGINS_PUB_URI = SPECIAL_URI + 'plugins/' export const PORT_DISABLED = -1 export const NBSP = '\xA0' +export const PLUGIN_CUSTOM_REST_PREFIX = '_' export const HTTP_OK = 200 export const HTTP_NO_CONTENT = 204 diff --git a/src/plugins.ts b/src/plugins.ts index f69ea4f17..86dea76e0 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -282,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 From 99b57840e1d440fae2972a7c1ae1d1ea5261592c Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 28 Aug 2024 22:53:41 +0200 Subject: [PATCH 060/234] create-folder in current folder's menu --- frontend/src/Breadcrumbs.ts | 7 +++++++ frontend/src/fileMenu.ts | 4 ++-- frontend/src/upload.ts | 2 +- src/selfCheck.ts | 1 - 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/Breadcrumbs.ts b/frontend/src/Breadcrumbs.ts index edfff8ca1..3016719b4 100644 --- a/frontend/src/Breadcrumbs.ts +++ b/frontend/src/Breadcrumbs.ts @@ -7,6 +7,7 @@ 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() + '/' @@ -43,6 +44,12 @@ function Breadcrumb({ path, label, current, title }:{ current?: boolean, path: s 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/fileMenu.ts b/frontend/src/fileMenu.ts index 0690318b5..8ea863d8e 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' @@ -26,7 +26,7 @@ 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') diff --git a/frontend/src/upload.ts b/frontend/src/upload.ts index b4c73e02a..c61f88505 100644 --- a/frontend/src/upload.ts +++ b/frontend/src/upload.ts @@ -476,7 +476,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/src/selfCheck.ts b/src/selfCheck.ts index fd5574fa7..fe67fe103 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 From cc844604689715cd89d315f3fa391d7fab85038c Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 28 Aug 2024 23:21:15 +0200 Subject: [PATCH 061/234] ux: menu icon on last breadcrumb will hint the user that it will pop up the menu --- frontend/src/Breadcrumbs.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/Breadcrumbs.ts b/frontend/src/Breadcrumbs.ts index 3016719b4..e48f44bfd 100644 --- a/frontend/src/Breadcrumbs.ts +++ b/frontend/src/Breadcrumbs.ts @@ -24,7 +24,10 @@ export function Breadcrumbs() { 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' } })) + }, }) ) ) } From f9b9958fbb4c8df9a0032bf9cb4377fc84c99d4c Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 17 Jul 2024 23:38:27 +0200 Subject: [PATCH 062/234] fix: memory leak during search #678 --- src/api.vfs.ts | 4 +- src/dirStream.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++ src/makeQ.ts | 25 ++++++++++ src/util-files.ts | 24 +++------- src/vfs.ts | 11 +++-- 5 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 src/dirStream.ts create mode 100644 src/makeQ.ts diff --git a/src/api.vfs.ts b/src/api.vfs.ts index 84eb9ffdc..2a54a84c9 100644 --- a/src/api.vfs.ts +++ b/src/api.vfs.ts @@ -199,9 +199,11 @@ 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 diff --git a/src/dirStream.ts b/src/dirStream.ts new file mode 100644 index 000000000..7e03f1534 --- /dev/null +++ b/src/dirStream.ts @@ -0,0 +1,116 @@ +import { makeQ } from './makeQ' +import { opendir, stat } from 'fs/promises' +import { join } from 'path' +import { Readable } from 'stream' +import { pendingPromise } from './cross' +import { Stats, Dirent } from 'node:fs' + +export interface DirStreamEntry extends Dirent { + closingBranch?: Promise + stats?: Stats +} + +const dirQ = makeQ(3) + +export function createDirStream(startPath: string, depth=0) { + 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 dir = await opendir(base) + const subDirsDone: Promise[] = [] + let last: DirStreamEntry | undefined = undefined + let n = 0 + for await (let entry of dir) { + if (stopped) { + await dir.close().catch(() => {}) // only necessary for early exit + break + } + const stats = entry.isSymbolicLink() && await stat(join(base, entry.name)).catch(() => null) + if (stats === null) continue + if (stats) + entry = new DirentFromStats(entry.name, stats) + entry.path = (path && path + '/') + entry.name + if (last && closingQ.length) // pending entries + last.closingBranch = Promise.resolve(closingQ.shift()!) + last = entry + const expanded: DirStreamEntry = entry + if (stats) + expanded.stats = stats + 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/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/util-files.ts b/src/util-files.ts index 28556e472..5af2085e6 100644 --- a/src/util-files.ts +++ b/src/util-files.ts @@ -8,6 +8,7 @@ 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,27 +72,16 @@ export function adjustStaticPathForGlob(path: string) { return glob.escapePath(path.replace(/\\/g, '/')) } +// wrapper adding a few features: hidden files, onlyFiles and onlyFolders export async function* dirStream(path: string, { depth=0, onlyFiles=false, onlyFolders = false }={}) { 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 + for await (const entry of createDirStream(path, depth)) { + const dirent = entry as DirStreamEntry + if (dirent.isDirectory() ? onlyFiles : (onlyFolders || !dirent.isFile())) continue + if (skip?.includes(entry.path)) continue + yield dirent } async function getItemsToSkip(path: string) { diff --git a/src/vfs.ts b/src/vfs.ts index aba10503d..8cbd9bccf 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -292,13 +292,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 })) { 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) { @@ -317,11 +319,14 @@ export async function* walkNode(parent: VfsNode, { parentsCache.set(name, item) if (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 function canSee(item: VfsNode) { From 5e3c8e3f20d8d1c663e2063faf08d68a7e4028a9 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 25 Aug 2024 15:55:50 +0200 Subject: [PATCH 063/234] optimization: reduce number of stat calls --- src/api.get_file_list.ts | 2 +- src/api.vfs.ts | 4 ++-- src/vfs.ts | 8 +++++--- src/zip.ts | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/api.get_file_list.ts b/src/api.get_file_list.ts index 19ef0ca0e..bd855c459 100644 --- a/src/api.get_file_list.ts +++ b/src/api.get_file_list.ts @@ -114,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' : '' diff --git a/src/api.vfs.ts b/src/api.vfs.ts index 2a54a84c9..8a740b787 100644 --- a/src/api.vfs.ts +++ b/src/api.vfs.ts @@ -32,7 +32,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 } @@ -208,7 +208,7 @@ const apis: ApiHandlers = { 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, diff --git a/src/vfs.ts b/src/vfs.ts index 8cbd9bccf..3362a2f08 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -28,6 +28,7 @@ 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' type Masks = Record @@ -50,6 +51,7 @@ export interface VfsNode extends VfsNodeStored { // include fields that are only 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) { @@ -125,7 +127,7 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode= return urlToNode(rest, ctx, ret, getRest) if (ret.source) try { - const st = await fs.stat(ret.source) // check existence + const st = ret.stats || await fs.stat(ret.source) // check existence ret.isFolder = st.isDirectory() } catch { @@ -196,13 +198,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) { diff --git a/src/zip.ts b/src/zip.ts index f9b0c2ccc..c2f24202d 100644 --- a/src/zip.ts +++ b/src/zip.ts @@ -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 { From b22bd73886a9ccc02c7dd44fc33695c3d2c4da77 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 1 Sep 2024 10:05:32 +0200 Subject: [PATCH 064/234] make customHtml.htmlHead have access to HFS object --- frontend/src/fileMenu.ts | 3 +-- src/serveGuiFiles.ts | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 8ea863d8e..481545b9c 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -85,8 +85,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy }, ] 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`, diff --git a/src/serveGuiFiles.ts b/src/serveGuiFiles.ts index 81f5f6117..505dfe548 100644 --- a/src/serveGuiFiles.ts +++ b/src/serveGuiFiles.ts @@ -96,10 +96,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} From cb1700dfb26a1eabe6ea02c02a390fb96094a3f7 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 31 Aug 2024 17:15:28 +0200 Subject: [PATCH 065/234] fix: on Windows, don't list files inside hidden folders --- src/dirStream.ts | 26 ++++++++++++++++++-------- src/util-files.ts | 17 ++--------------- src/vfs.ts | 2 +- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/dirStream.ts b/src/dirStream.ts index 7e03f1534..0ac7d1462 100644 --- a/src/dirStream.ts +++ b/src/dirStream.ts @@ -1,5 +1,7 @@ import { makeQ } from './makeQ' -import { opendir, stat } from 'fs/promises' +import { stat, readdir } from 'fs/promises' +import { runCmd } from './util-os' +import { IS_WINDOWS } from './const' import { join } from 'path' import { Readable } from 'stream' import { pendingPromise } from './cross' @@ -12,10 +14,12 @@ export interface DirStreamEntry extends Dirent { const dirQ = makeQ(3) -export function createDirStream(startPath: string, depth=0) { +export function createDirStream(startPath: string, { depth=0, hidden=true }) { let stopped = false let started = false const closingQ: string[] = [] + const hiddenRoot = !hidden && IS_WINDOWS && getWindowsHiddenFiles(startPath) // produce first level faster + const hiddenDeep = hiddenRoot && depth && getWindowsHiddenFiles(startPath, true) const stream = new Readable({ objectMode: true, read() { @@ -45,20 +49,19 @@ export function createDirStream(startPath: string, depth=0) { async function readDir(path: string, depth: number) { if (stopped) return const base = join(startPath, path) - const dir = await opendir(base) const subDirsDone: Promise[] = [] let last: DirStreamEntry | undefined = undefined let n = 0 - for await (let entry of dir) { - if (stopped) { - await dir.close().catch(() => {}) // only necessary for early exit - break - } + for await (let entry of await readdir(base, { withFileTypes: true })) { + if (stopped) break const stats = entry.isSymbolicLink() && await stat(join(base, entry.name)).catch(() => null) if (stats === null) continue if (stats) entry = new DirentFromStats(entry.name, stats) entry.path = (path && path + '/') + entry.name + const hiddenFiles = await (path && hiddenDeep || hiddenRoot) + if (hiddenFiles && hiddenFiles.includes(entry.path)) + continue if (last && closingQ.length) // pending entries last.closingBranch = Promise.resolve(closingQ.shift()!) last = entry @@ -92,6 +95,13 @@ export function createDirStream(startPath: string, depth=0) { } } +async function getWindowsHiddenFiles(path: string, depth=false) { + const out = await runCmd('dir', ['/ah', '/b', depth ? '/s' : '/c', path.replaceAll('/', '\\')]) // cannot pass '', so we pass /c as a noop parameter + .catch(()=>'') // error in case of no matching file + const slice = !depth ? 0 : path.length + (path.at(-1) === '\\' ? 0 : 1) + return out.trimEnd().split('\n').map(x => x.slice(slice).replaceAll('\\', '/')) +} + 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`. diff --git a/src/util-files.ts b/src/util-files.ts index 5af2085e6..f61ce2138 100644 --- a/src/util-files.ts +++ b/src/util-files.ts @@ -6,7 +6,6 @@ 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 @@ -72,26 +71,14 @@ export function adjustStaticPathForGlob(path: string) { return glob.escapePath(path.replace(/\\/g, '/')) } -// wrapper adding a few features: hidden files, onlyFiles and onlyFolders -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 skip = await getItemsToSkip(path) - for await (const entry of createDirStream(path, depth)) { + for await (const entry of createDirStream(path, { depth, hidden })) { const dirent = entry as DirStreamEntry if (dirent.isDirectory() ? onlyFiles : (onlyFolders || !dirent.isFile())) continue - if (skip?.includes(entry.path)) continue yield dirent } - - 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, '/')); - } } export async function unzip(stream: Readable, cb: (path: string) => Promisable) { diff --git a/src/vfs.ts b/src/vfs.ts index 3362a2f08..1e045d456 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -298,7 +298,7 @@ export async function* walkNode(parent: VfsNode, { try { let lastDir = prefixPath.slice(0, -1) || '.' parentsCache.set(lastDir, parent) - for await (const entry of dirStream(source, { depth, onlyFolders })) { + for await (const entry of dirStream(source, { depth, onlyFolders, hidden: false })) { if (ctx?.req.aborted) return const {path} = entry From 32e670eaaf8c95b50694e224db95725af57b0601 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 1 Sep 2024 23:52:38 +0200 Subject: [PATCH 066/234] admin/options: show_hidden_files (Windows only) --- admin/src/OptionsPage.ts | 8 +++++--- src/vfs.ts | 23 +++++------------------ 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index 4ab277702..8bde18a54 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -63,6 +63,7 @@ export default function OptionsPage() { sm: 4, } const httpsEnabled = values.https_port >= 0 + const isWindows = status?.platform === 'win32' return h(Form, { sx: { maxWidth: '60em' }, values, @@ -194,13 +195,14 @@ export default function OptionsPage() { 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: '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" }, + isWindows && { k: 'show_hidden_files', comp: BoolField, sm: 4, md: 3, helperText: "Showing makes search faster" }, + { 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/src/vfs.ts b/src/vfs.ts index 1e045d456..587660327 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' @@ -30,6 +15,8 @@ import { expandUsername } from './perm' import { getCurrentUsername } from './auth' import { Stats } from 'node:fs' +const showHiddenFiles = defineConfig('show_hidden_files', false) + type Masks = Record export interface VfsNodeStored extends VfsPerms { @@ -298,7 +285,7 @@ export async function* walkNode(parent: VfsNode, { try { let lastDir = prefixPath.slice(0, -1) || '.' parentsCache.set(lastDir, parent) - for await (const entry of dirStream(source, { depth, onlyFolders, hidden: false })) { + for await (const entry of dirStream(source, { depth, onlyFolders, hidden: showHiddenFiles.get() })) { if (ctx?.req.aborted) return const {path} = entry From 7d64d0ea37d116650d399eb61943169f70149065 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 3 Sep 2024 10:28:58 +0200 Subject: [PATCH 067/234] fix: (regression beta) block button in monitoring/logs was not working anymore --- src/adminApis.ts | 3 ++- src/block.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/adminApis.ts b/src/adminApis.ts index c082e92f3..8bb109543 100644 --- a/src/adminApis.ts +++ b/src/adminApis.ts @@ -150,7 +150,8 @@ export const adminApis = { string_undefined: { comment, expire }, object_undefined: { merge }, }) - addBlock({ ip, expire, comment }, merge) + const optionals = _.pickBy({ expire, comment }, v => v !== undefined) // passing undefined-s would override values in merge + addBlock({ ip, ...optionals }, merge) return {} } diff --git a/src/block.ts b/src/block.ts index 7aa01158c..d2bd86f01 100644 --- a/src/block.ts +++ b/src/block.ts @@ -37,8 +37,8 @@ setInterval(() => { // twice a minute, check if any block has expired export function addBlock(rule: BlockingRule, merge?: Partial) { block.set(was => { - const found = merge && _.findIndex(was, merge) - return found ? was.map((x, i) => i === found ? { ...x, ...rule, ip: `${x.ip}|${rule.ip}` } : x) - : [...was, { ...merge, ...rule }] + 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 From 651c65cb1c0e19d823c6d19a24d51ea8773a11c7 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 3 Sep 2024 10:30:04 +0200 Subject: [PATCH 068/234] plugins: changed parameters of event attemptingLogin --- plugins/antibrute/plugin.js | 2 +- src/api.auth.ts | 2 +- src/const.ts | 2 +- src/middlewares.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/antibrute/plugin.js b/plugins/antibrute/plugin.js index 9ed0ecde0..ab71f7466 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/src/api.auth.ts b/src/api.auth.ts index f0a74f703..e6d96f607 100644 --- a/src/api.auth.ts +++ b/src/api.auth.ts @@ -19,7 +19,7 @@ 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) + await events.emitAsync('attemptingLogin', { ctx, username }) 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 diff --git a/src/const.ts b/src/const.ts index daafb827d..b3bb874d8 100644 --- a/src/const.ts +++ b/src/const.ts @@ -7,7 +7,7 @@ import { mkdirSync } from 'fs' import { basename, dirname, join } from 'path' export * from './cross-const' -export const API_VERSION = 9 +export const API_VERSION = 9.1 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' diff --git a/src/middlewares.ts b/src/middlewares.ts index f6b8f91c9..34a0a88c6 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -127,7 +127,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => { async function doLogin(u: string, p: string) { if (!u || u === ctx.session?.username) return // providing credentials, but not needed - await events.emitAsync('attemptingLogin', ctx) + await events.emitAsync('attemptingLogin', { ctx, username: u }) const a = await srpCheck(u, p) if (a) { await setLoggedIn(ctx, a.username) From 499488b7325182a0be0ba9cb232c4035429a2fbd Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 4 Sep 2024 11:49:29 +0200 Subject: [PATCH 069/234] ux: admin/options: helper text for allowed_referer --- admin/src/OptionsPage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index 8bde18a54..6295436c0 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -150,7 +150,8 @@ export default function OptionsPage() { 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, autoRowHeight: true, fields: [ From e7e43e0fc840e2f14a979d1924173059de2b0610 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 4 Sep 2024 14:31:02 +0200 Subject: [PATCH 070/234] new config allow_session_ip_change (no UI yet) --- config.md | 1 + src/middlewares.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/config.md b/config.md index e766264cd..0f781c2b1 100644 --- a/config.md +++ b/config.md @@ -121,6 +121,7 @@ Configuration can be done in several ways 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. +- `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. - `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) diff --git a/src/middlewares.ts b/src/middlewares.ts index 34a0a88c6..5ea5a742c 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -16,6 +16,7 @@ import session from 'koa-session' import { app } from './index' import events from './events' +const allowSessionIpChange = defineConfig('allow_session_ip_change', false) const forceHttps = defineConfig('force_https', true) const ignoreProxies = defineConfig('ignore_proxies', false) export const sessionDuration = defineConfig('session_duration', Number(process.env.SESSION_DURATION) || DAY/1000, @@ -48,9 +49,8 @@ export const headRequests: Koa.Middleware = async (ctx, next) => { let proxyDetected: undefined | Koa.Context 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) + if (ss?.username && (!allowSessionIpChange.get() || !ctx.secure && allowSessionIpChange.get() === 'https')) if (!ss.ip) ss.ip = ctx.ip else if (ss.ip !== ctx.ip) { From ccca4cd5529c9feacd4e580db0f7c861fc1c624b Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 4 Sep 2024 16:39:46 +0200 Subject: [PATCH 071/234] fix: faulty upload of files with # in the name #722 --- admin/src/addFiles.ts | 8 ++++---- frontend/src/fileMenu.ts | 2 +- frontend/src/upload.ts | 4 ++-- src/cross.ts | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) 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/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 481545b9c..8e123ba5d 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -79,7 +79,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (Falsy 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('/', ' / ')) }, diff --git a/frontend/src/upload.ts b/frontend/src/upload.ts index c61f88505..c0f6c6da8 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, } from './misc' import _ from 'lodash' import { INTERNAL_Snapshot, proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' @@ -335,7 +335,7 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) { let uploadPath = path(toUpload.file) if (toUpload.name) uploadPath = prefix('', dirname(uploadPath), '/') + toUpload.name - req.open('PUT', to + encodeURI(uploadPath) + buildUrlQueryString({ + req.open('PUT', to + pathEncode(uploadPath) + buildUrlQueryString({ notificationChannel, ...resume && { resume: String(resume) }, ...toUpload.comment && { comment: toUpload.comment }, diff --git a/src/cross.ts b/src/cross.ts index 373cf74ab..66fa5b384 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -398,6 +398,7 @@ 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, encodeURIComponent) } From eb4dfbfba29f8ba246e8d00d80088f582fa29b11 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 7 Sep 2024 11:40:37 +0200 Subject: [PATCH 072/234] plugins: config.type 'vfs_path' --- admin/src/ArrayField.ts | 4 +++- admin/src/InstalledPlugins.ts | 2 ++ dev-plugins.md | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/admin/src/ArrayField.ts b/admin/src/ArrayField.ts index e6dd065f8..047659e84 100644 --- a/admin/src/ArrayField.ts +++ b/admin/src/ArrayField.ts @@ -13,7 +13,9 @@ import { Center, IconBtn } from './mui' 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) { - const rows = useMemo(() => (value||[]).map((x,$idx) => + 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 = { diff --git a/admin/src/InstalledPlugins.ts b/admin/src/InstalledPlugins.ts index a9782625d..ead59eeeb 100644 --- a/admin/src/InstalledPlugins.ts +++ b/admin/src/InstalledPlugins.ts @@ -15,6 +15,7 @@ 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') @@ -177,6 +178,7 @@ const type2comp = { multiselect: MultiSelectField, array: ArrayField, real_path: FileField, + vfs_path: VfsPathField, username: UsernameField, } diff --git a/dev-plugins.md b/dev-plugins.md index 466a6b0f0..4725cd2c6 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -125,7 +125,7 @@ used must be strictly JSON (thus, no single quotes, only double quotes for strin ### 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. @@ -628,12 +628,13 @@ If you want to override a text regardless of the language, use the special langu ## API version history -- 9 (v0.54.0) +- 9.1 (v0.54.0) - frontend event: showPlay - api.addBlock - api.misc - frontend event: paste - exports.customRest + HFS.customRestCall + - config.type: vfs_path - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip From 86ab563633ba75242b5c478667a83a37556032cc Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 7 Sep 2024 12:21:33 +0200 Subject: [PATCH 073/234] plugins: frontend event 'sortCompare' --- dev-plugins.md | 12 ++++++++++-- frontend/src/useFetchList.ts | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index 4725cd2c6..8898238b0 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -295,8 +295,9 @@ 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. @@ -381,6 +382,12 @@ This is a list of available frontend-events, with respective object parameter an - `uriChanged` - 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: Entry, b: Entry }` + - output `number | undefined` - All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place: - `afterMenuBar` between menu-bar and breadcrumbs - `afterList` at the end of the files list @@ -635,6 +642,7 @@ If you want to override a text regardless of the language, use the special langu - frontend event: paste - exports.customRest + HFS.customRestCall - config.type: vfs_path + - frontend event: sortCompare - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/src/useFetchList.ts b/frontend/src/useFetchList.ts index 88fa67020..000c20437 100644 --- a/frontend/src/useFetchList.ts +++ b/frontend/src/useFetchList.ts @@ -145,7 +145,8 @@ 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) From 103749bb8aa7b2b33b54fd59fabbf09b254ad5b7 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 7 Sep 2024 14:06:13 +0200 Subject: [PATCH 074/234] admin/home: better animation for errors and warnings --- admin/src/HomePage.ts | 2 +- shared/api.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index 9782ed20c..98de850ab 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -180,7 +180,7 @@ 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' } }), - h('span', { style: ['warning', 'error'].includes(color) ? { animation: '1s blink' } : undefined }, + h('span', { style: ['warning', 'error'].includes(color) ? { animation: '.5s blink 2' } : undefined }, ...content) ) } diff --git a/shared/api.ts b/shared/api.ts index 9c44cf08e..0d9086772 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -114,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 From 522147b128c61ede36a1d409beb76d7a1f988548 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 2 Sep 2024 15:14:30 +0200 Subject: [PATCH 075/234] fix: admin/options: bad layout of "block" on mobile --- admin/src/ArrayField.ts | 32 +++++++++++++++++++++++--------- admin/src/DataTable.ts | 14 ++++++++++---- admin/src/OptionsPage.ts | 10 +++++++--- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/admin/src/ArrayField.ts b/admin/src/ArrayField.ts index 047659e84..eae48769d 100644 --- a/admin/src/ArrayField.ts +++ b/admin/src/ArrayField.ts @@ -4,12 +4,13 @@ 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, autoRowHeight?: boolean } export function ArrayField({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, autoRowHeight, ...rest }: ArrayFieldProps) { @@ -19,7 +20,7 @@ export function ArrayField({ label, helperText, fields, value, 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() @@ -27,12 +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, ...autoRowHeight && { getRowHeight: () => 'auto' as const }, - sx: { '.MuiDataGrid-virtualScroller': { minHeight: '3em' }, + 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: { @@ -56,6 +59,7 @@ 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, } }), @@ -96,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) }) } }), @@ -107,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/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/OptionsPage.ts b/admin/src/OptionsPage.ts index 6295436c0..598564366 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -155,8 +155,11 @@ export default function OptionsPage() { { k: 'block', label: false, comp: ArrayField, prepend: true, sm: true, autoRowHeight: true, fields: [ - { k: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: 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', @@ -165,8 +168,9 @@ export default function OptionsPage() { toField: (x: any) => !x, fromField: (x: any) => x ? undefined : true, sm: 6, + $width: 80, }, - { k: 'comment' }, + { k: 'comment', $hideUnder: 'sm' }, ], }, From 7468e3102ab02cba91252c3ab6df9014365d7481 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 7 Sep 2024 15:27:25 +0200 Subject: [PATCH 076/234] fix: each login was not replacing but adding a timer for the session --- admin/src/LoginRequired.ts | 5 ++--- frontend/src/login.ts | 5 ++--- shared/index.ts | 11 +++++++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/admin/src/LoginRequired.ts b/admin/src/LoginRequired.ts index 29b93aa3e..f4a209bea 100644 --- a/admin/src/LoginRequired.ts +++ b/admin/src/LoginRequired.ts @@ -69,8 +69,7 @@ async function login(username: string, password: string) { // login was successful, update state state.loginRequired = false - sessionRefresher(res) + refreshSession(res) } -const sessionRefresher = makeSessionRefresher(state) -sessionRefresher(getHFS().session) +const refreshSession = makeSessionRefresher(state) diff --git a/frontend/src/login.ts b/frontend/src/login.ts index 909f4f06e..a080d087d 100644 --- a/frontend/src/login.ts +++ b/frontend/src/login.ts @@ -16,7 +16,7 @@ async function login(username:string, password:string) { const stopWorking = working() return srpClientSequence(username, password, apiCall).then(res => { stopWorking() - sessionRefresher(res) + refreshSession(res) state.loginRequired = false return res }, (err: any) => { @@ -28,8 +28,7 @@ async function login(username:string, password:string) { }) } -const sessionRefresher = makeSessionRefresher(state) -sessionRefresher(getHFS().session) +const refreshSession = makeSessionRefresher(state) export function logout() { return apiCall('logout', {}, { modal: working }).catch(res => { diff --git a/shared/index.ts b/shared/index.ts index 09f4daf33..a1527e318 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -104,15 +104,22 @@ export function getPrefixUrl() { } export function makeSessionRefresher(state: any) { - return function sessionRefresher(response: any) { + let timeout: any + const initial = getHFS().session + refreshSession(initial) + return refreshSession + + function refreshSession(response: 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'])) if (!response.username || !exp) return const delta = new Date(exp).getTime() - Date.now() const t = _.clamp(delta - 30_000, 4_000, 600_000) console.debug('session refresh in', Math.round(t / 1000)) - setTimeout(() => apiCall('refresh_session').then(sessionRefresher), t) + clearTimeout(timeout) + timeout = setTimeout(() => apiCall('refresh_session').then(refreshSession), t) } } From 96a39b7539648023cefcbfc055988250a1a9d695 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 7 Sep 2024 16:36:08 +0200 Subject: [PATCH 077/234] HFS.userBelongsTo --- dev-plugins.md | 5 ++++- frontend/src/state.ts | 2 ++ shared/index.ts | 3 ++- src/api.auth.ts | 6 ++++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index 8898238b0..481ca2286 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -268,7 +268,9 @@ 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`. +- `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. 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. @@ -643,6 +645,7 @@ If you want to override a text regardless of the language, use the special langu - exports.customRest + HFS.customRestCall - config.type: vfs_path - frontend event: sortCompare + - HFS.userBelongsTo - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/src/state.ts b/frontend/src/state.ts index cacdbed9a..adfa9d2e5 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -38,7 +38,9 @@ export const state = proxy({ + expandedUsername: [], searchOptions: { wild: true }, uploadOnExisting: getHFS().dontOverwriteUploading ? 'rename' : 'skip', uri: '', diff --git a/shared/index.ts b/shared/index.ts index a1527e318..d4aca05bd 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -27,6 +27,7 @@ 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, }) @@ -113,7 +114,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/src/api.auth.ts b/src/api.auth.ts index e6d96f607..cbe17ef27 100644 --- a/src/api.auth.ts +++ b/src/api.auth.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 { 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' @@ -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, From bdc6d6bb90a303b639e4dc8b49c7b89d99c8354e Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 8 Sep 2024 09:52:27 +0200 Subject: [PATCH 078/234] plugins: configDialog.maxWidth now supports also css values --- admin/src/InstalledPlugins.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/admin/src/InstalledPlugins.ts b/admin/src/InstalledPlugins.ts index ead59eeeb..7067457e2 100644 --- a/admin/src/InstalledPlugins.ts +++ b/admin/src/InstalledPlugins.ts @@ -104,7 +104,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)) From eec81149fc70564e52eebe94d9f7bbad88eee771 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 8 Sep 2024 23:23:59 +0200 Subject: [PATCH 079/234] plugins: HFS.DirEntry --- dev-plugins.md | 28 ++++++++++++++++------------ frontend/src/misc.ts | 16 +++++----------- src/serveGuiAndSharedFiles.ts | 2 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index 481ca2286..9bee55563 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -270,7 +270,10 @@ The HFS objects contains many properties: - `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. + 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. @@ -306,9 +309,9 @@ This is a list of available frontend-events, with respective object parameter an - `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 }` + - parameter `{ entry: DirEntry }` - The `Entry` type is an object with the following properties: + 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. @@ -320,21 +323,21 @@ This is a list of available frontend-events, with respective object parameter an - `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 + - `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) + - 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) + - 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) + - 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 @@ -345,7 +348,7 @@ This is a list of available frontend-events, with respective object parameter an - `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[] }` + - parameter `{ entry: DirEntry, menu: FileMenuEntry[], props: FileMenuProp[] }` - output `undefined | FileMenuEntry | FileMenuEntry[]` ```typescript interface FileMenuEntry { @@ -370,11 +373,11 @@ This is a list of available frontend-events, with respective object parameter an or if you like lodash, you can simply `HFS._.remove(menu, { id: 'show' })` - `fileShow` - you receive an entry of the list, and optionally produce React Component for visualization. - - parameter `{ entry: Entry }` (refer above for Entry object) + - 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: Entry, setCover(uri: string), meta: { title, album, artist, year } }` + - parameter `{ entry: DirEntry, setCover(uri: string), meta: { title, album, artist, year } }` - `menuZip` - parameter `{ def: ReactNode }` - output `Html` @@ -388,7 +391,7 @@ This is a list of available frontend-events, with respective object parameter an - 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: Entry, b: Entry }` + - parameter `{ a: DirEntry, b: DirEntry }` - output `number | undefined` - All of the following have no parameters and you are supposed to output `Html` that will be displayed in the described place: - `afterMenuBar` between menu-bar and breadcrumbs @@ -646,6 +649,7 @@ If you want to override a text regardless of the language, use the special langu - config.type: vfs_path - frontend event: sortCompare - HFS.userBelongsTo + - HFS.DirEntry - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/src/misc.ts b/frontend/src/misc.ts index d3b6d88c0..b097f0894 100644 --- a/frontend/src/misc.ts +++ b/frontend/src/misc.ts @@ -8,7 +8,7 @@ import { Callback, Dict, domOn, getHFS, Html, HTTP_MESSAGES, useBatch } from '@h 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' @@ -61,24 +61,18 @@ export function hfsEvent(name: string, params?:Dict) { }) } -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) }, customRestCall(name: string, ...rest: any[]) { return apiCall(cross.PLUGIN_CUSTOM_REST_PREFIX + name, ...rest) - } -} -Object.assign(getHFS(), { - ...tools, - emit: hfsEvent, - getNotifications, - debounceAsync, - useSnapState, + }, html: (html: string) => h(Html, {}, html), onEvent(name: string, cb: (params:any, extra: { output: any[], preventDefault: Callback }, output: any[]) => any) { const key = 'hfs.' + name diff --git a/src/serveGuiAndSharedFiles.ts b/src/serveGuiAndSharedFiles.ts index a5f97ea73..e4d1370cd 100644 --- a/src/serveGuiAndSharedFiles.ts +++ b/src/serveGuiAndSharedFiles.ts @@ -106,7 +106,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => { : !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)) { From c4a8d89098b390a8c69c7339b617e3592c36664e Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Mon, 9 Sep 2024 16:02:54 +0200 Subject: [PATCH 080/234] langs/tr --- src/langs/embedded.ts | 3 +- src/langs/hfs-lang-tr.json | 176 +++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/langs/hfs-lang-tr.json diff --git a/src/langs/embedded.ts b/src/langs/embedded.ts index b6c899fa1..53729cd25 100644 --- a/src/langs/embedded.ts +++ b/src/langs/embedded.ts @@ -15,5 +15,6 @@ import de from './hfs-lang-de.json' import fi from './hfs-lang-fi.json' import hu from './hfs-lang-hu.json' import ja from './hfs-lang-ja.json' +import tr from './hfs-lang-tr.json' -export default { it, zh, ru, sr, ko, ms, 'zh-tw': zh_tw, fr, 'pt-br': pt_br, vi, es, nl, el, de, fi, hu, ja } \ No newline at end of file +export default { it, zh, ru, sr, ko, ms, 'zh-tw': zh_tw, fr, 'pt-br': pt_br, vi, es, nl, el, de, fi, hu, ja, tr } \ No newline at end of file diff --git a/src/langs/hfs-lang-tr.json b/src/langs/hfs-lang-tr.json new file mode 100644 index 000000000..06284bf09 --- /dev/null +++ b/src/langs/hfs-lang-tr.json @@ -0,0 +1,176 @@ +{ + "author": "cbr00t", + "version": 1.0, + "hfs_version": "0.53.0", + "translate": { + "Select": "Seç", + "n_files": "{n,plural,one{# dosya} other{# dosya}}", + "n_folders": "{n,plural,one{# klasör} other{# klasör}}", + "filter_count": "{n,plural, one{# filtrelendi} other{# filtrelendi}}", + "select_count": "{n,plural, one{# seçildi} other{# seçildi}}", + "filter_placeholder": "Aşağıdaki listeyi filtrelemek için buraya yazın", + "Select some files": "Dosyaları Seç", + "zip_checkboxes": "Doyaları seçmek için onay kutularını kullanın, sonra tekrar ZIP yapın", + "zip_tooltip_selected": "Seçilen öğeleri tekil ZIP dosyası olarak indir", + "zip_tooltip_whole": "Tüm listeyi tekil ZIP dosyası olarak indir (filtrelenmemiş). Eğer bazı öğeleri seçerseniz, sadece onlar dahil edilecek.", + "zip_confirm_search": "Aramadaki TÜM SONUÇLAR, ZIP Arşivi olarak indirilsin mi?", + "zip_confirm_folder": "TÜM KLASÖR, ZIP Arşivi olarak indirilsin mi?", + "select_tooltip": "Yapılan seçim \"ZIP\" ve \"Sil\" işlemlerine uygulanır (mümkünse) ancak ayrıca listeye filtre uygulayabilirsiniz", + "delete_hint": "Silmek için önce SEÇ butonuna tıklayın", + "delete_confirm": "{n,plural, one{# öğeyi} other{# öğeyi}} Delete?", + "delete_completed": "{n} öğe SİLİNDİ", + "delete_failed": ", {n,plural, one{# başarısız} other{# başarısız}}", + "delete_select": "SİLİNECEK Öğeleri seç", + "Delete": "SİL", + "Options": "Ayarlar", + "Search": "Arama Yap", + "Zip": "Zip", + "search_msg": "Bu Klasörde ve Alt Klasörlerde arama yap", + "Searching": "Aranıyor", + "Searched": "Arama Bitti", + "Clear search": "Aramayı temizle", + "Interrupted": "Yarıda kesildi", + "stopped_before": "Herhangi birşey bulmadan önce durduruldu", + "empty_list": "Burada birşey yok", + "filter_none": "Filtreye uygun eşleşme yok", + + "Admin-panel": "Admin Paneli", + "Login": "Giriş Yap", + "Username": "Kullanıcı", + "Password": "Şifre", + "login_untrusted": "Oturum Açma iptal: Sunucu kimliğine güvenilmedi", + "login_bad_credentials": "Hatalı oturum bilgileri", + "login_bad_cookies": "Çerezler (Cookies) çalışmıyor - Oturum Açma başarısız", + "User panel": "Kullanıcı Paneli", + "Change password": "Şifre Değiştir", + "enter_pass": "Yeni Şifreyi Gir", + "enter_pass2": "Yeni Şifreyi (TEKRAR) Gir", + "pass2_mismatch": "İkinci girilen şifre, ilki ile eşleşmedi. Süreç iptal.", + "password_changed": "Şifre değiştirildi!", + "Logout": "Oturum Kapat", + "connection error": "İletişim sorunu", + "Full timestamp:": "Tam Zaman Damgası:", + "Search was interrupted": "Arama yarıda kesildi", + "Stop list": "Listelemeyi Durdur", + + "download_starting": "indirme işlemi şimdi başlayacak", + "wrong_account": "{u} Kullanıcı Hesabının erişimi yok, başka bir kullanıcı ile deneyiniz", + "no_upload_here": "Bu klasöre UPLOAD yetkiniz yok", + "Create folder": "Klasör Oluştur", + "Pick files": "Dosya(lar)ı Seç", + "Pick folder": "Klasör Seç", + "send_files": "{n,plural,one{# file} other{# files}} dosyayı gönder (UPLOAD), {size}", + "Clear": "Temizle", + "failed_upload": "{name} UPLOAD edilemedi (veri aktarım sorunu)", + "confirm_resume": "UPLOAD kaldığı yerden devam etsin mi?", + "file too large": "dosya çok büyük", + "Enter folder name": "Klasör ismi girin", + "Successfully created": "Başarıyla oluşturuldu", + "enter_folder": "Klasöre Gir", + "folder_exists": "Aynı isimde başka bir Klasör zaten var", + + "Sort by:": "Sırala: {by}", + "name": "İsim", + "extension": "Uzantı", + "size": "Boyut", + "time": "Zaman", + "Invert order": "Ters Sıra", + "Folders first": "Önce Klasörler", + "Numeric names": "Sayısal İsimler", + "theme:": "tema:", + "auto": "otomatik", + "light": "aydınlık (light)", + "dark": "karanlık (dark)", + "parent folder": "Üst Klasör", + "home": "Ev (Home)", + + "Continue": "Devam", + "Confirm": "Onay", + "Don't": "YAPMA", + "Warning": "Uyarı", + "Error": "Hata", + "Info": "Bilgi", + + "Unauthorized": "Yetki Yok", + "Forbidden": "Yasak", + "Not found": "Bulunamadı", + "Server error": "Sunucu hatası", + + "Upload": "Upload", + "upload_concluded": "Upload concluded:", + "upload_finished": "{n} tamamlandı ({size})", + "upload_errors": "{n} başarısız", + "upload_file_rejected": "Bazı dosyalar kabul edilmiyor", + + "File menu": "Dosya Menüsü", + "Folder menu": "Klasör Menüsü", + "Name": "İsim", + "file_open": "Aç", + "Download": "İndir (Download)", + "Missing permission": "Eksik Yetki", + "Reload": "Yeniden Yükle", + "Get list": "Listeyi Al", + "Skip existing files": "Mevcut Dosyaları Atla", + "Size": "Boyut", + "Timestamp": "Zaman Damgası", + "Show": "Göster", + "Loading failed": "Yükleme başarısız", + "Rename": "Yeniden Adlandır", + "Tiles mode:": "Döşeme (Tiles) modu:", + "off": "kapalı", + "Operation successful": "İşlem başarılı", + "Uploader": "Yükleyici (Uploader)", + "Download counter": "İndirme sayacı", + "Switch zoom mode": "Zoom moduna geç", + "Full screen": "Tam Ekran", + + "File Show help": "Dosya Yardım göster", + "showHelpMain": "Bazı eylemler için klavyeyi kullanabilirsiniz:", + "showHelp_←/→": "←/→", + "showHelp_↑/↓": "↑/↓", + "showHelp_space": "boşluk", + "showHelp_←/→_body": "Önceki/Sonraki Dosyaya Git", + "showHelp_↑/↓_body": "Uzun Resimleri scroll yap", + "Destination": "Hedef", + "in_queue": "{n} kuyrukta", + "enter_comment": "{name} için Açıklama (Comment)", + "Comment": "Açıklama (Comment)", + "upload_dd_hint": "Dosyaları buradaki Dosya Listesine Sürükle&Bırak da yapabilirsiniz", + "Upload not available": "Upload imkanı yok", + "Cut": "Kes", + "n_items": "{n,plural, one{# öğe} other{# öğe}}", + "good_bad": "{good} taşındı, {bad} başarısız", + "after_cut": "Seçim şuan Panoda (Clipboard). \nHedef Klasöre gidin ve Yapıştır (Paste) yapın.", + "Cancel clipboard": "Panoyu (Clipboard) Temizle", + "to_clipboard_source": "Kaynak Klasöre Geri Dön", + "Paste": "Yapıştır (Paste)", + "clipboard_list": "Panodaki (Clipboard) öğeler:", + + "Close": "Kapat", + "Folder": "Klasör", + "Web page": "Web Sayfası", + "Link": "Link", + "Auto-play": "Oto-Oynat", + "autoplay_seconds": "Resimler için bekle (saniye)", + "Select all": "Tümünü Seç", + "go_first": "İlk Öğeye git", + "go_last": "Son Öğeye git", + "Shuffle": "Karıştır (Shuffle)", + "Repeat": "Tekrarlar (Repeat)", + "showHelpListShortcut": "Dosya Listesinde {key} tuşuna basılı durumda tıklayarak, hızlıca Göster yapabilirsiniz", + "Invalid value": "Geçersiz değer", + "upload_skipped": "{n} atlandı", + "Overwrite policy": "Üzerine Yazma politikası", + "Rename to avoid overwriting": "Üzerine Yazmadan kaçınmak için Yeniden Adlandır", + "Overwrite existing files": "Mevcut Dosyaların üzerine yaz", + "Menu": "Menü", + + "clipboard": "Pano/Clipboard ({content})", + "to_clipboard_source_tooltip": "Pano içeriklerinin konumlandığı klasöre gidiniz", + "more_items": "{n} daha fazla öğe", + "Show details": "Detayları göster", + "upload_conflict": "zaten var", + "Logged in": "Oturum Açıldı", + "Logged out": "Oturum Kapandı" + } +} From ba8bafac133e1bef66493a409fcd5341dd626da3 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Tue, 10 Sep 2024 23:47:17 +0200 Subject: [PATCH 081/234] fix: file-show wasn't closing after click on folder inside file-menu --- frontend/src/show.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/show.ts b/frontend/src/show.ts index 5a3ad830e..311c9b0de 100644 --- a/frontend/src/show.ts +++ b/frontend/src/show.ts @@ -22,6 +22,7 @@ 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', @@ -29,6 +30,13 @@ export function fileShow(entry: DirEntry, { startPlaying=false } = {}) { 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) From 985f9f950432f5925099b794d5d9f349feac51bd Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 11 Sep 2024 23:51:04 +0200 Subject: [PATCH 082/234] langs/th --- src/langs/embedded.ts | 3 +- src/langs/hfs-lang-th.json | 176 +++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/langs/hfs-lang-th.json diff --git a/src/langs/embedded.ts b/src/langs/embedded.ts index 53729cd25..d3ca64f70 100644 --- a/src/langs/embedded.ts +++ b/src/langs/embedded.ts @@ -16,5 +16,6 @@ import fi from './hfs-lang-fi.json' import hu from './hfs-lang-hu.json' import ja from './hfs-lang-ja.json' import tr from './hfs-lang-tr.json' +import th from './hfs-lang-th.json' -export default { it, zh, ru, sr, ko, ms, 'zh-tw': zh_tw, fr, 'pt-br': pt_br, vi, es, nl, el, de, fi, hu, ja, tr } \ No newline at end of file +export default { it, zh, ru, sr, ko, ms, 'zh-tw': zh_tw, fr, 'pt-br': pt_br, vi, es, nl, el, de, fi, hu, ja, tr, th } \ No newline at end of file diff --git a/src/langs/hfs-lang-th.json b/src/langs/hfs-lang-th.json new file mode 100644 index 000000000..ce9c73df2 --- /dev/null +++ b/src/langs/hfs-lang-th.json @@ -0,0 +1,176 @@ +{ + "author": "Soonthorn Srichomkwan", + "version": 1.0, + "hfs_version": "0.53.0", + "translate": { + "Select": "เลือก", + "n_files": "{n} ไฟล์", + "n_folders": "{n} โฟลเดอร์", + "filter_count": "{n} ผ่าน", + "select_count": "{n} เลือก", + "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\" (เมื่อมีให้ใช้งาน) แต่คุณสามารถกรองรายการได้เช่นกัน", + "delete_hint": "หากต้องการลบ ให้คลิกเลือกก่อน", + "delete_confirm": "ต้องการลบ {n} รายการ ใช่หรือไม่?", + "delete_completed": "การลบ: {n} เสร็จสิ้น", + "delete_failed": ", {n} ล้มเหลว", + "delete_select": "เลือกบางอย่างเพื่อลบ", + "Delete": "ลบ", + "Options": "ตัวเลือก", + "Search": "ค้นหา", + "Zip": "Zip", + "search_msg": "ค้นหาโฟลเดอร์นี้และโฟลเดอร์ย่อย", + "Searching": "กำลังค้นหา", + "Searched": "ค้นหาแล้ว", + "Clear search": "ล้างการค้นหา", + "Interrupted": "ถูกขัดจังหวะ", + "stopped_before": "หยุดก่อนที่จะพบอะไร", + "empty_list": "ไม่พบอะไร", + "filter_none": "ไม่มีการตรงกันสำหรับตัวกรองนี้", + + "Admin-panel": "แผงควบคุมผู้ดูแลระบบ", + "Login": "เข้าสู่ระบบ", + "Username": "ชื่อผู้ใช้", + "Password": "รหัสผ่าน", + "login_untrusted": "การเข้าสู่ระบบถูกยกเลิก: ไม่สามารถเชื่อถือตัวตนของเซิร์ฟเวอร์ได้", + "login_bad_credentials": "ข้อมูลรับรองความถูกต้องไม่ถูกต้อง", + "login_bad_cookies": "คุกกี้ไม่ทำงาน - เข้าสู่ระบบล้มเหลว", + "User panel": "แผงผู้ใช้", + "Change password": "เปลี่ยนรหัสผ่าน", + "enter_pass": "ใส่รหัสผ่านใหม่", + "enter_pass2": "ใส่รหัสผ่านใหม่ซ้ำ", + "pass2_mismatch": "รหัสผ่านที่สองที่คุณป้อนไม่ตรงกับรหัสผ่านแรก ขั้นตอนถูกยกเลิก", + "password_changed": "รหัสผ่านเปลี่ยนแล้ว", + "Logout": "ออกจากระบบ", + "connection error": "ข้อผิดพลาดในการเชื่อมต่อ", + "Full timestamp:": "แสดงเวลาเต็ม", + "Search was interrupted": "การค้นหาถูกขัดจังหวะ", + "Stop list": "หยุดรายการ", + + "download_starting": "การดาวน์โหลดของคุณควรเริ่มต้นได้แล้ว", + "wrong_account": "บัญชี {u} ไม่มีสิทธิ์เข้าถึง ลองใช้บัญชีอื่น", + "no_upload_here": "ไม่มีสิทธิ์อัปโหลดสำหรับโฟลเดอร์ปัจจุบัน", + "Create folder": "สร้างโฟลเดอร์", + "Pick files": "เลือกไฟล์", + "Pick folder": "เลือกโฟลเดอร์", + "send_files": "Send {n,plural,one{# file} other{# files}}, {size}", + "Clear": "ล้าง", + "failed_upload": "ไม่สามารถอัปโหลด {name}", + "confirm_resume": "ดำเนินการอัปโหลดต่อหรือไม่?", + "file too large": "ไฟล์ใหญ่เกินไป", + "Enter folder name": "ใส่ชื่อโฟลเดอร์", + "Successfully created": "สร้างสำเร็จ", + "enter_folder": "ใส่โฟลเดอร์", + "folder_exists": "โฟลเดอร์ที่มีชื่อเดียวกันมีอยู่แล้ว", + + "Sort by:": "จัดเรียงโดย: {by}", + "name": "ชื่อ", + "extension": "นามสกุล", + "size": "ขนาด", + "time": "เวลา", + "Invert order": "สลับลำดับ", + "Folders first": "โฟลเดอร์ก่อน", + "Numeric names": "ชื่อตัวเลข", + "theme:": "theme:", + "auto": "อัตโนมัติ", + "light": "สว่าง", + "dark": "มืด", + "parent folder": "โฟลเดอร์หลัก", + "home": "หน้าแรก", + + "Continue": "ดำเนินการต่อ", + "Confirm": "ยืนยัน", + "Don't": "ไม่", + "Warning": "คำเตือน", + "Error": "ข้อผิดพลาด", + "Info": "ข้อมูล", + + "Unauthorized": "ไม่ได้รับอนุญาต", + "Forbidden": "ห้าม", + "Not found": "ไม่พบ", + "Server error": "ข้อผิดพลาดของเซิร์ฟเวอร์", + + "Upload": "อัปโหลด", + "upload_concluded": "การอัปโหลดเสร็จสิ้น:", + "upload_finished": "{n} เสร็จสิ้น ({size})", + "upload_errors": "{n} ล้มเหลว", + "upload_file_rejected": "บางไฟล์ไม่ได้รับการยอมรับ", + + "File menu": "เมนูไฟล์", + "Folder menu": "เมนูโฟลเดอร์", + "Name": "ชื่อ", + "file_open": "เปิด", + "Download": "ดาวน์โหลด", + "Missing permission": "ไม่มีสิทธิ์", + "Reload": "โหลดใหม่", + "Get list": "รับรายการ", + "Skip existing files": "ข้ามไฟล์ที่มีอยู่", + "Size": "ขนาด", + "Timestamp": "แสดงเวลา", + "Show": "แสดง", + "Loading failed": "การโหลดล้มเหลว", + "Rename": "เปลี่ยนชื่อ", + "Tiles mode:": "Tiles mode:", + "off": "ปิด", + "Operation successful": "การดำเนินงานประสบความสำเร็จ", + "Uploader": "โปรแกรมอัปโหลด", + "Download counter": "ตัวนับการดาวน์โหลด", + "Switch zoom mode": "สลับโหมดซูม", + "Full screen": "เต็มหน้า", + + "File Show help": "แสดงความช่วยเหลือไฟล์", + "showHelpMain": "คุณสามารถใช้คีย์บอร์ดสำหรับบางการกระทำ:", + "showHelp_←/→": "←/→", + "showHelp_↑/↓": "↑/↓", + "showHelp_space": "space", + "showHelp_←/→_body": "ไปยังไล์ก่อนหน้า/ถัดไป", + "showHelp_↑/↓_body": "Scroll tall images", + "Destination": "ปลายทาง", + "in_queue": "{n} ในคิว", + "enter_comment": "ความคิดเห็นสำหรับ {name}", + "Comment": "ความคิดเห็น", + "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": "เวปเพจ", + "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": "ออกจากระบบ" + } +} From 963dd7aa2351e8060e3283f9c4faca4b66e17755 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 12 Sep 2024 09:41:28 +0200 Subject: [PATCH 083/234] nicer: too much shadow for light theme --- mui-grid-form/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mui-grid-form/index.ts b/mui-grid-form/index.ts index 6183f6905..c5f188e87 100644 --- a/mui-grid-form/index.ts +++ b/mui-grid-form/index.ts @@ -183,7 +183,7 @@ export function Form({ 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) }, From e6b9a19fc4668513e0f7db703b51cfe4a5de1e49 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 12 Sep 2024 23:59:17 +0200 Subject: [PATCH 084/234] warn admins about hidden-files slowdown --- src/dirStream.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/dirStream.ts b/src/dirStream.ts index 0ac7d1462..08d4ecd38 100644 --- a/src/dirStream.ts +++ b/src/dirStream.ts @@ -4,7 +4,7 @@ import { runCmd } from './util-os' import { IS_WINDOWS } from './const' import { join } from 'path' import { Readable } from 'stream' -import { pendingPromise } from './cross' +import { DAY, pendingPromise } from './cross' import { Stats, Dirent } from 'node:fs' export interface DirStreamEntry extends Dirent { @@ -95,9 +95,16 @@ export function createDirStream(startPath: string, { depth=0, hidden=true }) { } } +let lastNotice = 0 async function getWindowsHiddenFiles(path: string, depth=false) { + const t = Date.now() const out = await runCmd('dir', ['/ah', '/b', depth ? '/s' : '/c', path.replaceAll('/', '\\')]) // cannot pass '', so we pass /c as a noop parameter .catch(()=>'') // error in case of no matching file + const now = Date.now() + if ((now - t) > 10_000 && (now - lastNotice) > DAY) { + lastNotice = now + console.log("A file list was heavily delayed. You can avoid this by enabling the option to show hidden files.") + } const slice = !depth ? 0 : path.length + (path.at(-1) === '\\' ? 0 : 1) return out.trimEnd().split('\n').map(x => x.slice(slice).replaceAll('\\', '/')) } From bd5330dd9e43f6539a2d86ab1bc9d82c23044a63 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 14 Sep 2024 10:17:26 +0200 Subject: [PATCH 085/234] plugins: frontend event 'appendMenuBar' --- dev-plugins.md | 2 ++ frontend/src/menu.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/dev-plugins.md b/dev-plugins.md index 9bee55563..10adab9a1 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -394,6 +394,7 @@ This is a list of available frontend-events, with respective object parameter an - parameter `{ a: DirEntry, b: DirEntry }` - output `number | undefined` - 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) @@ -650,6 +651,7 @@ If you want to override a text regardless of the language, use the special langu - frontend event: sortCompare - HFS.userBelongsTo - HFS.DirEntry + - frontend event: appendMenuBar - 8.891 (v0.53.0) - api.openDb - frontend event: menuZip diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts index 7bd08e77d..2bffe62f0 100644 --- a/frontend/src/menu.ts +++ b/frontend/src/menu.ts @@ -118,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`, ')')), From b0d65b8ab6cda4c5f37ad6f9942cadd852d4ab32 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 14 Sep 2024 19:59:20 +0200 Subject: [PATCH 086/234] fix: updated vulnerable lib path-to-regexp --- package-lock.json | 238 ++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 116 insertions(+), 124 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d4fa4bbb..b14d5bb6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "mui-grid-form" ], "dependencies": { - "@koa/router": "^12.0.1", + "@koa/router": "^13.0.1", "@node-rs/crc32": "^1.6.0", "@rejetto/kvstorage": "^0.12.2", "acme-client": "^5.3.1", @@ -86,7 +86,7 @@ "@mui/icons-material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.15", - "@mui/x-data-grid": "^6.19.11", + "@mui/x-data-grid": "^6.20.4", "@mui/x-date-pickers": "^6.19.9", "@mui/x-tree-view": "^6.17.0", "dayjs": "^1.11.10", @@ -110,6 +110,36 @@ "vite": "^5.0.12" } }, + "admin/node_modules/@mui/x-data-grid": { + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.20.4.tgz", + "integrity": "sha512-I0JhinVV4e25hD2dB+R6biPBtpGeFrXf8RwlMPQbr9gUggPmPmNtWKo8Kk2PtBBMlGtdMAgHWe7PqhmucUxU1w==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/utils": "^5.14.16", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@mui/material": "^5.4.1", + "@mui/system": "^5.4.1", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, + "admin/node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + }, "frontend": { "name": "@hfs/frontend", "dependencies": { @@ -2117,9 +2147,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -2872,18 +2902,16 @@ } }, "node_modules/@koa/router": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.1.tgz", - "integrity": "sha512-ribfPYfHb+Uw3b27Eiw6NPqjhIhTpVFzEWLwyc/1Xp+DCdwRRyIlAUODX+9bPARF6aQtUu1+/PHzdNvRzcs/+Q==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.0.1.tgz", + "integrity": "sha512-3NKqQt8pKjTKUBVnQx/E980rB6IyERd8QruImdxIVM2vb8TJWKYPnesw+mfElV/3wmdrc/rWk60Rs41Prr4XgQ==", "dependencies": { - "debug": "^4.3.4", "http-errors": "^2.0.0", "koa-compose": "^4.1.0", - "methods": "^1.1.2", - "path-to-regexp": "^6.2.1" + "path-to-regexp": "^8.1.0" }, "engines": { - "node": ">= 12" + "node": ">= 18" } }, "node_modules/@koa/router/node_modules/depd": { @@ -3163,11 +3191,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.14", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", - "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "version": "7.2.16", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.16.tgz", + "integrity": "sha512-qI8TV3M7ShITEEc8Ih15A2vLzZGLhD+/UPNwck/hcls2gwg7dyRjNGXcQYHKLB5Q7PuTRfrTkAoPa2VV1s67Ag==", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3176,14 +3204,16 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", - "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz", + "integrity": "sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==", "dependencies": { "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.2.0" + "react-is": "^18.3.1" }, "engines": { "node": ">=12.0.0" @@ -3202,31 +3232,6 @@ } } }, - "node_modules/@mui/x-data-grid": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.20.1.tgz", - "integrity": "sha512-x1muWWIG9otkk4FuvoTxH3I4foyA1caFu8ZC9TvMQ+7NSBKcfy/JeLQfKkZk8ACUUosvENdrRIkhqU2xdIqIVg==", - "dependencies": { - "@babel/runtime": "^7.23.2", - "@mui/utils": "^5.14.16", - "clsx": "^2.0.0", - "prop-types": "^15.8.1", - "reselect": "^4.1.8" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui" - }, - "peerDependencies": { - "@mui/material": "^5.4.1", - "@mui/system": "^5.4.1", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - } - }, "node_modules/@mui/x-date-pickers": { "version": "6.20.1", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.20.1.tgz", @@ -4201,9 +4206,9 @@ "dev": true }, "node_modules/@types/prop-types": { - "version": "15.7.11", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/qs": { "version": "6.9.7", @@ -4941,9 +4946,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -6630,14 +6635,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7286,9 +7283,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.1.0.tgz", + "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", + "engines": { + "node": ">=16" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -7787,9 +7787,9 @@ } }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "node_modules/react-router": { "version": "6.23.1", @@ -8123,11 +8123,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, - "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" - }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -10738,9 +10733,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", "requires": { "regenerator-runtime": "^0.14.0" } @@ -11170,7 +11165,7 @@ "@mui/icons-material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.15", - "@mui/x-data-grid": "^6.19.11", + "@mui/x-data-grid": "^6.20.4", "@mui/x-date-pickers": "^6.19.9", "@mui/x-tree-view": "^6.17.0", "@types/react": "^18.2.48", @@ -11190,6 +11185,25 @@ "vite": "^5.0.12", "watch-size": "^2.0.0", "web-vitals": "^2.1.4" + }, + "dependencies": { + "@mui/x-data-grid": { + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.20.4.tgz", + "integrity": "sha512-I0JhinVV4e25hD2dB+R6biPBtpGeFrXf8RwlMPQbr9gUggPmPmNtWKo8Kk2PtBBMlGtdMAgHWe7PqhmucUxU1w==", + "requires": { + "@babel/runtime": "^7.23.2", + "@mui/utils": "^5.14.16", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + } + }, + "reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + } } }, "@hfs/frontend": { @@ -11280,15 +11294,13 @@ } }, "@koa/router": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.1.tgz", - "integrity": "sha512-ribfPYfHb+Uw3b27Eiw6NPqjhIhTpVFzEWLwyc/1Xp+DCdwRRyIlAUODX+9bPARF6aQtUu1+/PHzdNvRzcs/+Q==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.0.1.tgz", + "integrity": "sha512-3NKqQt8pKjTKUBVnQx/E980rB6IyERd8QruImdxIVM2vb8TJWKYPnesw+mfElV/3wmdrc/rWk60Rs41Prr4XgQ==", "requires": { - "debug": "^4.3.4", "http-errors": "^2.0.0", "koa-compose": "^4.1.0", - "methods": "^1.1.2", - "path-to-regexp": "^6.2.1" + "path-to-regexp": "^8.1.0" }, "dependencies": { "depd": { @@ -11412,32 +11424,22 @@ } }, "@mui/types": { - "version": "7.2.14", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", - "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "version": "7.2.16", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.16.tgz", + "integrity": "sha512-qI8TV3M7ShITEEc8Ih15A2vLzZGLhD+/UPNwck/hcls2gwg7dyRjNGXcQYHKLB5Q7PuTRfrTkAoPa2VV1s67Ag==", "requires": {} }, "@mui/utils": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", - "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", + "version": "5.16.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz", + "integrity": "sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==", "requires": { "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", - "prop-types": "^15.8.1", - "react-is": "^18.2.0" - } - }, - "@mui/x-data-grid": { - "version": "6.20.1", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.20.1.tgz", - "integrity": "sha512-x1muWWIG9otkk4FuvoTxH3I4foyA1caFu8ZC9TvMQ+7NSBKcfy/JeLQfKkZk8ACUUosvENdrRIkhqU2xdIqIVg==", - "requires": { - "@babel/runtime": "^7.23.2", - "@mui/utils": "^5.14.16", - "clsx": "^2.0.0", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "reselect": "^4.1.8" + "react-is": "^18.3.1" } }, "@mui/x-date-pickers": { @@ -12112,9 +12114,9 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.11", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "@types/qs": { "version": "6.9.7", @@ -12650,9 +12652,9 @@ } }, "clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==" + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" }, "co": { "version": "4.6.0", @@ -13899,11 +13901,6 @@ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" - }, "micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -14362,9 +14359,9 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.1.0.tgz", + "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==" }, "path-type": { "version": "4.0.0", @@ -14736,9 +14733,9 @@ } }, "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "react-router": { "version": "6.23.1", @@ -14996,11 +14993,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, - "reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" - }, "resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", diff --git a/package.json b/package.json index 61a61d41e..2757b0863 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ ] }, "dependencies": { - "@koa/router": "^12.0.1", + "@koa/router": "^13.0.1", "@node-rs/crc32": "^1.6.0", "@rejetto/kvstorage": "^0.12.2", "acme-client": "^5.3.1", From c185f868078e4059f194ab54747d72a5e2ccffee Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 14 Sep 2024 20:04:14 +0200 Subject: [PATCH 087/234] better code --- frontend/src/BrowseFiles.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/src/BrowseFiles.ts b/frontend/src/BrowseFiles.ts index d4c806b26..3070a037e 100644 --- a/frontend/src/BrowseFiles.ts +++ b/frontend/src/BrowseFiles.ts @@ -64,12 +64,15 @@ function FilesList() { const total = theList.length const nPages = Math.ceil(total / pageSize) - 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 => @@ -94,7 +97,7 @@ 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) => { if (pleaseGoBottom) setGoBottom(true) if (i < page || i > page + extraPages) @@ -127,7 +130,7 @@ function FilesList() { current: page + scrolledPages, atBottom, pageSize, - pageChange, + changePage, }) ) } @@ -137,9 +140,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 +156,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 +164,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')), ) }) From 8e19099a1190ff7e699abbda124e0f8d5720bdc7 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 18 Sep 2024 21:59:53 +0200 Subject: [PATCH 088/234] langs/uk --- src/langs/embedded.ts | 3 +- src/langs/hfs-lang-uk.json | 170 +++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/langs/hfs-lang-uk.json diff --git a/src/langs/embedded.ts b/src/langs/embedded.ts index d3ca64f70..2b11a97a9 100644 --- a/src/langs/embedded.ts +++ b/src/langs/embedded.ts @@ -17,5 +17,6 @@ import hu from './hfs-lang-hu.json' import ja from './hfs-lang-ja.json' import tr from './hfs-lang-tr.json' import th from './hfs-lang-th.json' +import uk from './hfs-lang-uk.json' -export default { it, zh, ru, sr, ko, ms, 'zh-tw': zh_tw, fr, 'pt-br': pt_br, vi, es, nl, el, de, fi, hu, ja, tr, th } \ No newline at end of file +export default { it, zh, ru, sr, ko, ms, 'zh-tw': zh_tw, fr, 'pt-br': pt_br, vi, es, nl, el, de, fi, hu, ja, tr, th, uk } \ No newline at end of file diff --git a/src/langs/hfs-lang-uk.json b/src/langs/hfs-lang-uk.json new file mode 100644 index 000000000..da1a3e7ee --- /dev/null +++ b/src/langs/hfs-lang-uk.json @@ -0,0 +1,170 @@ +{ +"author": "m.ksy", +"version": 1.0, +"hfs_version": "0.53.0", +"translate": { +"Select": "Вибір", +"n_files": "{n,plural, one{# файл} few{# файли} many{# файлів}}", +"n_folders": "{n,plural, one{# тека} few{# теки} many{# тек}}", +"filter_count": "{n,plural, one{# елемент} few{# елементи} many{# елементів}}", +"select_count": "{n,plural, one{Вибрано #} other{Вибрано #}}", +"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": "Дозволяє вибрати файли для скачування або видалення, а також використовувати фільтри для швидкого пошуку", +"delete_hint": "Для видалення файлів спочатку натисніть Вибір", +"delete_confirm": "Видалити {n,plural, one{# елемент} few{# елементи} many{# елементів}}?", +"delete_completed": "{n,plural, one{Видалений} other{Видалено}} {n,plural, one{# елемент} few{# елементи} many{# елементів}}", +"delete_failed": ", {n} неуспішно", +"delete_select": "Виділіть елементи для видалення", +"Delete": "Видалити", +"Options": "Налаштування", +"Search": "Пошук", +"Zip": "Zip", +"search_msg": "Пошук у поточній та вкладених теках", +"Searching": "Іде пошук", +"Searched": "Результати пошуку", +"Clear search": "Скасувати пошук", +"Interrupted": "Перервано", +"stopped_before": "Пошук зупинено", +"empty_list": "Тут нічого немає", +"filter_none": "Жоден елемент не відповідає фільтру", +"Admin-panel": "Панель адміністратора", +"Login": "Вхід", +"Username": "Ім'я користувача", +"Password": "Пароль", +"login_untrusted": "Вхід скасовано: особистість сервера не має довіри", +"login_bad_credentials": "Неправильне ім'я користувача або пароль", +"login_bad_cookies": "Не вдалося увійти - відключені cookies", +"User panel": "Панель користувача", +"Change password": "Змінити пароль", +"enter_pass": "Введіть новий пароль", +"enter_pass2": "Повторіть пароль", +"pass2_mismatch": "Введені паролі не збігаються", +"password_changed": "Пароль успішно змінено", +"Logout": "Вийти", +"connection error": "Помилка з'єднання", +"Full timestamp:": "Дата і час: ", +"Search was interrupted": "Пошук перервано", +"Stop list": "Зупинити", +"upload_starting": "Завантаження почалося", +"wrong_account": "Користувач {u} не має доступу до цієї теки", +"no_upload_here": "Немає доступу до завантаження файлів в цій теці", +"Create folder": "Створити теку", +"Pick files": "Вибрати файли", +"Pick folder": "Вибрати теку", +"send_files": "Завантажити {n,plural, one{# файл} few{# файли} many{# файлів}}, {size}", +"Clear": "Очистити", +"failed_upload": "Не вдалося завантажити {name}", +"confirm_resume": "Відновити завантаження?", +"file too large": "занадто великий файл", +"Enter folder name": "Введіть назву теки", +"Successfully created": "Успішно створено", +"enter_folder": "Перейти до теки", +"folder_exists": "Тека вже існує", +"Sort by:": "Сортування за: {by}", +"name": "назвою", +"extension": "розширенням", +"size": "розміром", +"time": "часом", +"Invert order": "Інвертувати", +"Folders first": "Спочатку теки", +"Numeric names": "Спочатку цифри", +"theme:": "Тема:", +"auto": "авто", +"light": "світла", +"dark": "темна", +"parent folder": "Батьківська тека", +"home": "Коренева тека", +"Continue": "Продовжити", +"Confirm": "Підтвердити", +"Don't": "Ні", +"Warning": "Увага", +"Error": "Помилка", +"Info": "Інформація", +"Unauthorized": "Вхід не виконано", +"Forbidden": "Заборонено", +"Not found": "Не знайдено", +"Server error": "Помилка сервера", +"Upload": "Завантажити", +"upload_concluded": "Завантаження закінчено:", +"upload_finished": "{n,plural, one{# файл завантажено} few{# файли завантажено} many{# файлів завантажено}} ({size})", +"upload_errors": "{n,plural, one{# файл} few{# файли} many{# файлів}} неуспішно", +"upload_file_rejected": "Деякі файли не були прийняті", +"File menu": "Меню файлу", +"Folder menu": "Меню теки", +"Name": "Назва", +"file_open": "Відкрити", +"Download": "Завантажити", +"Missing permission": "Відсутній доступ", +"Reload": "Оновити", +"Get list": "Отримати список", +"Skip existing files": "Пропуск існуючих файлів", +"Size": "Розмір", +"Timestamp": "Дата", +"Show": "Перегляд файлу", +"Loading failed": "Не вдалося завантажити", +"Rename": "Перейменувати", +"Tiles mode:": "Плитки:", +"off": "вимкнено", +"Operation successful": "Операція успішна", +"Uploader": "Завантажувач", +"Download counter": "Лічильник завантажень", +"Switch zoom mode": "Переключити збільшення", +"Full screen": "Повний екран", +"File Show help": "Допомога", +"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": "Коментар для {name}", +"Comment": "Коментувати", +"upload_dd_hint": "Ви можете завантажити файли, перетягнувши їх до списку файлів", +"Upload not available": "Завантаження недоступне", +"Cut": "Вирізати", +"n_items": "{n,plural, one{# елемент} few{# елементи} many{# елементів}}", +"good_bad": "{good} {good,plural, one{переміщений} other{переміщено}}, {bad} неуспішно", +"after_cut": "Вибране скопійовано до буфера обміну.\nПерейдіть до теки призначення для вставки.", +"Cancel clipboard": "Очистити буфер обміну", +"to_clipboard_source": "У теку", +"Paste": "Вставити", +"clipboard_list": "Елементи в буфері обміну:", +"Close": "Закрити", +"Folder": "тека", +"Web page": "Веб адреса", +"Link": "Посилання", +"Auto-play": "Автовідтворення", +"autoplay_seconds": "Скільки секунд чекати між зображеннями", +"Select all": "Вибрати все", +"go_first": "До першого елемента", +"go_last": "До останнього елемента", +"Shuffle": "Перемішати", +"Repeat": "Повтор", +"showHelpListShortcut": "Зі списку файлів натисніть {key} щоб швидко відкрити Перегляд файлу", +"Invalid value": "Недійсне значення", +"upload_skipped": "{n,plural, one{# пропущено} other{# пропущено}}", +"Overwrite policy": "Правило перезапису", +"Rename to avoid overwriting": "Перейменувати, щоб уникнути перезапису", +"Overwrite existing files": "Перезаписати існуючі файли", +"Menu": "Меню", +"clipboard": "Буфер обміну ({content})", +"to_clipboard_source_tooltip": "Перейти до теки, де знаходиться вміст з буфера обміну", +"more_items": "Ще {rest} елементів", +"Show details": "Показати деталі", +"upload_conflict": "вже існує", +"Logged in": "Вхід виконано", +"Logged out": "Вихід виконано" +} +} \ No newline at end of file From 0fe1c381275ec45f81085f0f13a70114e969a21c Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 18 Sep 2024 22:01:05 +0200 Subject: [PATCH 089/234] better english --- src/const.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/const.ts b/src/const.ts index b3bb874d8..e0d7e1374 100644 --- a/src/const.ts +++ b/src/const.ts @@ -47,7 +47,7 @@ if (dir) { } process.chdir(dir) } -console.log('cwd', process.cwd()) +console.log('working directory', process.cwd()) if (APP_PATH !== process.cwd()) console.log('app', APP_PATH) console.log('node', process.version) From 46116b1fc92e3730b8ea601827ed865a1f0d2fbb Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 18 Sep 2024 22:18:21 +0200 Subject: [PATCH 090/234] forgot to document some backend events --- dev-plugins.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev-plugins.md b/dev-plugins.md index 10adab9a1..38c37b5d5 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -449,7 +449,10 @@ This section is still partially documented, and you may need to have a look at t - called just before trying to delete a file or folder (which still may not exist and fail) - async supported - stoppable +- `login` - `logout` +- `attemptingLogin` +- `failedLogin` - `config ready` - `config.KEY` where KEY is the key of a config that has changed - `connectionClosed` @@ -670,6 +673,7 @@ If you want to override a text regardless of the language, use the special langu - exports.customHtml - more functions in HFS.misc - frontend event 'entry' can now ask to skip an entry + - backend events: login attemptingLogin failedLogin - 8.72 (v0.52.0) - HFS.toast - HFS.misc functions From 50384f4aa2c9a8e1afa915e808a9417b921760b9 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 18 Sep 2024 22:18:48 +0200 Subject: [PATCH 091/234] attemptingLogin.via --- src/middlewares.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/middlewares.ts b/src/middlewares.ts index 5ea5a742c..722bbd69f 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -112,7 +112,7 @@ 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() { @@ -120,14 +120,14 @@ export const prepareState: Koa.Middleware = async (ctx, next) => { 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, username: u }) + await events.emitAsync('attemptingLogin', { ctx, username: u, via }) const a = await srpCheck(u, p) if (a) { await setLoggedIn(ctx, a.username) From 1664e195d51eb29b624666e8f620c1566361a1bd Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 18 Sep 2024 22:40:47 +0200 Subject: [PATCH 092/234] new config authorization_header (no UI) --- config.md | 3 ++- src/middlewares.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/config.md b/config.md index 0f781c2b1..9c05c284e 100644 --- a/config.md +++ b/config.md @@ -120,8 +120,9 @@ 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. - `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. +- `authorization_header` support Authentication HTTP header. Default is true. - `create-admin` special entry to quickly create an admin account. The value will be set as password. As soon as the account is created, this entry is removed. #### Virtual File System (VFS) diff --git a/src/middlewares.ts b/src/middlewares.ts index 722bbd69f..f13f053ed 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -19,6 +19,7 @@ import events from './events' const allowSessionIpChange = defineConfig('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) @@ -116,7 +117,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => { } 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(':') From 4c17f4f94d83db20628544a31a60e08b70095e66 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Wed, 18 Sep 2024 23:07:38 +0200 Subject: [PATCH 093/234] new config split_uploads --- admin/src/OptionsPage.ts | 8 ++-- frontend/src/upload.ts | 82 ++++++++++++++++++++++++---------------- src/cross.ts | 2 +- src/serveGuiFiles.ts | 2 + src/upload.ts | 1 + 5 files changed, 58 insertions(+), 37 deletions(-) diff --git a/admin/src/OptionsPage.ts b/admin/src/OptionsPage.ts index 598564366..bec9545b9 100644 --- a/admin/src/OptionsPage.ts +++ b/admin/src/OptionsPage.ts @@ -192,11 +192,13 @@ export default function OptionsPage() { 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, fromField: x => x * 1E6, toField: x => x ? x / 1E6 : null, min: 0, step: .1, + 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" }), diff --git a/frontend/src/upload.ts b/frontend/src/upload.ts index c0f6c6da8..dab7a497f 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, pathEncode, + buildUrlQueryString, randomId, HTTP_MESSAGES, pathEncode, pendingPromise, } from './misc' import _ from 'lodash' import { INTERNAL_Snapshot, proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' @@ -310,38 +310,54 @@ 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) - 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 + pathEncode(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)) + const splitSize = getHFS().splitUploads || Infinity + 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 + 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 { + offset += splitSize + if (offset < fullSize) return // continue looping + done() + } + if (!resuming) + next() + offset = fullSize // stop looping + } + req.onerror = () => { + error(0) + finished.resolve() + } + 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 + req.open('PUT', to + pathEncode(uploadPath) + buildUrlQueryString({ + notificationChannel, + ...offset + splitSize < fullSize && { 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, offset + splitSize)) + await finished + } while (offset < fullSize) async function subscribeNotifications() { if (notificationChannel) return diff --git a/src/cross.ts b/src/cross.ts index 66fa5b384..a98ef8039 100644 --- a/src/cross.ts +++ b/src/cross.ts @@ -25,7 +25,7 @@ export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time'] export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' } export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url', 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips', - 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address']) + 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads']) export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' } export type Dict = Record export type Falsy = false | null | undefined | '' | 0 diff --git a/src/serveGuiFiles.ts b/src/serveGuiFiles.ts index 505dfe548..dd05d7e32 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 @@ -106,6 +107,7 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) { loadScripts: Object.fromEntries(mapPlugins((p, id) => [id, p.frontend_js?.map(f => f.includes('//') ? f : pub + id + '/' + f)])), prefixUrl: ctx.state.revProxyPath, dontOverwriteUploading: dontOverwriteUploading.get(), + splitUploads: splitUploads.get(), forceTheme: mapPlugins(p => _.isString(p.isTheme) ? p.isTheme : undefined).find(Boolean), customHtml: _.omit(getAllSections(), ['top', 'bottom', 'htmlHead', 'style']), // exclude the sections we already apply in this phase ...newObj(FRONTEND_OPTIONS, (v, k) => getConfig(k)), diff --git a/src/upload.ts b/src/upload.ts index 064d654e1..6b7b9276c 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -145,6 +145,7 @@ 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) { From 22ae8570f5cce72956174519ea315b320423802f Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 19 Sep 2024 15:29:37 +0200 Subject: [PATCH 094/234] searching, nested folders are now displayed like files, with a smaller font for their parent-folder --- frontend/src/BrowseFiles.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/BrowseFiles.ts b/frontend/src/BrowseFiles.ts index 3070a037e..f847d34a7 100644 --- a/frontend/src/BrowseFiles.ts +++ b/frontend/src/BrowseFiles.ts @@ -198,7 +198,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)) let className = isFolder ? 'folder' : 'file' if (entry.cantOpen) className += ' cant-open' @@ -228,7 +228,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', From 5a848d30fd845d906c4a15cfeea86ac830708a86 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 19 Sep 2024 16:06:29 +0200 Subject: [PATCH 095/234] admin/home: detect cloudflare and advice configuration --- admin/src/HomePage.ts | 12 +++++++----- src/adminApis.ts | 3 ++- src/middlewares.ts | 3 +++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/admin/src/HomePage.ts b/admin/src/HomePage.ts index 98de850ab..17f5dcbc3 100644 --- a/admin/src/HomePage.ts +++ b/admin/src/HomePage.ts @@ -4,7 +4,7 @@ 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' + replaceStringToReact, wait, with_, DAY } 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' @@ -54,15 +54,17 @@ export default function HomePage() { 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") ] ), 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, diff --git a/src/adminApis.ts b/src/adminApis.ts index 8bb109543..2a251f424 100644 --- a/src/adminApis.ts +++ b/src/adminApis.ts @@ -23,7 +23,7 @@ import { getConnections } from './connections' 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' @@ -128,6 +128,7 @@ export const adminApis = { 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(), frpDetected: localhostAdmin.get() && !getProxyDetected() && getConnections().every(isLocalHost) diff --git a/src/middlewares.ts b/src/middlewares.ts index f13f053ed..fffae3658 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -48,6 +48,7 @@ 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) const ss = ctx.session @@ -71,6 +72,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 From ba5a98e4ffd68b84b7c3e2d0c25c585d410099f0 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Thu, 19 Sep 2024 16:25:17 +0200 Subject: [PATCH 096/234] initial support for bun --- src/const.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/const.ts b/src/const.ts index e0d7e1374..37658111e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -23,8 +23,8 @@ 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' // we want this to be the first stuff to be printed, then we print it in this module, that is executed at the beginning @@ -51,5 +51,7 @@ console.log('working directory', process.cwd()) if (APP_PATH !== process.cwd()) console.log('app', APP_PATH) console.log('node', process.version) -console.log('platform', process.platform) +const bun = (globalThis as any).Bun +if (bun) console.log('bun', bun.version) +console.log('platform', process.platform, IS_BINARY ? 'binary' : basename(process.execPath)) console.log('pid', process.pid) From 1c9dc1c76541f73817ccf88a701baace90c3273b Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Fri, 20 Sep 2024 17:54:15 +0200 Subject: [PATCH 097/234] fix: error "undefined reading source" while uploading --- src/upload.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/upload.ts b/src/upload.ts index 6b7b9276c..7e2984463 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -63,8 +63,9 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) { else try { // refer to the source of the closest node that actually belongs to the vfs, so that cache is more effective - let closestVfsNode: typeof base | undefined = base - while (closestVfsNode && !closestVfsNode.original) closestVfsNode = closestVfsNode.parent + let closestVfsNode = base // if base=root, there's no parent and no original + while (closestVfsNode?.parent && !closestVfsNode.original) + closestVfsNode = closestVfsNode.parent! // if it's not original, it surely has a parent const statDir = closestVfsNode!.source! if (!Object.hasOwn(diskSpaceCache, statDir)) { const c = diskSpaceCache[statDir] = getDiskSpaceSync(statDir) From 5c5bbcd0b718a8029af2a63053942f8d73678c25 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Fri, 20 Sep 2024 23:37:27 +0200 Subject: [PATCH 098/234] search results now have space at each /, so that it's easier to read and wrapping of long lines is better --- frontend/src/BrowseFiles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/BrowseFiles.ts b/frontend/src/BrowseFiles.ts index f847d34a7..cbd3a3a4e 100644 --- a/frontend/src/BrowseFiles.ts +++ b/frontend/src/BrowseFiles.ts @@ -198,7 +198,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 - (isFolder ? 1 : 0)) + const containerName = n.slice(0, -name.length - (isFolder ? 1 : 0)).replaceAll('/', '/ ') let className = isFolder ? 'folder' : 'file' if (entry.cantOpen) className += ' cant-open' From 0d7fe4927457b954ce34c54eae20c8ceb3864ed5 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 10:40:55 +0200 Subject: [PATCH 099/234] fix: drag&drop to upload with firefox produces wrong folder structure #719 --- frontend/src/upload.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/upload.ts b/frontend/src/upload.ts index dab7a497f..42f142a01 100644 --- a/frontend/src/upload.ts +++ b/frontend/src/upload.ts @@ -481,10 +481,11 @@ export function acceptDropFiles(cb: false | undefined | ((files:File[], to: stri if (entry) (function recur(entry: FileSystemEntry, to = '') { if (entry.isFile) - (entry as FileSystemFileEntry).file(x => cb([x], to)) + (entry as FileSystemFileEntry).file(x => cb([x], x.webkitRelativePath ? '' : to)) // ff130 fills webkitRelativePath when dropping a folder, while chrome128 doesn't and we pass 'to' to preserve the structure else (entry as FileSystemDirectoryEntry).createReader?.().readEntries(entries => { + const newTo = to + entry.name + '/' for (const e of entries) - recur(e, to + entry.name + '/') + recur(e, newTo) }) })(entry) } From 255507ba65e72ab2905ea167186aabb37cedcaa1 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 11:18:53 +0200 Subject: [PATCH 100/234] fix: folders served as web-pages shouldn't be considered downloads #697 --- src/log.ts | 1 + src/serveFile.ts | 14 +++++++------- src/serveGuiAndSharedFiles.ts | 8 ++++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/log.ts b/src/log.ts index 24c71baaf..d4c8d39ab 100644 --- a/src/log.ts +++ b/src/log.ts @@ -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/serveFile.ts b/src/serveFile.ts index 2d8b751de..454860683 100644 --- a/src/serveFile.ts +++ b/src/serveFile.ts @@ -17,9 +17,9 @@ import { sendErrorPage } from './errorPages' import { Readable } from 'stream' 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) export async function serveFileNode(ctx: Koa.Context, node: VfsNode) { const { source, mime } = node @@ -28,7 +28,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 } @@ -41,8 +41,8 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) { 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.get('host') @@ -150,7 +150,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 e4d1370cd..052ec43ad 100644 --- a/src/serveGuiAndSharedFiles.ts +++ b/src/serveGuiAndSharedFiles.ts @@ -96,8 +96,12 @@ 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)) From 13a068ab46e421cb6cf9a606e5f2d62b6a55cac0 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 11:56:07 +0200 Subject: [PATCH 101/234] copy_files API (not used by UI, but you can install copy-files plugin) #680 --- dev-plugins.md | 5 +++-- src/apiMiddleware.ts | 3 ++- src/const.ts | 2 +- src/frontEndApis.ts | 39 ++++++++++++++++++++++++++------------- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/dev-plugins.md b/dev-plugins.md index 38c37b5d5..75d7d8f23 100644 --- a/dev-plugins.md +++ b/dev-plugins.md @@ -120,7 +120,8 @@ 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` +- `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 @@ -644,7 +645,7 @@ If you want to override a text regardless of the language, use the special langu ## API version history -- 9.1 (v0.54.0) +- 9.2 (v0.54.0) - frontend event: showPlay - api.addBlock - api.misc diff --git a/src/apiMiddleware.ts b/src/apiMiddleware.ts index f0d3eacb0..ccfbbfe29 100644 --- a/src/apiMiddleware.ts +++ b/src/apiMiddleware.ts @@ -14,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) diff --git a/src/const.ts b/src/const.ts index 37658111e..002e24dd4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -7,7 +7,7 @@ import { mkdirSync } from 'fs' import { basename, dirname, join } from 'path' export * from './cross-const' -export const API_VERSION = 9.1 +export const API_VERSION = 9.2 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' diff --git a/src/frontEndApis.ts b/src/frontEndApis.ts index d3db20602..de4d769a4 100644 --- a/src/frontEndApis.ts +++ b/src/frontEndApis.ts @@ -8,7 +8,8 @@ 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' @@ -117,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) }) From ba113081747494278076dcdd0d71c5f3497c87ca Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 12:40:56 +0200 Subject: [PATCH 102/234] toast for comments, instead of dialog #699 --- frontend/src/fileMenu.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 8e123ba5d..3a5612f7d 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -10,7 +10,7 @@ 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 { alertDialog, promptDialog, toast } from './dialog' import { apiCall, useApi } from '@hfs/shared/api' import { inputComment } from './upload' import { cut } from './clip' @@ -170,7 +170,7 @@ async function editComment(entry: DirEntry) { if (res === null) 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) { From 0db55a9fc1cb35d00b7dd8b3dda217301266a144 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 12:41:14 +0200 Subject: [PATCH 103/234] toast for rename, instead of dialog --- frontend/src/fileMenu.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 3a5612f7d..114e03150 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -147,22 +147,20 @@ 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) { From 5f8dcc2672d7186a00330dca6c6b155c8be17a8a Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 13:01:43 +0200 Subject: [PATCH 104/234] fix: error renaming current folder --- frontend/src/fileMenu.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 114e03150..3edbf4d33 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -150,7 +150,8 @@ async function rename(entry: DirEntry) { const MSG = t`Operation successful` if (uri === location.pathname) //current folder return alertDialog(MSG).then(() => - getHFS().navigate(uri + '../' + pathEncode(dest) + '/') ) + setTimeout(() => // after history.back() issued by closing the dialog + 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 From f7420e182f5f970725f5d65a3f7ffad2275ba293 Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sat, 21 Sep 2024 15:08:07 +0200 Subject: [PATCH 105/234] better code: avoid setTimeout --- admin/src/InternetPage.ts | 8 +++++++- frontend/src/fileMenu.ts | 3 +-- frontend/src/login.ts | 2 +- shared/dialogs.ts | 5 +++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/admin/src/InternetPage.ts b/admin/src/InternetPage.ts index c59e1f45b..853c09072 100644 --- a/admin/src/InternetPage.ts +++ b/admin/src/InternetPage.ts @@ -366,7 +366,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/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 3edbf4d33..114e03150 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -150,8 +150,7 @@ async function rename(entry: DirEntry) { const MSG = t`Operation successful` if (uri === location.pathname) //current folder return alertDialog(MSG).then(() => - setTimeout(() => // after history.back() issued by closing the dialog - getHFS().navigate(uri + '../' + pathEncode(dest) + '/') )) + 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 diff --git a/frontend/src/login.ts b/frontend/src/login.ts index a080d087d..a059497de 100644 --- a/frontend/src/login.ts +++ b/frontend/src/login.ts @@ -117,7 +117,7 @@ export async function loginDialog(closable=true, reloadAfter=true) { going = true try { const res = await login(usr, pwd) - close(true) + 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/shared/dialogs.ts b/shared/dialogs.ts index 304dd0c99..48a3c488f 100644 --- a/shared/dialogs.ts +++ b/shared/dialogs.ts @@ -203,7 +203,7 @@ export function newDialog(options: DialogOptions) { if (history.state?.$dialog === $id) options.closed = back() closeDialogAt(i, v) - return options + return options.closed } } @@ -227,7 +227,8 @@ 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) + Promise.resolve(d.closed).then(() => + d?.onClose?.(value)) return d } From 47f01bb007e3468f273d9ccc5bcb2623442ff42b Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 22 Sep 2024 12:27:59 +0200 Subject: [PATCH 106/234] fix: admin/options: "Links from other website" was not working correctly behind a proxy --- src/basicWeb.ts | 2 +- src/serveFile.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/basicWeb.ts b/src/basicWeb.ts index 3f9d42ae4..4cf54356b 100644 --- a/src/basicWeb.ts +++ b/src/basicWeb.ts @@ -42,7 +42,7 @@ export function basicWeb(ctx: Koa.Context, node: VfsNode) { stream.push(`${title.get()}`) stream.push(getSection('basicHeader')) const u = getCurrentUsername(ctx) - const links: Dict = u ? { [`//LOGOUT%00:@${ctx.get('host')}/?get=logout`]: `Logout (${u})` } : { '/?get=login': "Login" } + const links: Dict = u ? { [`//LOGOUT%00:@${ctx.host}/?get=logout`]: `Logout (${u})` } : { '/?get=login': "Login" } stream.push(_.map(links, (v,k) => a(k, v)).join(' ') + '\n