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