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' }))