inheritable "default" #377

Massimo Melina committed Oct 15, 2023 at 14:23 UTC 7aaa79c2c266c5f82e3519d9222622f962f1a81b
11 files changed +40 -25
admin/src/FileForm.ts
+15 -4
@@ -42,6 +42,14 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
42 setValues(Object.assign(objSameKeys(defaultPerms, () => null), rest))
43 }, [file]) //eslint-disable-line
44
45 + const inheritedDefault = useMemo(() => {
46 + let p = file.parent
47 + while (p) {
48 + if (p.default != null)
49 + return p.default
50 + p = p.parent
51 + }
52 + }, [file])
53 const { source } = file
54 const isDir = file.type === 'folder'
55 const hasSource = source !== undefined // we need a boolean
@@ -50,6 +58,7 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
58 const showTimestamps = lg || hasSource
59 const showSize = lg || (hasSource && !realFolder)
60 const showAccept = file.accept! > '' || isDir && (file.can_upload ?? file.inherited?.can_upload)
61 + const showWebsite = isDir
62 const barColors = useDialogBarColors()
63
64 const { data, element } = useApiEx<{ list: Account[] }>('get_accounts')
@@ -107,11 +116,13 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
116 showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
117 showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: showSize && 4, label: "Created", toField: formatTimestamp },
118 showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: showSize && 4, label: "Modified", toField: formatTimestamp },
110 - showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", xl: file.website ? 4 : 12,
119 + showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", xl: showWebsite ? 4 : 12,
120 helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip") },
112 - file.website && { k: 'default', comp: BoolField, label:"Serve index.html", xl: true,
113 - toField: Boolean, fromField: (v:boolean) => v ? 'index.html' : null,
114 - helperText: md("This folder may be a website because contains `index.html`. Enabling this will show the website instead of the list of files.")
121 + showWebsite && { k: 'default', comp: BoolField, xl: true,
122 + label: "Serve as website if index.html is found" + (inheritedDefault && values.default == null ? ' (inherited)' : ''),
123 + value: values.default ?? inheritedDefault,
124 + toField: Boolean, fromField: (v:boolean) => v && !inheritedDefault ? 'index.html' : v ? null : false,
125 + helperText: md("...instead of showing list of files")
126 },
127 isDir && { k: 'masks', multiline: true,
128 toField: yaml.stringify, fromField: v => v ? yaml.parse(v) : undefined,
frontend/src/fileMenu.ts
+2 -2
@@ -30,7 +30,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe
30 ...addToMenu.map(x => {
31 if (x === 'open') {
32 if (entry.cantOpen) return
33 - const open = { id: 'open', icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder ? undefined : '_blank' }
33 + const open = { id: 'open', icon: 'play', label: t('file_open', "Open"), href: uri, target: isFolder || entry.web ? undefined : '_blank' }
34 return !isFolder ? open : h(Link, { to: uri, onClick: () => close() }, hIcon(open.icon), open.label)
35 }
36 if (x === 'delete')
@@ -90,7 +90,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe
90 href: '#',
91 ..._.omit(e, ['label', 'icon', 'onClick']),
92 async onClick(event: MouseEvent) {
93 - if (!e.href) // even with #, the
93 + if (!e.href)
94 event.preventDefault()
95 if (false !== await e.onClick?.(event))
96 close()
frontend/src/state.ts
+5 -2
@@ -3,7 +3,7 @@
3 import _ from 'lodash'
4 import { proxy, useSnapshot } from 'valtio'
5 import { subscribeKey } from 'valtio/utils'
6 -import { getHFS, hIcon } from './misc'
6 +import { Dict, getHFS, hIcon } from './misc'
7
8 export const state = proxy<{
9 stopSearch?: ()=>void,
@@ -91,6 +91,7 @@ export class DirEntry {
91 public readonly c?: string
92 public readonly p?: string
93 public readonly icon?: string
94 + public readonly web?: true
95 public comment?: string
96 // we memoize these value for speed
97 public readonly name: string
@@ -100,10 +101,12 @@ export class DirEntry {
101 public readonly t?:Date
102 public readonly cantOpen: boolean
103
103 - constructor(n: string, rest?: object) {
104 + constructor(n: string, rest?: any) {
105 Object.assign(this, rest) // we actually allow any custom property to be memorized
106 this.n = n // must do it after rest to avoid overwriting
107 this.uri = (n[0] === '/' ? '' : location.pathname) + pathEncode(this.n)
108 + if (rest?.web) // this is actually a folder pointing to a default file, and it requires a final slash for correct handling
109 + this.uri += '/'
110 this.isFolder = this.n.endsWith('/')
111 if (!this.isFolder) {
112 const i = this.n.lastIndexOf('.') + 1
frontend/src/useFetchList.ts
+1
@@ -74,6 +74,7 @@ export default function useFetchList() {
74 case 'msg':
75 state.loginRequired = false
76 for (const entry of data) {
77 + if (!Array.isArray(entry)) continue // unexpected
78 const [op, par] = entry
79 const error = op === 'error' && par
80 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)
shared/api.ts
+1 -1
@@ -161,7 +161,7 @@ export async function getNotification(channel: string, cb: (name: string, data:a
161 if (type === 'connected')
162 return resolve(ret)
163 if (type !== 'msg') return
164 - for (const { name, data } of entries)
164 + for (const [name, data] of entries)
165 if (name)
166 cb(name, data)
167 })
src/api.file_list.ts
+9 -10
@@ -6,13 +6,12 @@ import { ApiError, ApiHandler, SendListReadable } from './apiMiddleware'
6 import { stat } from 'fs/promises'
7 import { mapPlugins } from './plugins'
8 import { asyncGeneratorToArray, dirTraversal, pattern2filter, WHO_NO_ONE } from './misc'
9 -import _ from 'lodash'
9 import { HTTP_FOOL, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const'
10 import Koa from 'koa'
11 import { descriptIon, DESCRIPT_ION, getCommentFor } from './comments'
12 import { basename } from 'path'
13
15 -export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string }
14 + export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean }
15
16 export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c }, ctx) => {
17 const node = await urlToNode(uri || '/', ctx)
@@ -23,11 +22,7 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c
22 return fail()
23 if (dirTraversal(search))
24 return fail(HTTP_FOOL)
26 - if (node.default)
27 - return (list?.custom ?? _.identity)({ // sse will wrap the object in a 'custom' message, otherwise we plainly return the object
28 - redirect: uri // tell the browser to access the folder (instead of using this api), so it will get the default file
29 - })
30 - if (!await nodeIsDirectory(node))
25 + if (await hasDefaultFile(node) || !await nodeIsDirectory(node))
26 return fail(HTTP_METHOD_NOT_ALLOWED)
27 offset = Number(offset)
28 limit = Number(limit)
@@ -92,13 +87,17 @@ export const get_file_list: ApiHandler = async ({ uri, offset, limit, search, c
87 }
88 }
89
90 + async function hasDefaultFile(node: VfsNode) {
91 + return node.default && await urlToNode(node.default, ctx, node)
92 + }
93 +
94 async function nodeToDirEntry(ctx: Koa.Context, node: VfsNode): Promise<DirEntry | null> {
96 - let { source, default:def } = node
95 + let { source } = node
96 const name = getNodeName(node)
97 if (!source)
98 return name ? { n: name + '/' } : null
100 - if (def)
101 - return { n: name }
99 + if (node.isFolder && await hasDefaultFile(node))
100 + return { n: name, web: true }
101 try {
102 const st = await stat(source)
103 const folder = st.isDirectory()
src/apiMiddleware.ts
+2 -2
@@ -140,8 +140,8 @@ export class SendListReadable<T> extends Readable {
140 ready() { // useful to indicate the end of an initial phase, but we leave open for updates
141 this._push(['ready'])
142 }
143 - custom(data: any) {
144 - this._push(data)
143 + custom(name: string, data: any) {
144 + this._push([name, data])
145 }
146 props(props: object) {
147 this._push(['props', props])
src/frontEndApis.ts
+1 -1
@@ -27,7 +27,7 @@ export const frontEndApis: ApiHandlers = {
27 list.ready() // on chrome109 EventSource doesn't emit 'open' until something is sent
28 return list.events(ctx, {
29 [NOTIFICATION_PREFIX + channel](name, data) {
30 - list.custom({ name, data })
30 + list.custom(name, data)
31 }
32 })
33 },
src/middlewares.ts
+1 -1
@@ -115,7 +115,7 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
115 return serveFile(ctx, favicon.get())
116 let node = await urlToNode(path, ctx)
117 if (node?.default && (path.endsWith('/') || !node.default.match(/\.html?$/i))) // final/ needed on browser to make resource urls correctly
118 - node = await urlToNode(node.default, ctx, node)
118 + node = await urlToNode(node.default, ctx, node) ?? node
119 if (!node)
120 return sendErrorPage(ctx, HTTP_NOT_FOUND)
121 if (ctx.method === 'POST') { // curl -F upload=@file url/
src/vfs.ts
+2 -1
@@ -19,7 +19,7 @@ export interface VfsNode extends VfsPerms {
19 name?: string
20 source?: string
21 children?: VfsNode[]
22 - default?: string
22 + 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
23 mime?: string | Record<string,string>
24 rename?: Record<string, string>
25 masks?: Masks // express fields for descendants that are not in the tree
@@ -60,6 +60,7 @@ function inheritFromParent(parent: VfsNode, child: VfsNode) {
60 else
61 child.mime ??= parent.mime
62 child.accept ??= parent.accept
63 + child.default ??= parent.default
64 return child
65 }
66
tests/test.ts
+1 -1
@@ -27,7 +27,7 @@ describe('basics', () => {
27 //before(async () => appStarted)
28 it('frontend', req('/', /<body>/, { headers: { accept: '*/*' } })) // workaround: 'accept' is necessary when running server-for-test-dev, still don't know why
29 it('force slash', req('/f1', 302))
30 - it('list', reqList('/f1/', { inList:['f2/', 'page'] }))
30 + it('list', reqList('/f1/', { inList:['f2/', 'page/'] }))
31 it('search', reqList('f1', { inList:['f2/'], outList:['page'] }, { search:'2' }))
32 it('search root', reqList('/', { inList:['cantListPage/'], outList:['cantListPage/page/'] }, { search:'page' }))
33 it('download', req('/f1/f2/alfa.txt', { re:/abcd/, mime:'text/plain' }))