From 7aaa79c2c266c5f82e3519d9222622f962f1a81b Mon Sep 17 00:00:00 2001 From: Massimo Melina Date: Sun, 15 Oct 2023 14:23:50 +0200 Subject: [PATCH] inheritable "default" #377 --- admin/src/FileForm.ts | 19 +++++++++++++++---- frontend/src/fileMenu.ts | 4 ++-- frontend/src/state.ts | 7 +++++-- frontend/src/useFetchList.ts | 1 + shared/api.ts | 2 +- src/api.file_list.ts | 19 +++++++++---------- src/apiMiddleware.ts | 4 ++-- src/frontEndApis.ts | 2 +- src/middlewares.ts | 2 +- src/vfs.ts | 3 ++- tests/test.ts | 2 +- 11 files changed, 40 insertions(+), 25 deletions(-) diff --git a/admin/src/FileForm.ts b/admin/src/FileForm.ts index dab363f55..db6fb6f64 100644 --- a/admin/src/FileForm.ts +++ b/admin/src/FileForm.ts @@ -42,6 +42,14 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor setValues(Object.assign(objSameKeys(defaultPerms, () => null), rest)) }, [file]) //eslint-disable-line + const inheritedDefault = useMemo(() => { + let p = file.parent + while (p) { + if (p.default != null) + return p.default + p = p.parent + } + }, [file]) const { source } = file const isDir = file.type === 'folder' const hasSource = source !== undefined // we need a boolean @@ -50,6 +58,7 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor const showTimestamps = lg || hasSource const showSize = lg || (hasSource && !realFolder) const showAccept = file.accept! > '' || isDir && (file.can_upload ?? file.inherited?.can_upload) + const showWebsite = isDir const barColors = useDialogBarColors() const { data, element } = useApiEx<{ list: Account[] }>('get_accounts') @@ -107,11 +116,13 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes }, showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: showSize && 4, label: "Created", toField: formatTimestamp }, showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: showSize && 4, label: "Modified", toField: formatTimestamp }, - showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", xl: file.website ? 4 : 12, + showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", xl: showWebsite ? 4 : 12, helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip") }, - file.website && { k: 'default', comp: BoolField, label:"Serve index.html", xl: true, - toField: Boolean, fromField: (v:boolean) => v ? 'index.html' : null, - helperText: md("This folder may be a website because contains `index.html`. Enabling this will show the website instead of the list of files.") + showWebsite && { k: 'default', comp: BoolField, xl: true, + label: "Serve as website 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, toField: yaml.stringify, fromField: v => v ? yaml.parse(v) : undefined, diff --git a/frontend/src/fileMenu.ts b/frontend/src/fileMenu.ts index 48ca46aec..913fe3c60 100644 --- a/frontend/src/fileMenu.ts +++ b/frontend/src/fileMenu.ts @@ -30,7 +30,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe ...addToMenu.map(x => { if (x === 'open') { if (entry.cantOpen) return - const open = { id: 'open', icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder ? undefined : '_blank' } + const open = { id: 'open', icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder || entry.web ? undefined : '_blank' } return !isFolder ? open : h(Link, { to: uri, onClick: () => close() }, hIcon(open.icon), open.label) } if (x === 'delete') @@ -90,7 +90,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe href: '#', ..._.omit(e, ['label', 'icon', 'onClick']), async onClick(event: MouseEvent) { - if (!e.href) // even with #, the + if (!e.href) event.preventDefault() if (false !== await e.onClick?.(event)) close() diff --git a/frontend/src/state.ts b/frontend/src/state.ts index 6a001f681..1e603b69f 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -3,7 +3,7 @@ import _ from 'lodash' import { proxy, useSnapshot } from 'valtio' import { subscribeKey } from 'valtio/utils' -import { getHFS, hIcon } from './misc' +import { Dict, getHFS, hIcon } from './misc' export const state = proxy<{ stopSearch?: ()=>void, @@ -91,6 +91,7 @@ export class DirEntry { public readonly c?: string public readonly p?: string public readonly icon?: string + public readonly web?: true public comment?: string // we memoize these value for speed public readonly name: string @@ -100,10 +101,12 @@ export class DirEntry { public readonly t?:Date public readonly cantOpen: boolean - constructor(n: string, rest?: object) { + constructor(n: string, rest?: any) { Object.assign(this, rest) // we actually allow any custom property to be memorized this.n = n // must do it after rest to avoid overwriting this.uri = (n[0] === '/' ? '' : location.pathname) + pathEncode(this.n) + if (rest?.web) // this is actually a folder pointing to a default file, and it requires a final slash for correct handling + this.uri += '/' this.isFolder = this.n.endsWith('/') if (!this.isFolder) { const i = this.n.lastIndexOf('.') + 1 diff --git a/frontend/src/useFetchList.ts b/frontend/src/useFetchList.ts index 9c8f9cae0..93208fd02 100644 --- a/frontend/src/useFetchList.ts +++ b/frontend/src/useFetchList.ts @@ -74,6 +74,7 @@ export default function useFetchList() { case 'msg': state.loginRequired = false for (const entry of data) { + if (!Array.isArray(entry)) continue // unexpected const [op, par] = entry const error = op === 'error' && par if (error === 405) { // "method not allowed" happens when we try to directly access an unauthorized file, and we get a login prompt, and then get_file_list the file (because we didn't know it was file or folder) diff --git a/shared/api.ts b/shared/api.ts index ef9c92aed..d2b02d7d6 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -161,7 +161,7 @@ export async function getNotification(channel: string, cb: (name: string, data:a if (type === 'connected') return resolve(ret) if (type !== 'msg') return - for (const { name, data } of entries) + for (const [name, data] of entries) if (name) cb(name, data) }) diff --git a/src/api.file_list.ts b/src/api.file_list.ts index 14223dd7c..e9e041c8f 100644 --- a/src/api.file_list.ts +++ b/src/api.file_list.ts @@ -6,13 +6,12 @@ import { ApiError, ApiHandler, SendListReadable } from './apiMiddleware' import { stat } from 'fs/promises' import { mapPlugins } from './plugins' import { asyncGeneratorToArray, dirTraversal, pattern2filter, WHO_NO_ONE } from './misc' -import _ from 'lodash' import { HTTP_FOOL, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const' import Koa from 'koa' import { descriptIon, DESCRIPT_ION, getCommentFor } from './comments' import { basename } from 'path' -export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string } + export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean } export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c }, ctx) => { const node = await urlToNode(uri || '/', ctx) @@ -23,11 +22,7 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c return fail() if (dirTraversal(search)) return fail(HTTP_FOOL) - if (node.default) - return (list?.custom ?? _.identity)({ // sse will wrap the object in a 'custom' message, otherwise we plainly return the object - redirect: uri // tell the browser to access the folder (instead of using this api), so it will get the default file - }) - if (!await nodeIsDirectory(node)) + if (await hasDefaultFile(node) || !await nodeIsDirectory(node)) return fail(HTTP_METHOD_NOT_ALLOWED) offset = Number(offset) limit = Number(limit) @@ -92,13 +87,17 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c } } + async function hasDefaultFile(node: VfsNode) { + return node.default && await urlToNode(node.default, ctx, node) + } + async function nodeToDirEntry(ctx: Koa.Context, node: VfsNode): Promise { - let { source, default:def } = node + let { source } = node const name = getNodeName(node) if (!source) return name ? { n: name + '/' } : null - if (def) - return { n: name } + if (node.isFolder && await hasDefaultFile(node)) + return { n: name, web: true } try { const st = await stat(source) const folder = st.isDirectory() diff --git a/src/apiMiddleware.ts b/src/apiMiddleware.ts index 023244b46..08512591c 100644 --- a/src/apiMiddleware.ts +++ b/src/apiMiddleware.ts @@ -140,8 +140,8 @@ export class SendListReadable extends Readable { ready() { // useful to indicate the end of an initial phase, but we leave open for updates this._push(['ready']) } - custom(data: any) { - this._push(data) + custom(name: string, data: any) { + this._push([name, data]) } props(props: object) { this._push(['props', props]) diff --git a/src/frontEndApis.ts b/src/frontEndApis.ts index bcdbf533f..ef8674425 100644 --- a/src/frontEndApis.ts +++ b/src/frontEndApis.ts @@ -27,7 +27,7 @@ export const frontEndApis: ApiHandlers = { list.ready() // on chrome109 EventSource doesn't emit 'open' until something is sent return list.events(ctx, { [NOTIFICATION_PREFIX + channel](name, data) { - list.custom({ name, data }) + list.custom(name, data) } }) }, diff --git a/src/middlewares.ts b/src/middlewares.ts index bc70e9cf4..622832d24 100644 --- a/src/middlewares.ts +++ b/src/middlewares.ts @@ -115,7 +115,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => { return serveFile(ctx, favicon.get()) let node = await urlToNode(path, ctx) if (node?.default && (path.endsWith('/') || !node.default.match(/\.html?$/i))) // final/ needed on browser to make resource urls correctly - node = await urlToNode(node.default, ctx, node) + node = await urlToNode(node.default, ctx, node) ?? node if (!node) return sendErrorPage(ctx, HTTP_NOT_FOUND) if (ctx.method === 'POST') { // curl -F upload=@file url/ diff --git a/src/vfs.ts b/src/vfs.ts index 6c4ebe591..9afb1bd9f 100644 --- a/src/vfs.ts +++ b/src/vfs.ts @@ -19,7 +19,7 @@ export interface VfsNode extends VfsPerms { name?: string source?: string children?: VfsNode[] - default?: string + default?: string | false // we could have used empty string to override inherited default, but false is clearer, even reading the yaml, and works well with pickProps(), where empty strings are removed mime?: string | Record rename?: Record masks?: Masks // express fields for descendants that are not in the tree @@ -60,6 +60,7 @@ function inheritFromParent(parent: VfsNode, child: VfsNode) { else child.mime ??= parent.mime child.accept ??= parent.accept + child.default ??= parent.default return child } diff --git a/tests/test.ts b/tests/test.ts index d49b142a9..ad85a88d7 100644 --- a/tests/test.ts +++ b/tests/test.ts @@ -27,7 +27,7 @@ describe('basics', () => { //before(async () => appStarted) it('frontend', req('/', //, { headers: { accept: '*/*' } })) // workaround: 'accept' is necessary when running server-for-test-dev, still don't know why it('force slash', req('/f1', 302)) - it('list', reqList('/f1/', { inList:['f2/', 'page'] })) + it('list', reqList('/f1/', { inList:['f2/', 'page/'] })) it('search', reqList('f1', { inList:['f2/'], outList:['page'] }, { search:'2' })) it('search root', reqList('/', { inList:['cantListPage/'], outList:['cantListPage/page/'] }, { search:'page' })) it('download', req('/f1/f2/alfa.txt', { re:/abcd/, mime:'text/plain' }))