upload

Massimo Melina committed Jan 8, 2023 at 01:49 UTC 7f500a2a764aac5e76b370a3f4b1c229fbe2ce4a
11 files changed +282 -45
README.md
+2
@@ -34,6 +34,7 @@ You won't find all previous features here (yet), but still we got:
34 - search
35 - accounts
36 - resumable downloads
37 +- upload
38 - download folders as zip archive
39 - simple website serving
40 - plug-ins
@@ -177,6 +178,7 @@ Valid keys in a node are:
178 - `"*"`: any account can, i.e. anyone who logged in.
179 - `[ frank, peter ]`: the list of accounts who can.
180 - `can_see`: specify who can see this entry. Even if a user can download you can still make the file not appear in the list.
181 +- `can_upload` specify who can upload. Applies to folders with a source. Default is none.
182 Remember that to see in the list you must also be able to download, or else you won't see it anyway. Value is a `WhoCan` descriptor, refer above.
183 - `masks`: maps a file mask to a set of properties as the one documented in this section. E.g.
184 ```
admin/src/FileForm.ts
+11 -14
@@ -18,7 +18,7 @@ export default function FileForm({ file }: { file: VfsNode }) {
18 const { parent, children, isRoot, ...rest } = file
19 const [values, setValues] = useState(rest)
20 useEffect(() => {
21 - setValues(Object.assign({ can_see: null, can_read: null }, rest))
21 + setValues(Object.assign({ can_see: null, can_read: null, can_upload: null }, rest))
22 }, [file]) //eslint-disable-line
23
24 const { source } = file
@@ -26,17 +26,13 @@ export default function FileForm({ file }: { file: VfsNode }) {
26 const hasSource = source !== undefined // we need a boolean
27 const realFolder = hasSource && isDir
28 const inheritedPerms = useMemo(() => {
29 - const ret = { can_read: true, can_see: true }
30 - // reconstruct parents backward
31 - const parents = []
29 + const ret = {}
30 let run = parent
31 while (run) {
34 - parents.unshift(run)
32 + _.defaults(ret, run)
33 run = run.parent
34 }
37 - for (const node of parents)
38 - Object.assign(ret, node)
39 - return ret
35 + return _.defaults(ret, { can_read: true, can_see: true, can_upload: false })
36 }, [parent])
37 const showCanSee = (values.can_read ?? inheritedPerms.can_read) === true
38 const showTimestamps = hasSource && Boolean(values.ctime)
@@ -80,12 +76,9 @@ export default function FileForm({ file }: { file: VfsNode }) {
76 : { k: 'name', required: true, helperText: source && "You can decide a name that's different from the one on your disk" },
77 isRoot ? { k: 'source', comp: FileField, files: false, helperText: "If you specify a folder here, its files will be listed in the home" }
78 : (hasSource && { k: 'source', comp: FileField, folders: true, multiline: true }),
83 - { k: 'can_read', label:"Who can download", xl: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
84 - helperText: "Note: who can't download won't see it in the list"
85 - },
86 - showCanSee && { k: 'can_see', label:"Who can see", xl: 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_see,
87 - helperText: "If you hide this element it will not be listed, but will still be accessible if you have a direct link"
88 - },
79 + perm('can_read', "Who can download", "Note: who can't download won't see it in the list"),
80 + showCanSee && perm('can_see', "Who can see", "You can hide and keep it downloadable if you have a direct link"),
81 + hasSource && perm('can_upload', "Who can upload"),
82 hasSource && !realFolder && { k: 'size', comp: DisplayField, toField: formatBytes },
83 showTimestamps && { k: 'ctime', comp: DisplayField, lg: 6, label: 'Created', toField: formatTimestamp },
84 showTimestamps && { k: 'mtime', comp: DisplayField, lg: 6, label: 'Modified', toField: formatTimestamp },
@@ -97,6 +90,10 @@ export default function FileForm({ file }: { file: VfsNode }) {
90 helperText: "This is a special field. Leave it empty unless you know what you are doing." }
91 ]
92 })
93 +
94 + function perm(perm: keyof typeof inheritedPerms, label: string, helperText='', props={}) {
95 + return { k: perm, xl: 6, comp: WhoField, parent, accounts, label, inherit: inheritedPerms[perm], helperText, ...props }
96 + }
97 }
98
99 function formatTimestamp(x: string) {
frontend/src/index.scss
+6
@@ -265,6 +265,12 @@ button label {
265 }
266 }
267
268 +.upload-list {
269 + & td:nth-child(1) { width: 0; }
270 + & td:nth-child(2) { text-align: right; width: 0; white-space: nowrap; padding-left: 0.5em; }
271 + & td:nth-child(3) { padding: .2em .5em; word-break: break-word; }
272 +}
273 +
274 /* Works on Firefox */
275 * {
276 scrollbar-width: thin;
frontend/src/menu.ts
+11 -2
@@ -11,9 +11,12 @@ import showUserPanel from './UserPanel'
11 import { useNavigate } from 'react-router-dom'
12 import _ from 'lodash'
13 import { closeDialog } from '@hfs/shared/dialogs'
14 +import { showUpload, uploadState } from './upload'
15 +import { useSnapshot } from 'valtio'
16
17 export function MenuPanel() {
16 - const { showFilter, remoteSearch, stopSearch, stoppedSearch, patternFilter, selected } = useSnapState()
18 + const { showFilter, remoteSearch, stopSearch, stoppedSearch, patternFilter, selected, can_upload } = useSnapState()
19 + const { uploading } = useSnapshot(uploadState)
20 const [filter, setFilter] = useState(patternFilter)
21 ;[state.patternFilter] = useDebounce(showFilter ? filter : '', 300)
22 useEffect(() => {
@@ -70,7 +73,13 @@ export function MenuPanel() {
73 }, "Select some files"),
74 }
75 }
73 - })
76 + }),
77 + can_upload && h(MenuButton, {
78 + icon: 'upload',
79 + label: 'Upload',
80 + className: uploading && 'ani-working',
81 + onClick: showUpload,
82 + }),
83 ),
84 remoteSearch && h('div', { id: 'searched' },
85 (stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
frontend/src/state.ts
+2
@@ -28,7 +28,9 @@ export const state = proxy<{
28 serverConfig?: any,
29 loginRequired?: boolean, // force user to login before proceeding
30 messageOnly?: string, // no gui, just show this message
31 + can_upload: boolean
32 }>({
33 + can_upload: false,
34 iconsClass: '',
35 username: '',
36 list: [],
frontend/src/upload.ts new
+191
@@ -0,0 +1,191 @@
1 +// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2 +
3 +import { createElement as h, useState } from 'react'
4 +import { Flex, FlexV } from './components'
5 +import { formatBytes, hIcon, newDialog, prefix } from './misc'
6 +import _ from 'lodash'
7 +import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8 +import { alertDialog } from './dialog'
9 +import { reloadList } from './useFetchList'
10 +
11 +export const uploadState = proxy<{
12 + done: number
13 + doneByte: number
14 + errors: number
15 + qs: { to: string, files: File[] }[]
16 + paused: boolean
17 + uploading?: File
18 +}>({
19 + paused: false,
20 + qs: [],
21 + errors: 0,
22 + doneByte: 0,
23 + done: 0,
24 +})
25 +
26 +let reloadOnClose = false
27 +
28 +export function showUpload() {
29 + if (!uploadState.qs.length)
30 + Object.assign(uploadState, {
31 + errors: 0,
32 + done: 0,
33 + doneByte: 0,
34 + })
35 + newDialog({
36 + dialogProps: { style: { minWidth: 'min(20em, 100vw - 1em)' } },
37 + title: "Upload",
38 + icon: () => hIcon('upload'),
39 + Content,
40 + onClose() {
41 + if (!reloadOnClose) return
42 + reloadOnClose = false
43 + reloadList()
44 + }
45 + })
46 +
47 + function Content(){
48 + const [files, setFiles] = useState([] as File[])
49 + const { qs, done, doneByte, paused } = useSnapshot(uploadState)
50 + return h(FlexV, {},
51 + h(Flex, { gap: '.5em', flexWrap: 'wrap', justifyContent: 'center', position: 'sticky', top: -4, background: 'var(--bg)', boxShadow: '0 3px 3px #000' },
52 + h('button',{ onClick: () => selectFiles() }, "Add file(s)"),
53 + h('button',{ onClick: () => selectFiles(true) }, "Add folder"),
54 + files.length > 1 && h('button', { onClick() { setFiles([]) } }, "Clear"),
55 + files.length > 0 && h('button', {
56 + onClick() {
57 + const to = location.pathname
58 + const ready = _.find(uploadState.qs, { to })
59 + if (!ready)
60 + uploadState.qs.push({ to, files: files.map(ref) })
61 + else {
62 + _.remove(ready.files, f => { // avoid duplicates
63 + const match = path(f)
64 + return Boolean(_.find(files, x => match === path(x)))
65 + })
66 + ready.files.push(...files.map(ref))
67 + }
68 + setFiles([])
69 + }
70 + }, `Send ${files.length} file(s), ${formatBytes(files.reduce((a, f) => a + f.size, 0))}`),
71 + ),
72 + h(FilesList, {
73 + files,
74 + remove(f) {
75 + setFiles(files.filter(x => x !== f))
76 + }
77 + }),
78 + done > 0 && h('div', {}, `Finished ${done} files, ${formatBytes(doneByte)}`),
79 + qs.length > 0 && h('div', {},
80 + h(Flex, { alignItems: 'center', justifyContent: 'center', borderTop: '1px dashed', padding: '.5em' },
81 + "Queue",
82 + `(${_.sumBy(qs, q => q.files.length)})`,
83 + h('button',{
84 + onClick(){
85 + abortCurrentUpload()
86 + uploadState.qs = []
87 + }
88 + }, "Clear"),
89 + h('button',{
90 + onClick(){
91 + uploadState.paused = !uploadState.paused
92 + }
93 + }, paused ? "Resume" : "Pause"),
94 + ),
95 + qs.map((q,idx) =>
96 + h('div', { key: q.to },
97 + h('div', {}, "Destination ", q.to),
98 + h(FilesList, {
99 + files: Array.from(q.files),
100 + remove(f) {
101 + if (f === uploadState.uploading)
102 + abortCurrentUpload()
103 + else
104 + _.pull(uploadState.qs[idx].files, f)
105 + }
106 + }),
107 + ))
108 + )
109 + )
110 +
111 + function selectFiles(folder=false) {
112 + const el = Object.assign(document.createElement('input'), {
113 + type: 'file',
114 + name: 'file',
115 + multiple: true,
116 + webkitdirectory: folder,
117 + })
118 + el.addEventListener('change', () =>
119 + setFiles([ ...files, ...el.files ||[] ] ))
120 + el.click()
121 + }
122 + }
123 +
124 +}
125 +
126 +function path(f: File, pre='') {
127 + return (prefix('', pre, '/') + (f.webkitRelativePath || f.name)).replaceAll('//','/')
128 +}
129 +
130 +function FilesList({ files, remove }: { files: File[], remove: (f:File) => any }) {
131 + const { uploading } = useSnapshot(uploadState)
132 + return !files.length ? null : h('table', { className: 'upload-list', width: '100%' },
133 + h('tbody', {},
134 + files.map((f,i) =>
135 + h('tr', { key: i },
136 + h('td', {}, iconBtn('trash', () => remove(f))),
137 + h('td', {}, formatBytes(f.size)),
138 + h('td', { className: f === uploading ? 'ani-working' : undefined }, path(f)),
139 + ))
140 + )
141 + )
142 +}
143 +
144 +function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...props }={}) {
145 + return h('button', { onClick, ...props, ...small && { style: { padding: '.1em', ...style } } }, hIcon(icon))
146 +}
147 +
148 +/// Manage uploadQ
149 +
150 +let controller: AbortController | undefined
151 +subscribe(uploadState, () => {
152 + const [cur] = uploadState.qs
153 + if (cur && !uploadState.uploading && !uploadState.paused)
154 + startUpload(cur.files[0], cur.to)
155 +})
156 +
157 +function startUpload(f: File | undefined, to: string) {
158 + if (!f) return
159 + uploadState.uploading = f
160 + controller = new AbortController()
161 + const full = path(f, to)
162 + fetch(full, {
163 + method: 'PUT',
164 + body: f,
165 + signal: controller.signal,
166 + }).then(async res => {
167 + if (!res.ok)
168 + throw Error("Upload failed for " + full)
169 + uploadState.done++
170 + uploadState.doneByte += f.size
171 + reloadOnClose = true
172 + }).finally(() => {
173 + uploadState.uploading = undefined
174 + const { qs } = uploadState
175 + qs[0].files.shift()
176 + if (!qs[0].files.length)
177 + qs.shift()
178 + if (!qs.length) {
179 + reloadList()
180 + reloadOnClose = false
181 + }
182 + }).catch(e => {
183 + if (e.code === e.ABORT_ERR) return
184 + if (!uploadState.errors++)
185 + alertDialog(e).then()
186 + })
187 +}
188 +
189 +function abortCurrentUpload() {
190 + controller?.abort()
191 +}
\ No newline at end of file
frontend/src/useFetchList.ts
+7 -4
@@ -39,6 +39,7 @@ export default function useFetchList() {
39 state.selected = {}
40 state.loading = true
41 state.error = undefined
42 + state.can_upload = false
43 // buffering entries is necessary against burst of events that will hang the browser
44 const buffer: DirList = []
45 const flush = () => {
@@ -61,10 +62,12 @@ export default function useFetchList() {
62 lastReq.current = undefined
63 return
64 case 'msg':
64 - data.forEach((data: any) => {
65 - if (data.add)
66 - return buffer.push(data.add)
67 - const { error } = data
65 + data.forEach((entry: any) => {
66 + if (entry.props)
67 + return Object.assign(state, _.pick(entry.props, ['can_upload']))
68 + if (entry.add)
69 + return buffer.push(entry.add)
70 + const { error } = entry
71 if (error === 405) { // "method not allowed" happens when we try to directly access an unauthorized file, and we get a login prompt, and then file_list the file (because we didn't know it was file or folder)
72 state.messageOnly = "Your download should now start"
73 window.location.reload() // reload will start the download, because now we got authenticated
src/api.file_list.ts
+7 -1
@@ -26,9 +26,15 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
26 const filter = pattern2filter(search)
27 const walker = walkNode(node, ctx, search ? Infinity : 0)
28 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
29 + const can_upload = hasPermission(node, 'can_upload', ctx)
30 if (!sse)
30 - return { list: await asyncGeneratorToArray(produceEntries()) }
31 + return {
32 + can_upload,
33 + list: await asyncGeneratorToArray(produceEntries())
34 + }
35 setTimeout(async () => {
36 + if (can_upload)
37 + list.custom({ props: { can_upload } })
38 for await (const entry of produceEntries())
39 list.add(entry)
40 list.close()
src/api.vfs.ts
+2 -2
@@ -1,6 +1,6 @@
1 // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from './vfs'
3 +import { defaultPerms, getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from './vfs'
4 import _ from 'lodash'
5 import { stat } from 'fs/promises'
6 import { ApiError, ApiHandlers } from './apiMiddleware'
@@ -60,7 +60,7 @@ const apis: ApiHandlers = {
60 const n = await urlToNodeOriginal(uri)
61 if (!n)
62 return new ApiError(HTTP_NOT_FOUND, 'path not found')
63 - props = pickProps(props, ['name','source','can_see','can_read','masks','default'])
63 + props = pickProps(props, ['name','source','masks','default', ...Object.keys(defaultPerms)])
64 props = objSameKeys(props, v => v === null ? undefined : v) // null is a way to serialize undefined, that will restore default values
65 if (props.masks && typeof props.masks !== 'object')
66 delete props.masks
src/middlewares.ts
+19 -2
@@ -8,10 +8,10 @@ import {
8 ADMIN_URI,
9 BUILD_TIMESTAMP,
10 DEV,
11 - HTTP_FORBIDDEN,
11 SESSION_DURATION,
12 + HTTP_FORBIDDEN,
13 HTTP_UNAUTHORIZED,
14 - HTTP_NOT_FOUND
14 + HTTP_NOT_FOUND,
15 } from './const'
16 import { FRONTEND_URI } from './const'
17 import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs, VfsNode } from './vfs'
@@ -76,6 +76,13 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
76 return ctx.redirect(ADMIN_URI)
77 if (path.startsWith(ADMIN_URI))
78 return serveAdminPrefixed(ctx,next)
79 + if (ctx.method === 'PUT') { // curl -T file url/
80 + let rest = basename(path)
81 + const folder = await urlToNode(dirname(path), ctx, vfs, v => rest = v+'/'+rest)
82 + if (!folder)
83 + return ctx.status = HTTP_NOT_FOUND
84 + return await getUpload(folder, rest, ctx.req, ctx)
85 + }
86 const node = await urlToNode(path, ctx)
87 if (!node)
88 return ctx.status = HTTP_NOT_FOUND
@@ -109,6 +116,16 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
116 return serveFrontendFiles(ctx, next)
117 }
118
119 +async function getUpload(base: VfsNode, path: string, stream: Readable, ctx: Koa.Context) {
120 + if (!base.source || !hasPermission(base, 'can_upload', ctx))
121 + return ctx.status = base.can_upload === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED
122 + path = join(base.source, path)
123 + mkdirSync(dirname(path), { recursive: true })
124 + const dest = createWriteStream(path)
125 + await pipeline(stream, dest)
126 + ctx.body = '{}'
127 +}
128 +
129 let proxyDetected = false
130 export const someSecurity: Koa.Middleware = async (ctx, next) => {
131 ctx.request.ip = normalizeIp(ctx.ip)
src/vfs.ts
+24 -20
@@ -22,8 +22,9 @@ type Who = typeof WHO_ANYONE
22 | AccountList
23
24 interface VfsPerm {
25 - can_see: Who
25 can_read: Who
26 + can_see: Who // use this to hide something you can_read
27 + can_upload: Who
28 }
29
30 type Masks = Record<string, VfsNode>
@@ -39,13 +40,13 @@ export interface VfsNode extends Partial<VfsPerm> {
40 // fields that are only filled at run-time
41 isTemp?: true // this node doesn't belong to the tree and was created by necessity
42 url?: string // what url brought to this node
42 - parents?: VfsNode[]
43 original?: VfsNode // if this is a temp node but reflecting an existing node
44 }
45
46 export const defaultPerms: VfsPerm = {
47 can_see: WHO_ANYONE,
48 can_read: WHO_ANYONE,
49 + can_upload: WHO_NO_ONE,
50 }
51
52 export const MIME_AUTO = 'auto'
@@ -63,7 +64,7 @@ function inheritFromParent(parent: VfsNode, child: VfsNode) {
64 return child
65 }
66
66 -export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=vfs) : Promise<VfsNode | undefined> {
67 +export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=vfs, getRest?: (rest: string) => any) : Promise<VfsNode | undefined> {
68 let initialSlashes = 0
69 while (url[initialSlashes] === '/')
70 initialSlashes++
@@ -77,23 +78,23 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
78 ctx.status = HTTP_FOOL
79 return
80 }
80 - const parents = parent.parents || [] // don't waste time cloning the array, as we won't keep intermediate nodes
81 + // does the tree node have a child that goes by this name?
82 + const sameName = !IS_WINDOWS ? (x:string) => x === name // easy
83 + : with_(name.toLowerCase(), lc =>
84 + (x: string) => x.toLowerCase() === lc)
85 + const child = parent.children?.find(x => sameName(getNodeName(x)))
86 +
87 const ret: VfsNode = {
88 + ...child,
89 + original: child,
90 isTemp: true,
91 url: enforceFinal('/', parent.url || '') + name,
84 - parents,
92 }
86 - parents.push(parent)
93 inheritFromParent(parent, ret)
94 inheritMasks(ret, parent, name)
95 applyMasks(ret, parent, name)
90 - // does the tree node have a child that goes by this name?
91 - const sameName = !IS_WINDOWS ? (x:string) => x === name // easy
92 - : with_(name.toLowerCase(), lc =>
93 - (x: string) => x.toLowerCase() === lc)
94 - const child = parent.children?.find(x => sameName(getNodeName(x)))
96 if (child) // yes
96 - return urlToNode(rest, ctx, Object.assign(ret, child, { original: child }))
97 + return urlToNode(rest, ctx, ret, getRest)
98 // not in the tree, we can see consider continuing on the disk
99 if (!parent.source) return // but then we need the current node to be linked to the disk, otherwise, we give up
100 let onDisk = name
@@ -109,10 +110,15 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
110 if (parent.default)
111 inheritFromParent({ mime: { '*': MIME_AUTO } }, ret)
112 if (rest)
112 - return urlToNode(rest, ctx, ret)
113 + return urlToNode(rest, ctx, ret, getRest)
114 if (ret.source)
115 try { await fs.stat(ret.source) } // check existence
115 - catch { return }
116 + catch {
117 + if (!getRest)
118 + return
119 + getRest(onDisk)
120 + return parent
121 + }
122 return ret
123 }
124
@@ -140,7 +146,7 @@ export async function nodeIsDirectory(node: VfsNode) {
146
147 export function hasPermission(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context): boolean {
148 return matchWho(node[perm] ?? defaultPerms[perm], ctx)
143 - && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // for can_see you must also can_read
149 + && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // can_see is used to hide something you nonetheless can_read, so you MUST also can_read
150 }
151
152 export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
@@ -176,11 +182,9 @@ export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=
182 const name = getNodeName(item)
183 // we basename for depth>0 where we already have the rest of the path in the parent's url, and would be duplicated
184 const virtualBasename = basename(name)
179 - const url = enforceFinal('/', parent.url || '') + virtualBasename
185 Object.assign(item, {
186 isTemp: true,
182 - url,
183 - parents: [ ...parent.parents||[], parent],
187 + url: enforceFinal('/', parent.url || '') + virtualBasename,
188 })
189 inheritFromParent(parent, item)
190 applyMasks(item, parent, virtualBasename)
@@ -240,8 +244,8 @@ events.on('accountRenamed', (from, to) => {
244 saveVfs()
245
246 function recur(n: VfsNode) {
243 - replace(n.can_see)
244 - replace(n.can_read)
247 + for (const k of typedKeys(defaultPerms))
248 + replace(n[k])
249
250 if (n.masks)
251 Object.values(n.masks).forEach(recur)