admin/fs: "Accept on upload"
Massimo Melina committed
Mar 24, 2023 at 17:45 UTC
2272a9c3f243e3134dafd2edd6d69504b1fb299a
9 files changed
+51
-33
admin/src/FileForm.ts
+8
-3
@@ -2,7 +2,7 @@
2
3
import { state } from './state'
4
import { createElement as h, ReactNode, useEffect, useMemo, useState } from 'react'
5
-import { Alert, Box, MenuItem, MenuList, } from '@mui/material'
5
+import { Alert, Box, Link, MenuItem, MenuList, } from '@mui/material'
6
import {
7
BoolField,
8
DisplayField,
@@ -33,6 +33,8 @@ interface FileFormProps {
33
urls: string[] | false
34
}
35
36
+const ACCEPT_LINK = "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept"
37
+
38
export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }: FileFormProps) {
39
const { parent, children, isRoot, ...rest } = file
40
const [values, setValues] = useState(rest)
@@ -55,6 +57,7 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
57
}, [parent])
58
const showTimestamps = hasSource && Boolean(values.ctime)
59
const showSize = hasSource && !realFolder
60
+ const showAccept = file.accept! > '' || isDir && (file.can_upload ?? inheritedPerms.can_upload)
61
const barColors = useDialogBarColors()
62
63
const { data, element } = useApiEx<{ list: Account[] }>('get_accounts')
@@ -105,8 +108,10 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
108
perm('can_read', "Who can download", "Who can see but not download will be asked to login"),
109
perm('can_see', "Who can see", "If you don't see, you may download with a direct link"),
110
isDir && perm('can_list', "Who can list", "Permission to see content of folders"),
108
- isDir && perm('can_upload', "Who can upload", hasSource ? '' : "Works only on folders with source"),
109
- isDir && perm('can_delete', "Who can delete", hasSource ? '' : "Works only on folders with source", { lg: 12 }),
111
+ isDir && perm('can_delete', "Who can delete", hasSource ? '' : "Works only on folders with source"),
112
+ isDir && perm('can_upload', "Who can upload", hasSource ? '' : "Works only on folders with source", { lg: showAccept ? 6 : 12 }),
113
+ showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything",
114
+ helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip"), lg: 6 },
115
showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
116
showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: showSize && 4, label: 'Created', toField: formatTimestamp },
117
showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: showSize && 4, label: 'Modified', toField: formatTimestamp },
admin/src/VfsPage.ts
+1
@@ -179,6 +179,7 @@ export interface VfsNode extends VfsPerms {
179
website?: true
180
masks?: any
181
isRoot?: true
182
+ accept?: string
183
}
184
185
const WHO_ANYONE = true
frontend/src/state.ts
+1
-3
@@ -29,13 +29,11 @@ export const state = proxy<{
29
messageOnly?: string, // no gui, just show this message
30
can_upload?: boolean
31
can_delete?: boolean
32
+ accept?: string
33
}>({
33
- can_delete: undefined,
34
- can_upload: undefined,
34
iconsClass: '',
35
username: '',
36
list: [],
38
- filteredList: undefined,
37
loading: false,
38
listReloader: 0,
39
patternFilter: '',
frontend/src/upload.ts
+30
-14
@@ -8,7 +8,7 @@ import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8
import { alertDialog, confirmDialog, promptDialog } from './dialog'
9
import { reloadList } from './useFetchList'
10
import { apiCall, getNotification } from './api'
11
-import { useSnapState } from './state'
11
+import { state, useSnapState } from './state'
12
import { Link } from 'react-router-dom'
13
import { t } from './i18n'
14
@@ -85,7 +85,7 @@ export function showUpload() {
85
function Content(){
86
const [files, setFiles] = useState([] as File[])
87
const { qs, paused, eta } = useSnapshot(uploadState)
88
- const { can_upload } = useSnapState()
88
+ const { can_upload, accept } = useSnapState()
89
const etaStr = useMemo(() => !eta ? '' : formatTime(eta*1000, 0, 2), [eta])
90
const size = formatBytes(files.reduce((a, f) => a + f.size, 0))
91
@@ -93,8 +93,8 @@ export function showUpload() {
93
h(FlexV, { position: 'sticky', top: -4, background: 'var(--bg)' },
94
!can_upload ? t('no_upload_here', "No upload permission for the current folder")
95
: h(Flex, { justifyContent: 'center', flexWrap: 'wrap', marginTop: '1em' },
96
- h('button', { onClick: () => pickFiles() }, t`Pick files`),
97
- h('button', { onClick: () => pickFiles(true) }, t`Pick folder`),
96
+ h('button', { onClick: () => pickFiles({ accept: normalizeAccept(accept) }) }, t`Pick files`),
97
+ !accept && h('button', { onClick: () => pickFiles({ folder: true }) }, t`Pick folder`),
98
files.length > 0 && h('button', {
99
onClick() {
100
enqueue(files)
@@ -141,8 +141,8 @@ export function showUpload() {
141
)
142
)
143
144
- function pickFiles(folder=false) {
145
- selectFiles(list => setFiles([ ...files, ...list ||[] ] ), { folder })
144
+ function pickFiles(options: Parameters<typeof selectFiles>[1]) {
145
+ selectFiles(list => setFiles([ ...files, ...list ||[] ] ), options)
146
}
147
}
148
@@ -207,16 +207,32 @@ subscribe(uploadState, () => {
207
startUpload(cur.files[0], cur.to).then()
208
})
209
210
-export function enqueue(files: File[]) {
211
- const to = location.pathname
212
- const ready = _.find(uploadState.qs, { to })
213
- if (!ready)
214
- return uploadState.qs.push({ to, files: files.map(ref) })
215
- _.remove(ready.files, f => { // avoid duplicates
210
+export async function enqueue(files: File[]) {
211
+ if (_.remove(files, f => !simulateBrowserAccept(f)).length)
212
+ await alertDialog(t('upload_file_rejected', "Some files were not accepted"), 'warning')
213
+
214
+ _.remove(files, f => { // avoid duplicates
215
const match = path(f)
217
- return Boolean(_.find(files, x => match === path(x)))
216
+ return Boolean(_.find(files, x => x !== f && match === path(x)))
217
})
219
- ready.files.push(...files.map(ref))
218
+ if (!files.length) return
219
+ const to = location.pathname
220
+ _.find(uploadState.qs, { to })?.files.push(...files.map(ref))
221
+ || uploadState.qs.push({ to, files: files.map(ref) })
222
+
223
+ function simulateBrowserAccept(f: File) {
224
+ const { accept } = state
225
+ if (!accept) return true
226
+ return normalizeAccept(accept)!.split(/ *[|,] */).some(pattern =>
227
+ pattern.startsWith('.') ? f.name.endsWith(pattern)
228
+ : f.type.match(pattern.replace('*', '.*'))
229
+ )
230
+ }
231
+
232
+}
233
+
234
+function normalizeAccept(accept?: string) {
235
+ return accept?.replace(/\|/g, ',').replace(/ +/g, '')
236
}
237
238
let req: XMLHttpRequest | undefined
frontend/src/useFetchList.ts
+1
-1
@@ -94,7 +94,7 @@ export default function useFetchList() {
94
if (!desiredPath.endsWith('/')) // now we know it was a folder for sure
95
return navigate(desiredPath + '/')
96
if (entry.props) {
97
- Object.assign(state, _.pick(entry.props, ['can_upload', 'can_delete']))
97
+ Object.assign(state, _.pick(entry.props, ['can_upload', 'can_delete', 'accept']))
98
continue
99
}
100
state.can_upload ??= false
langs/hfs-lang-en.json
+1
@@ -100,6 +100,7 @@
100
"upload_concluded": "Upload concluded:",
101
"upload_finished": "{n} finished ({size})",
102
"upload_errors": "{n} failed",
103
+ "upload_file_rejected": "Some files were not accepted",
104
105
"download counter": "download counter"
106
}
src/api.file_list.ts
+3
-5
@@ -39,14 +39,12 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
39
const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
40
const can_upload = hasPermission(node, 'can_upload', ctx)
41
const can_delete = hasPermission(node, 'can_delete', ctx)
42
+ const props = { can_upload, can_delete, accept: node.accept }
43
if (!sse)
43
- return {
44
- can_upload, can_delete,
45
- list: await asyncGeneratorToArray(produceEntries())
46
- }
44
+ return { ...props, list: await asyncGeneratorToArray(produceEntries()) }
45
setTimeout(async () => {
46
if (can_upload || can_delete)
49
- list.custom({ props: { can_upload, can_delete } })
47
+ list.custom({ props })
48
for await (const entry of produceEntries())
49
list.add(entry)
50
list.close()
src/api.vfs.ts
+1
-1
@@ -82,7 +82,7 @@ const apis: ApiHandlers = {
82
const n = await urlToNodeOriginal(uri)
83
if (!n)
84
return new ApiError(HTTP_NOT_FOUND, 'path not found')
85
- props = pickProps(props, ['name','source','masks','default', ...Object.keys(defaultPerms)])
85
+ props = pickProps(props, ['name','source','masks','default', 'accept', ...Object.keys(defaultPerms)])
86
if (props.name && props.name !== getNodeName(n)) {
87
const parent = await urlToNodeOriginal(dirname(uri))
88
if (parent?.children?.find(x => getNodeName(x) === props.name))
src/vfs.ts
+5
-6
@@ -37,6 +37,7 @@ export interface VfsNode extends Partial<VfsPerm> {
37
mime?: string | Record<string,string>
38
rename?: Record<string, string>
39
masks?: Masks // express fields for descendants that are not in the tree
40
+ accept?: string
41
// fields that are only filled at run-time
42
isTemp?: true // this node doesn't belong to the tree and was created by necessity
43
original?: VfsNode // if this is a temp node but reflecting an existing node
@@ -53,15 +54,13 @@ export const defaultPerms: VfsPerm = {
54
export const MIME_AUTO = 'auto'
55
56
function inheritFromParent(parent: VfsNode, child: VfsNode) {
56
- for (const k of typedKeys(defaultPerms)) {
57
- const v = parent[k]
58
- if (v !== undefined)
59
- child[k] ??= v
60
- }
57
+ for (const k of typedKeys(defaultPerms))
58
+ child[k] ??= parent[k]
59
if (typeof parent.mime === 'object' && typeof child.mime === 'object')
60
_.defaults(child.mime, parent.mime)
61
else
64
- child.mime ||= parent.mime
62
+ child.mime ??= parent.mime
63
+ child.accept ??= parent.accept
64
return child
65
}
66