admin/shared: in-memory vfs editing

Massimo Melina committed Oct 15, 2025 at 14:43 UTC 2d03f025e4e3adc7bd93de885b975f47828c5372
17 files changed +572 -180
admin/src/FileForm.ts
+47 -22
@@ -1,6 +1,6 @@
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 { state, useSnapState } from './state'
3 +import { markVfsModified, state, useSnapState } from './state'
4 import { createElement as h, forwardRef, ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'
5 import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList, useTheme } from '@mui/material'
6 import {
@@ -9,12 +9,13 @@ import {
9 import { apiCall, UseApi } from './api'
10 import {
11 basename, defaultPerms, formatBytes, formatTimestamp, isWhoObject, newDialog, objSameKeys,
12 - onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, HTTP_MESSAGES, xlate, md, Callback,
13 - useRequestRender, splitAt, IMAGE_FILEMASK, copyTextToClipboard, normalizeHost, CFG, try_
12 + onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, xlate, md, Callback,
13 + useRequestRender, splitAt, IMAGE_FILEMASK, copyTextToClipboard, normalizeHost, CFG, try_,
14 + WHO_ANY_ACCOUNT
15 } from './misc'
16 import { isModifiedConfig } from './AccountForm'
17 import { Btn, Flex, IconBtn, LinkBtn, propsForModifiedValues, useBreakpoint, wikiLink } from './mui'
17 -import { reloadVfs, VfsNode } from './VfsPage'
18 +import { deleteVfs, id2node, reindexVfs, VfsNodeAdmin } from './VfsPage'
19 import _ from 'lodash'
20 import FileField from './FileField'
21 import { alertDialog, toast, useDialogBarColors } from './dialog'
@@ -33,7 +34,7 @@ import { Account, account2icon } from './AccountsPage'
34 const ACCEPT_LINK = "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept"
35
36 interface FileFormProps {
36 - file: VfsNode
37 + file: VfsNodeAdmin
38 addToBar?: ReactNode
39 statusApi: UseApi
40 accounts: Account[]
@@ -112,9 +113,9 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
113 || file.id.startsWith(movingFile) // can't move below myself
114 || file.id === movingFile.replace(/[^/]+\/?$/,''), // can't move to the same parent
115 title: movingFile,
115 - onClick() {
116 - state.movingFile = ''
117 - return moveVfs(movingFile, file.id)
116 + async onClick() {
117 + if (await moveVfs(movingFile, file.id))
118 + state.movingFile = ''
119 },
120 }),
121 h(IconBtn, {
@@ -122,25 +123,30 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
123 title: "Delete",
124 confirm: `Delete ${file.name}?`,
125 disabled: isRoot,
125 - onClick: () => apiCall('del_vfs', { uris: [file.id] }).then(({ errors: [err] }) => {
126 - if (err)
127 - alertDialog(xlate(err, HTTP_MESSAGES), 'error')
128 - else
129 - reloadVfs([])
130 - }),
126 + onClick() {
127 + deleteVfs([file.id])
128 + saved()
129 + },
130 }),
131 ...wantArray(addToBar)
132 ],
133 onError: alertDialog,
134 save: {
135 ...propsForModifiedValues(isModifiedConfig(values, rest)),
136 + children: "Apply",
137 + startIcon: h(Check),
138 async onClick() {
139 + const node = state.selectedFiles[0] || id2node.get(values.id)
140 + if (!node)
141 + throw Error("Selected node not found")
142 const props = _.omit(values, ['birthtime','mtime','size','id'])
139 - ;(props as any).masks ||= null // undefined cannot be serialized
140 - await apiCall('set_vfs', { uri: values.id, props })
141 - if (props.name !== file.name) // when the name changes, the id of the selected file is changing too, and we have to update it in the state if we want it to be correctly re-selected after reload
142 - state.selectedFiles[0].id = file.parent!.id + props.name + (isDir ? '/' : '')
143 - reloadVfs()
143 + const wasId = node.id
144 + Object.assign(node, props)
145 + if (props.name !== undefined)
146 + reindexVfs({ node, clearMap: false, select: [node] })
147 + if (node.id !== wasId)
148 + setValues(v => ({ ...v, id: node.id }))
149 + markVfsModified()
150 saved()
151 }
152 },
@@ -220,9 +226,13 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
226 if (!show[perm]) return null
227 const dontShow = [perm, ...onlyTruthy(_.map(show, (v,k) => !v && k))]
228 const others = _.difference(Object.keys(defaultPerms), dontShow)
223 - let inherit = file.inherited?.[perm] ?? defaultPerms[perm]
224 - while (typeof inherit === 'string' && _.get(show, inherit) === false) // is 'inherit' referring another permission that is not displayed?
225 - inherit = _.get(values, inherit) ?? _.get(file.inherited, inherit) ?? _.get(defaultPerms, inherit)! // then show its value instead
229 + // a freshly created node can be selected before `inherited` is filled by a server roundtrip
230 + let inherit = file.inherited?.[perm] ?? getParentInheritedPerm(perm) ?? defaultPerms[perm]
231 + while (typeof inherit === 'string' && _.get(show, inherit) === false) // is 'inherit' referring to another permission that is not displayed?
232 + inherit = _.get(values, inherit)
233 + // non-permission who values (like WHO_ANY_ACCOUNT) are not valid keys for inherited lookup
234 + ?? (inherit !== WHO_ANY_ACCOUNT ? getParentInheritedPerm(inherit) : undefined)
235 + ?? _.get(defaultPerms, inherit)! // then show its value instead
236 return {
237 comp: WhoField,
238 k: perm, sm: 6, lg: 12, xl: 4,
@@ -236,6 +246,21 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
246 }
247 }
248
249 + function getParentInheritedPerm(perm: keyof VfsPerms): Who | undefined {
250 + if (file[perm] !== undefined)
251 + return
252 + let cursor = file.parent
253 + while (cursor) {
254 + let inheritedPerm = cursor[perm]
255 + if (!isWhoObject(inheritedPerm))
256 + return inheritedPerm
257 + inheritedPerm = inheritedPerm.children
258 + if (inheritedPerm !== undefined)
259 + return inheritedPerm
260 + cursor = cursor.parent
261 + }
262 + }
263 +
264 }
265
266 function perm2word(perm: string) {
admin/src/FilePicker.ts
+2 -2
@@ -129,7 +129,7 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
129 checked: sel.includes(it.n),
130 disabled: !folders && isFolder,
131 onClick(ev) {
132 - const id = it.n
132 + const id = it.n + (it.k ? '/' : '')
133 const removed = sel.filter(x => x !== id)
134 setSel(removed.length < sel.length ? removed : [...sel, id])
135 ev.stopPropagation()
@@ -151,7 +151,7 @@ export default function FilePicker({ onSelect, multiple=true, files=true, folder
151 disabled: !sel.length && (!cwd || !folders && files), // !cwd is the drive selection on Windows, which is not a path
152 sx: { minWidth: 'max-content' },
153 onClick() {
154 - onSelect(sel.length ? sel.map(x => cwdDelimiter + x) : [cwd])
154 + onSelect(sel.length ? sel.map(x => cwdDelimiter + x) : [cwd + '/'])
155 }
156 }, files && (sel.length || !folders) ? `Select (${sel.length})` : sm ? "Select this folder" : "This folder"),
157 folders && h(Btn, {
admin/src/VfsMenuBar.ts
+27 -3
@@ -2,12 +2,14 @@
2
3 import { createElement as h, ReactNode } from 'react'
4 import { Alert, Box, ButtonProps, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5 -import { Add, Storage } from '@mui/icons-material'
5 +import { Add, Save, Storage } from '@mui/icons-material'
6 import addFiles, { addLink, addVirtual } from './addFiles'
7 import MenuButton from './MenuButton'
8 import { osIcon } from './LogsPage'
9 import { reloadVfs } from './VfsPage'
10 -import { prefix } from './misc'
10 +import { prefix, VFS_STORED_KEYS } from './misc'
11 +import { state, useSnapState } from './state'
12 +import _ from 'lodash'
13 import { Btn, Flex, reloadBtn, useBreakpoint } from './mui'
14 import { apiCall, ApiObject, useApi } from './api'
15 import VfsPathField from './VfsPathField'
@@ -17,6 +19,7 @@ import { getDiskSpaces } from '../../src/util-os'
19 import { adminApis } from '../../src/adminApis'
20
21 export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusApi: ApiObject }) {
22 + const { vfsModified } = useSnapState()
23 return h(Flex, {
24 zIndex: 2,
25 gap: 1,
@@ -24,6 +27,13 @@ export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusA
27 width: 'fit-content',
28 },
29 h(AddVfsBtn),
30 + h(Btn, {
31 + icon: Save,
32 + title: "Save",
33 + disabled: !vfsModified && "No changes to save",
34 + modified: vfsModified,
35 + onClick: saveVfs
36 + }),
37 reloadBtn(() => reloadVfs()),
38 h(Btn, {
39 icon: Storage,
@@ -49,6 +59,7 @@ export function AddVfsBtn(props: Partial<ButtonProps>) {
59 return h(MenuButton, {
60 variant: 'contained',
61 icon: Add,
62 + title: "Add item to virtual file system",
63 ...props,
64 items: [
65 { children: "virtual folder", onClick: addVirtual },
@@ -87,4 +98,17 @@ function SystemIntegrationButton({ platform }: { platform: string | undefined })
98 onClick: () => apiCall('windows_remove').then(reload),
99 })
100 })
90 -}
\ No newline at end of file
101 +}
102 +
103 +function saveVfs() {
104 + apiCall('set_vfs', { uri: '/', props: recur() })
105 + .then(() => {
106 + state.vfsModified = false
107 + })
108 + //.then(() => toast("Changes saved"))
109 + function recur(n=state.vfs) {
110 + const ret = _.pick(n, VFS_STORED_KEYS)
111 + ret.children = n?.children?.map(recur) as any
112 + return ret
113 + }
114 +}
admin/src/VfsPage.ts
+74 -47
@@ -1,9 +1,9 @@
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, Fragment, useEffect, useMemo, useRef, useState } from 'react'
4 -import { apiCall, useApiEx } from './api'
3 +import { createElement as h, Fragment, useEffect, useMemo, useRef } from 'react'
4 +import { useApiEx } from './api'
5 import { Alert, Box, Button, Card, CardContent, Grid, Link, List, ListItem, ListItemText, Typography } from '@mui/material'
6 -import { state, useSnapState } from './state'
6 +import { markVfsModified, state, useSnapState } from './state'
7 import VfsTree, { vfsNodeIcon } from './VfsTree'
8 import {
9 CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix, VfsNodeAdminSend, HIDE_IN_TESTS, wait
@@ -19,9 +19,9 @@ import { PageProps } from './App'
19
20 let selectOnReload: string[] | undefined
21 let exposeVfsLoading: Promise<unknown> | undefined
22 +export const id2node = new Map<string, VfsNodeAdmin>()
23
24 export default function VfsPage({ setTitleSide }: PageProps) {
24 - const [id2node] = useState(() => new Map<string, VfsNode>())
25 const { vfs, selectedFiles, movingFile } = useSnapState()
26 const { data, reload, element, loading } = useApiEx('get_vfs')
27 exposeVfsLoading = loading
@@ -74,7 +74,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
74 hintElement,
75 ), [hintElement]))
76
77 - const single = selectedFiles?.length < 2 && selectedFiles[0] as VfsNode
77 + const single = selectedFiles?.length < 2 && selectedFiles[0] as VfsNodeAdmin
78 const sideContent = accountsApi.element || !vfs || !selectedFiles.length ? null
79 : single ? h(FileForm, {
80 key: single.id,
@@ -109,7 +109,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
109 const { close } = newDialog({
110 title: selectedFiles.length > 1 ? "Multiple selection" :
111 h(Flex, {},
112 - vfsNodeIcon(selectedFiles[0] as VfsNode),
112 + vfsNodeIcon(selectedFiles[0] as VfsNodeAdmin),
113 h(Flex, { flexWrap: 'wrap', gap: '0 0.5em' },
114 selectedFiles[0].name || "Home",
115 h(Box, { component: 'span', color: 'text.secondary' }, ancestors.join(' /'))
@@ -126,53 +126,72 @@ export default function VfsPage({ setTitleSide }: PageProps) {
126 }, [isSideBreakpoint, _.last(selectedFiles)?.id])
127
128 useEffect(() => {
129 - state.vfs = undefined
130 - if (!data) return
131 - // rebuild id2node
132 - id2node.clear()
129 + if (state.vfs || !data) return
130 const { root } = data
131 if (!root) return
132 root.isRoot = true
136 - recur(root) // this must be done before state change that would cause Tree to render and expecting id2node
133 state.vfs = root
138 - // refresh objects of selectedFiles
139 - state.selectedFiles = consumeSelectOnReload()
140 - || onlyTruthy(state.selectedFiles.map(x => id2node.get(x.id))) // refresh with new objects
134 + reindexVfs({ sortChildren: true, select: consumeSelectOnReload() })
135 + state.vfsModified = false
136
137 function consumeSelectOnReload() {
143 - if (selectOnReload)
144 - closeDialogRef.current() // this is noop when side-paneling
145 - const ret = selectOnReload && onlyTruthy(selectOnReload.map(id => id2node.get(id)))
138 + if (!selectOnReload) return
139 + closeDialogRef.current() // this is noop when side-paneling
140 + const ret = selectOnReload
141 selectOnReload = undefined
142 return ret
143 }
144
150 - // calculate id and parent fields, and builds the map id2node
151 - function recur(node: VfsNode, pre='/', parent: VfsNode|undefined=undefined) {
152 - node.parent = parent
153 - node.id = node.isRoot ? '/' : prefix(pre, pathEncode(node.name), node.type === 'folder' ? '/' : '')
154 - id2node.set(node.id, node)
155 - if (!node.children) return
156 - node.children = _.sortBy(node.children, ['type', x => x.name?.toLocaleLowerCase()])
157 - for (const n of node.children)
158 - recur(n, node.id, node)
159 - }
160 -
161 - }, [data, id2node])
162 - if (element) {
145 + }, [data])
146 + if (element && !state.vfs) {
147 id2node.clear()
148 return element
149 }
150 const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
151 return h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
152 h(Grid, { item: true, xs: 12, [sideBreakpoint]: 5, lg: 6, xl: 5, ...scrollProps },
169 - id2node.size > 0 && h(VfsTree, { id2node, statusApi }) ),
153 + h(VfsTree, { statusApi }) ),
154 isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
155 h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
156 h(CardContent, {}, sideContent)) )
157 )
158 }
159
160 +export function reindexVfs({
161 + node=state.vfs,
162 + clearMap=true,
163 + sortChildren=false,
164 + select=state.selectedFiles,
165 +}: {
166 + node?: VfsNodeAdmin
167 + clearMap?: boolean
168 + sortChildren?: boolean
169 + select?: VfsNodeAdmin[] | string[]
170 +} = {}) {
171 + if (!node) return
172 + if (clearMap)
173 + id2node.clear()
174 + recur(node, node.parent?.id || '/', node.parent)
175 + // Reindex can update ids/references; remap caller-provided selections to canonical nodes from id2node.
176 + if (select)
177 + state.selectedFiles = onlyTruthy(select.map(x => id2node.get(typeof x === 'string' ? x : x.id)))
178 +
179 + function recur(node: VfsNodeAdmin, pre: string, parent: VfsNodeAdmin | undefined) {
180 + const oldId = node.id
181 + node.parent = parent
182 + const newId = node.isRoot ? '/' : prefix(pre, pathEncode(node.name), node.type === 'folder' ? '/' : '')
183 + if (oldId && oldId !== newId)
184 + id2node.delete(oldId)
185 + node.id = newId
186 + id2node.set(newId, node)
187 + if (!node.children) return
188 + if (sortChildren)
189 + node.children = _.sortBy(node.children, ['type', x => x.name?.toLocaleLowerCase()])
190 + for (const child of node.children)
191 + recur(child, node.id, node)
192 + }
193 +}
194 +
195 export function reloadVfs(pleaseSelect?: string[]) {
196 selectOnReload = pleaseSelect
197 state.vfs = undefined
@@ -183,28 +202,36 @@ async function deleteFiles() {
202 const f = state.selectedFiles
203 if (!f.length) return
204 if (!await confirmDialog(`Delete ${f.length} item(s)?`)) return
186 - try {
187 - const uris = f.map(x => x.id).sort()
188 - _.remove(uris, (x, i) => i // exclude first, but remove descendants as they are both redundant and would cause errors
189 - && _.findLastIndex(uris, y => x.startsWith(y), i - 1) !== -1) // search backward among previous elements, as they array is sorted
190 - _.pull(uris, '/')
191 - const { errors } = await apiCall('del_vfs', { uris })
192 - const urisThatFailed = uris.filter((_uri, idx) => errors[idx])
193 - if (urisThatFailed.length)
194 - return alertDialog("Following elements couldn't be deleted: " + urisThatFailed.join(', '), 'error')
195 - reloadVfs()
196 - }
197 - catch(e) {
198 - await alertDialog(e as Error)
205 + deleteVfs(f.map(x => x.id))
206 +}
207 +
208 +export function deleteVfs(uris: string[]) {
209 + const sorted = _.uniq(uris).sort()
210 + const topLevelUris = sorted.filter((uri, idx) => uri !== '/'
211 + && (idx === 0 || _.findLastIndex(sorted, parentUri => isDescendantUri(uri, parentUri), idx - 1) < 0))
212 + if (!topLevelUris.length) return
213 + for (const uri of topLevelUris) {
214 + const node = id2node.get(uri)!
215 + const siblings = node.parent!.children!
216 + _.remove(siblings, { id: node.id })
217 + if (!siblings.length)
218 + node.parent!.children = undefined
219 }
220 + if (state.movingFile && topLevelUris.some(uri => state.movingFile === uri || isDescendantUri(state.movingFile, uri)))
221 + state.movingFile = ''
222 + markVfsModified()
223 +}
224 +
225 +export function isDescendantUri(childUri: string, parentUri: string) {
226 + return parentUri.endsWith('/') && childUri.startsWith(parentUri)
227 }
228
202 -export interface VfsNode extends Omit<VfsNodeAdminSend, 'birthtime' | 'mtime' | 'children'> {
229 +export interface VfsNodeAdmin extends Omit<VfsNodeAdminSend, 'birthtime' | 'mtime' | 'children'> {
230 id: string
231 birthtime?: string
232 mtime?: string
233 default?: string
207 - children?: VfsNode[]
208 - parent?: VfsNode
234 + children?: VfsNodeAdmin[]
235 + parent?: VfsNodeAdmin
236 isRoot?: true
237 }
admin/src/VfsTree.ts
+61 -20
@@ -1,6 +1,6 @@
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 { state, useSnapState } from './state'
3 +import { markVfsModified, state, useSnapState } from './state'
4 import { createElement as h, ReactElement, useCallback, useEffect, useRef, MouseEvent } from 'react'
5 import { TreeItem, TreeView } from '@mui/x-tree-view'
6 import {
@@ -8,11 +8,11 @@ import {
8 RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff, UnfoldMore, UnfoldLess
9 } from '@mui/icons-material'
10 import { Box, Typography } from '@mui/material'
11 -import { reloadVfs, VfsNode } from './VfsPage'
12 -import { onlyTruthy, toMutable, Who, with_ } from './misc'
11 +import { id2node, isDescendantUri, reindexVfs, VfsNodeAdmin } from './VfsPage'
12 +import { onlyTruthy, pathEncode, prefix, toMutable, wantArray, Who, with_ } from './misc'
13 import { Flex, iconTooltip, useToggleButton } from './mui'
14 import VfsMenuBar from './VfsMenuBar'
15 -import { apiCall, ApiObject } from './api'
15 +import { ApiObject } from './api'
16 import { alertDialog, confirmDialog } from './dialog'
17 import _ from 'lodash'
18
@@ -21,10 +21,10 @@ export const FileIcon = InsertDriveFileOutlined
21
22 let once = true
23
24 -export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject }) {
24 +export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
25 const { vfs, selectedFiles, expanded } = useSnapState()
26 const dragging = useRef<string>()
27 - const Branch = useCallback(function({ node }: { node: Readonly<VfsNode> }): ReactElement {
27 + const Branch = useCallback(function({ node }: { node: Readonly<VfsNodeAdmin> }): ReactElement {
28 let { id, name, isRoot } = node
29 const folder = node.type === 'folder'
30 const ref = useRef<HTMLLIElement | null>()
@@ -52,7 +52,7 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
52 const from = dragging.current
53 if (!from) return
54 if (await confirmDialog(`Moving ${from} under ${id}`))
55 - moveVfs(from, id)
55 + await moveVfs(from, id)
56 },
57 sx: {
58 display: 'flex',
@@ -69,8 +69,8 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
69 display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'auto auto', height: '1em',
70 }
71 },
72 - node.can_delete !== undefined && iconTooltip(Delete, "Delete permission"),
73 - node.can_upload !== undefined && iconTooltip(Upload, "Upload permission"),
72 + node.can_delete != null && iconTooltip(Delete, "Delete permission"),
73 + node.can_upload != null && iconTooltip(Upload, "Upload permission"),
74 !isRoot && !node.source && !node.url && iconTooltip(Cloud, "Virtual (no source)"),
75 isRestricted(node.can_see) && iconTooltip(RemoveRedEye, "Restrictions on who can see"),
76 isRestricted(node.can_read) && iconTooltip(Lock, "Restrictions on who can download"),
@@ -90,7 +90,7 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
90 : node.children?.map(x => h(Branch, { key: x.id, node: x })) )
91
92 function isRestricted(who: Who | undefined) {
93 - return who !== undefined && who !== true
93 + return who != null && who !== true
94 }
95
96 function toggle(ev: MouseEvent<any>){
@@ -101,7 +101,7 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
101 }
102 }, [statusApi.data])
103 const ref = useRef<HTMLUListElement>(null)
104 - const allExpanded = expanded.length === id2node.size
104 + const allExpanded = id2node.size > 0 && expanded.length === id2node.size
105 const initialExpansion = ['/', ...vfs?.children?.length === 1 ? [vfs.children[0].id] : []] // in case there's only one child, expand that too
106 if (once) {
107 once = false
@@ -140,23 +140,64 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
140 '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
141 },
142 onNodeSelect(_ev, ids) {
143 - if (typeof ids === 'string') return // shut up ts
144 - state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
143 + state.selectedFiles = onlyTruthy(wantArray(ids).map(id => id2node.get(id)))
144 }
146 - }, h(Branch, { node: vfs as Readonly<VfsNode> }))
145 + }, h(Branch, { node: vfs as Readonly<VfsNodeAdmin> }))
146 )
147 }
148
149 export function moveVfs(from: string, to: string) {
151 - return apiCall('move_vfs', { from, parent: to }).then(() => {
152 - reloadVfs([ to + from.slice(1 + from.lastIndexOf('/', from.length-2)) ])
153 - return true
154 - }, alertDialog)
150 + const fromNode = id2node.get(from)
151 + if (!fromNode)
152 + return alertDialog("Item to move not found", 'error').then(() => false)
153 + if (fromNode.isRoot)
154 + return alertDialog("Cannot move root", 'error').then(() => false)
155 + const toNode = id2node.get(to)
156 + if (!toNode || toNode.type !== 'folder')
157 + return alertDialog("Destination folder not found", 'error').then(() => false)
158 + if (isDescendantUri(to, from))
159 + return alertDialog("Cannot move inside itself", 'error').then(() => false)
160 + if (toNode.children?.find(x => x.name === fromNode.name))
161 + return alertDialog("Item with same name already present in destination", 'error').then(() => false)
162 + const oldSiblings = fromNode.parent?.children
163 + if (!oldSiblings)
164 + return alertDialog("Source parent not found", 'error').then(() => false)
165 + const fromParent = fromNode.parent
166 + const movedName = fromNode.name
167 + const movedIsFolder = fromNode.type === 'folder'
168 + const destinationAncestors = getAncestorIds(toNode)
169 + _.remove(oldSiblings, { id: fromNode.id })
170 + if (!oldSiblings.length && fromParent)
171 + fromParent.children = undefined
172 + addToChildrenOf(toNode, [fromNode])
173 + const movedId = prefix(to, pathEncode(movedName), movedIsFolder ? '/' : '')
174 + reindexVfs({ select: [movedId] })
175 + state.expanded = _.uniq([...state.expanded, ...destinationAncestors])
176 + return Promise.resolve(true)
177 +
178 + function getAncestorIds(node: VfsNodeAdmin) {
179 + const ret: string[] = []
180 + let cur: typeof node | undefined = node
181 + while (cur) {
182 + ret.push(cur.id)
183 + cur = cur.parent
184 + }
185 + return ret
186 + }
187 }
188
157 -export function vfsNodeIcon(node: VfsNode) {
189 +export function vfsNodeIcon(node: VfsNodeAdmin) {
190 return node.isRoot ? iconTooltip(Home, "home, or root if you like")
191 : node.type === 'folder' ? iconTooltip(FolderIcon, "Folder")
192 : node.url ? iconTooltip(Link, "Web-link")
193 : iconTooltip(FileIcon, "File")
162 -}
\ No newline at end of file
194 +}
195 +
196 +export function addToChildrenOf(parent: VfsNodeAdmin, moreChildren: VfsNodeAdmin[]) {
197 + if (!parent.children)
198 + parent.children = []
199 + // keep the assignment above and push separated: on proxied nodes, combining them will push to a stale array reference.
200 + parent.children.push(...moreChildren)
201 +
202 + markVfsModified()
203 +}
admin/src/addFiles.ts
+44 -24
@@ -3,11 +3,11 @@
3 import { alertDialog, newDialog, promptDialog, toast } from './dialog'
4 import { createElement as h, Fragment } from 'react'
5 import { Box } from '@mui/material'
6 -import { reloadVfs } from './VfsPage'
6 +import { reindexVfs, VfsNodeAdmin } from './VfsPage'
7 +import { addToChildrenOf } from './VfsTree'
8 import { state } from './state'
8 -import { apiCall } from './api'
9 import FilePicker from './FilePicker'
10 -import { focusSelector, pathEncode } from '@hfs/shared'
10 +import { basename, extname, focusSelector } from '@hfs/shared'
11
12 let lastFolder: undefined | string
13 export default function addFiles() {
@@ -24,20 +24,8 @@ export default function addFiles() {
24 h(FilePicker, {
25 from: lastFolder ?? parent.source,
26 async onSelect(sel) {
27 - const res = await Promise.all(sel.map(source =>
28 - apiCall('add_vfs', { parent: parent.id, source }).then(r => r, e => [source, e.message])))
27 + addNodes(parent, sel.map(source => ({ source, name: basename(source), id: '' })))
28 lastFolder = sel[0].slice(0, sel[0].lastIndexOf('/'))
30 - const errs = res.filter(Array.isArray)
31 - if (errs.length)
32 - await alertDialog(h(Box, {},
33 - "Some elements have been rejected",
34 - h('ul', {},
35 - errs.map(([file, err]) =>
36 - h('li', { key: file }, file, ': ', err))
37 - )
38 - ), 'error')
39 - const ids = res.filter(x => x.name).map(x => parent.id + pathEncode(x.name) + (x.link.endsWith('/') ? '/' : ''))
40 - reloadVfs(ids)
29 close()
30 }
31 })
@@ -46,14 +34,45 @@ export default function addFiles() {
34 })
35 }
36
37 +function addNodes(parent: VfsNodeAdmin, nodes: VfsNodeAdmin[]) {
38 + for (const n of nodes) {
39 + if (n.source?.endsWith('/') || !n.source && !n.url)
40 + n.type = 'folder'
41 + n.id ||= parent.id + n.name + (n.type === 'folder' ? '/' : '')
42 + n.parent = parent
43 + }
44 + addToChildrenOf(parent, nodes)
45 + reindexVfs({ select: nodes })
46 +}
47 +
48 +function getFreeName(parent: VfsNodeAdmin, name: string) {
49 + const ext = extname(name)
50 + const noExt = ext ? name.slice(0, -ext.length) : name
51 + let idx = 2
52 + while (parent.children?.find(isSameFilenameAs(name)))
53 + name = `${noExt} ${idx++}${ext}`
54 + return name
55 +}
56 +
57 +function normalizeFilename(x: string) {
58 + return x.toLocaleLowerCase().normalize() // in this context we always use lowercase for comparison
59 +}
60 +
61 +export function isSameFilenameAs(name: string) {
62 + const normalized = normalizeFilename(name)
63 + return (other: string | VfsNodeAdmin) =>
64 + normalized === normalizeFilename(typeof other === 'string' ? other : other.name)
65 +}
66 +
67 export async function addVirtual() {
68 try {
51 - const name = await promptDialog("Enter folder name")
69 + let name = await promptDialog("Enter folder name")
70 if (!name) return
53 - const { id: parent } = getFolderFromSelected()
54 - const res = await apiCall('add_vfs', { parent, name })
55 - toast(`Folder "${res.name}" created`, 'success') // the name may have a number appended
56 - reloadVfs([ parent + pathEncode(res.name) + '/' ])
71 + const parent = getFolderFromSelected()
72 + name = getFreeName(parent, name)
73 + if (!name) return
74 + addNodes(parent, [{ name, id: '', type: 'folder' }])
75 + toast(`Folder "${name}" created`, 'success') // the name may have a number appended
76 }
77 catch(e) {
78 await alertDialog(e as Error)
@@ -62,9 +81,10 @@ export async function addVirtual() {
81
82 export async function addLink() {
83 try {
65 - const { id: parent } = getFolderFromSelected()
66 - const res = await apiCall('add_vfs', { parent, name: 'new link', url: 'https://example.com' })
67 - reloadVfs([ parent + pathEncode(res.name) ])
84 + const parent = getFolderFromSelected()
85 + const name = getFreeName(parent, 'new link')
86 + if (!name) return
87 + addNodes(parent, [{ name, url: 'https://example.com', id: '' }])
88 toast("Link created", 'success', {
89 onClose: () => focusSelector('input[name=url]')
90 })
admin/src/state.ts
+11 -4
@@ -2,7 +2,7 @@
2
3 import { proxy, useSnapshot } from 'valtio'
4 import { Dict } from './misc'
5 -import { VfsNode } from './VfsPage'
5 +import { reindexVfs, VfsNodeAdmin } from './VfsPage'
6 import _ from 'lodash'
7 import { subscribeKey } from 'valtio/utils'
8 import { produce } from 'immer'
@@ -11,10 +11,11 @@ const STORAGE_KEY = 'admin_state'
11 const INIT = {
12 title: '',
13 config: {} as Dict,
14 - selectedFiles: [] as VfsNode[],
14 + selectedFiles: [] as VfsNodeAdmin[],
15 accountsAsTree: false,
16 movingFile: '',
17 - vfs: undefined as VfsNode | undefined,
17 + vfs: undefined as VfsNodeAdmin | undefined,
18 + vfsModified: false,
19 expanded: [] as string[],
20 loginRequired: false as boolean | number,
21 username: '',
@@ -45,7 +46,13 @@ export function useSnapState() {
46 return useSnapshot(state)
47 }
48
49 +export function markVfsModified() {
50 + state.vfs = { ...state.vfs! }
51 + state.vfsModified = true
52 + reindexVfs()
53 +}
54 +
55 // use this to reflect a deep change in an object to its root, so that valtio is triggered
56 export function updateStateObject(obj: any, k: string, cb: (x: any) => void) {
57 obj[k] = produce(obj[k], cb)
51 -}
\ No newline at end of file
58 +}
e2e/admin-vfs.spec.ts new
+199
@@ -0,0 +1,199 @@
1 +import { expect, Page, test } from '@playwright/test'
2 +import { clickAdminMenu, URL, username, password } from './common'
3 +
4 +async function selectVfsNode(page: Page, name: string, expectedId: string) {
5 + await page.getByRole('treeitem', { name, exact: true }).click()
6 + await expect.poll(() => page.evaluate(() => (window as any).state?.selectedFiles?.[0]?.id || ''))
7 + .toBe(expectedId)
8 +}
9 +
10 +async function pasteMovingNode(page: Page, movingName: string) {
11 + await page.getByRole('button', { name: new RegExp(movingName) }).click()
12 +}
13 +
14 +async function expandVfsNode(page: Page, nodeId: string) {
15 + // Mobile uses a details dialog for selection, so forcing expansion via app state keeps this test focused on move behavior.
16 + await expect.poll(() => page.evaluate(id => {
17 + const state = (window as any).state
18 + if (!state) return false
19 + if (!state.expanded.includes(id))
20 + state.expanded = [...state.expanded, id]
21 + return state.expanded.includes(id)
22 + }, nodeId)).toBe(true)
23 +}
24 +
25 +test('move via cut/paste keeps node visible', async ({ page }) => {
26 + await page.goto(URL + '~/admin/')
27 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
28 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
29 + await page.getByRole('textbox', { name: 'Password' }).press('Enter')
30 + await clickAdminMenu(page, /Shared files/)
31 + await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
32 +
33 + await selectVfsNode(page, 'zipNoList', '/zipNoList/')
34 + await page.getByRole('button', { name: 'Cut' }).click()
35 + await page.getByRole('button', { name: 'Close' }).click()
36 + await selectVfsNode(page, 'f1', '/f1/')
37 + await pasteMovingNode(page, 'zipNoList')
38 +
39 + await expect.poll(() => page.evaluate(() => {
40 + function find(node: any, name: string): any {
41 + if (!node) return
42 + if (node.name === name) return node
43 + for (const child of node.children || []) {
44 + const found = find(child, name)
45 + if (found) return found
46 + }
47 + }
48 + const root = (window as any).state?.vfs
49 + const moved = find(root, 'zipNoList')
50 + const destination = find(root, 'f1')
51 + return {
52 + rootHasMoved: (root?.children || []).some((x: any) => x.name === 'zipNoList'),
53 + destinationHasMoved: (destination?.children || []).some((x: any) => x.name === 'zipNoList'),
54 + movedParent: moved?.parent?.id,
55 + }
56 + })).toEqual({
57 + rootHasMoved: false,
58 + destinationHasMoved: true,
59 + movedParent: '/f1/',
60 + })
61 +})
62 +
63 +test('move to nested destination expands ancestors', async ({ page }) => {
64 + await page.goto(URL + '~/admin/')
65 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
66 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
67 + await page.getByRole('textbox', { name: 'Password' }).press('Enter')
68 + await clickAdminMenu(page, /Shared files/)
69 + await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
70 +
71 + await selectVfsNode(page, 'zipNoList', '/zipNoList/')
72 + await page.getByRole('button', { name: 'Cut' }).click()
73 + await page.getByRole('button', { name: 'Close' }).click()
74 + await expandVfsNode(page, '/protectFromAbove/')
75 + await selectVfsNode(page, 'child', '/protectFromAbove/child/')
76 + await pasteMovingNode(page, 'zipNoList')
77 +
78 + await expect.poll(() => page.evaluate(() => {
79 + function find(node: any, name: string): any {
80 + if (!node) return
81 + if (node.name === name) return node
82 + for (const child of node.children || []) {
83 + const found = find(child, name)
84 + if (found) return found
85 + }
86 + }
87 + const root = (window as any).state?.vfs
88 + const moved = find(root, 'zipNoList')
89 + const destination = find(root, 'child')
90 + const expanded = (window as any).state?.expanded || []
91 + return {
92 + destinationHasMoved: (destination?.children || []).some((x: any) => x.name === 'zipNoList'),
93 + movedParent: moved?.parent?.id,
94 + hasDestinationExpanded: expanded.includes('/protectFromAbove/child/'),
95 + hasAncestorExpanded: expanded.includes('/protectFromAbove/'),
96 + }
97 + })).toEqual({
98 + destinationHasMoved: true,
99 + movedParent: '/protectFromAbove/child/',
100 + hasDestinationExpanded: true,
101 + hasAncestorExpanded: true,
102 + })
103 +})
104 +
105 +test('move into empty folder keeps node visible', async ({ page }) => {
106 + await page.goto(URL + '~/admin/')
107 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
108 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
109 + await page.getByRole('textbox', { name: 'Password' }).press('Enter')
110 + await clickAdminMenu(page, /Shared files/)
111 + await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
112 +
113 + await selectVfsNode(page, 'zipNoList', '/zipNoList/')
114 + await page.getByRole('button', { name: 'Cut' }).click()
115 + await page.getByRole('button', { name: 'Close' }).click()
116 + await selectVfsNode(page, 'for-disabled', '/for-disabled/')
117 + await pasteMovingNode(page, 'zipNoList')
118 +
119 + await expect.poll(() => page.evaluate(() => {
120 + function find(node: any, name: string): any {
121 + if (!node) return
122 + if (node.name === name) return node
123 + for (const child of node.children || []) {
124 + const found = find(child, name)
125 + if (found) return found
126 + }
127 + }
128 + const root = (window as any).state?.vfs
129 + const moved = find(root, 'zipNoList')
130 + const destination = find(root, 'for-disabled')
131 + return {
132 + rootHasMoved: (root?.children || []).some((x: any) => x.name === 'zipNoList'),
133 + destinationHasMoved: (destination?.children || []).some((x: any) => x.name === 'zipNoList'),
134 + movedParent: moved?.parent?.id,
135 + }
136 + })).toEqual({
137 + rootHasMoved: false,
138 + destinationHasMoved: true,
139 + movedParent: '/for-disabled/',
140 + })
141 +})
142 +
143 +test('delete virtual folder updates tree and marks modified', async ({ page }) => {
144 + await page.goto(URL + '~/admin/')
145 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
146 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
147 + await page.getByRole('textbox', { name: 'Password' }).press('Enter')
148 + await clickAdminMenu(page, /Shared files/)
149 + await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
150 +
151 + const folderName = 'for-disabled'
152 + await selectVfsNode(page, folderName, '/for-disabled/')
153 + await page.getByRole('button', { name: 'Delete' }).first().click()
154 + const confirm = page.locator('.dialog-confirm')
155 + await expect(confirm).toBeVisible()
156 + await confirm.locator('a').first().click()
157 +
158 + await expect.poll(() => page.evaluate(name => {
159 + const root = (window as any).state?.vfs
160 + return {
161 + hasFolder: (root?.children || []).some((x: any) => x.name === name),
162 + modified: (window as any).state?.vfsModified,
163 + }
164 + }, folderName)).toEqual({
165 + hasFolder: false,
166 + modified: true,
167 + })
168 +})
169 +
170 +test('apply keeps unset permissions nullish in-memory', async ({ page }) => {
171 + await page.goto(URL + '~/admin/')
172 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
173 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
174 + await page.getByRole('textbox', { name: 'Password' }).press('Enter')
175 + await clickAdminMenu(page, /Shared files/)
176 + await page.getByText('zipNoList', { exact: true }).waitFor({ timeout: 10_000 })
177 +
178 + await selectVfsNode(page, 'f1', '/f1/')
179 + await page.locator('button:has-text("Apply")').click()
180 +
181 + await expect.poll(() => page.evaluate(() => {
182 + function findById(node: any, id: string): any {
183 + if (!node) return
184 + if (node.id === id) return node
185 + for (const child of node.children || []) {
186 + const found = findById(child, id)
187 + if (found) return found
188 + }
189 + }
190 + // On mobile, applying from the details dialog can clear selectedFiles on dialog close; assert against canonical VFS node instead.
191 + const node = findById((window as any).state?.vfs, '/f1/')
192 + const unsetPerms = ['can_see', 'can_read', 'can_list', 'can_upload', 'can_delete', 'can_archive']
193 + .filter(k => node?.[k] == null)
194 + return { id: node?.id, unsetPerms }
195 + })).toEqual({
196 + id: '/f1/',
197 + unsetPerms: ['can_see', 'can_read', 'can_list', 'can_upload', 'can_delete', 'can_archive'],
198 + })
199 +})
e2e/common.ts
+12 -1
@@ -22,4 +22,15 @@ export function resetTimestamp() {
22
23 export function forwardConsole(page: Page) {
24 page.on('console', msg => console.log(msg.type(), msg.text()));
25 -}
\ No newline at end of file
25 +}
26 +
27 +export async function clickAdminMenu(page: Page, sectionName: string | RegExp) {
28 + const isPhone = await page.evaluate(() => window.matchMedia('(max-width: 600px)').matches)
29 + if (isPhone) {
30 + // On phones, admin navigation links are rendered inside a drawer that must be opened first.
31 + await page.getByRole('button', { name: 'menu' }).nth(0).click()
32 + }
33 + await page.getByRole('link', { name: sectionName }).click()
34 + // The admin page content updates asynchronously after route changes; this avoids transient flakiness across tests.
35 + await page.waitForTimeout(100)
36 +}
e2e/frontend.spec.ts
+6 -9
@@ -1,10 +1,11 @@
1 import { test, expect, Page } from '@playwright/test'
2 import fs from 'fs'
3 import { wait } from '../src/cross'
4 -import { password, resetTimestamp, URL, username } from './common'
4 +import { clickAdminMenu, forwardConsole, password, resetTimestamp, URL, username } from './common'
5
6 // a generic test touch several parts
7 test('around1', async ({ page }) => {
8 + forwardConsole(page)
9 resetTimestamp()
10 await page.goto(URL)
11 await expect(page).toHaveTitle(/File server/)
@@ -390,13 +391,6 @@ async function loginAdmin(page: Page) {
391 return isPhone
392 }
393
393 -async function clickAdminMenu(page: Page, text: string) {
394 - if ((page as AdminPage).isPhone)
395 - await page.getByRole('button', { name: 'menu' }).nth(0).click() // on phones the menu is popup
396 - await page.getByRole('link', { name: text }).click()
397 - await page.waitForTimeout(100)
398 -}
399 -
394 async function closeAdminPhoneDialog(page: Page, isPhone: boolean) {
395 // On phones, detail pages are shown in dialogs and block the menu behind them.
396 if (isPhone)
@@ -416,6 +410,7 @@ async function screenshot(page: Page, selectorForMask = '') {
410 }
411
412 test('anew', async ({ page, browserName }) => {
413 + forwardConsole(page)
414 if (page.viewportSize()?.width! < 1000 || browserName !== 'chromium') return // test only for desktop chromium
415 // reset config so each run starts from the same default workspace state
416 const port = 8082
@@ -453,13 +448,15 @@ test('anew', async ({ page, browserName }) => {
448 await adminPage.getByRole('button', { name: 'Cut' }).click()
449 await adminPage.locator('div').filter({ hasText: 'InfoNow that this is marked' }).nth(1).click()
450 await adminPage.getByRole('button', { name: 'Close' }).click()
456 - await adminPage.getByText('Home folder').click()
451 + await adminPage.getByRole('treeitem', { name: 'Home folder', exact: true })
452 + .getByText('Home folder', { exact: true }).click()
453 await adminPage.getByRole('button', { name: '(/work2/folder1/)' }).click() // paste button
454 await adminPage.getByText('data.kv').click()
455 await adminPage.getByRole('button', { name: 'Cut' }).click()
456 await adminPage.getByRole('button', { name: 'Close' }).click()
457 await adminPage.getByText('folder1').click()
458 await adminPage.getByRole('button', { name: '(/data.kv)' }).click() // paste
459 + await adminPage.getByRole('button', { name: 'Save' }).click()
460 await page.getByRole('button', { name: 'Close' }).click()
461 await page.getByRole('link', { name: 'home' }).click()
462 await page.getByRole('link', { name: 'Reload' }).click()
frontend/src/clip.ts
+1 -2
@@ -3,11 +3,10 @@ import { DirList, state, useSnapState } from './state'
3 import { Btn } from './components'
4 import { alertDialog, toast } from './dialog'
5 import { useNavigate } from 'react-router-dom'
6 -import { dirname, HTTP_MESSAGES, xlate } from '../../src/cross'
6 import { apiCall } from '@hfs/shared/api'
7 import { reloadList, usePath } from './useFetchList'
8 import _ from 'lodash'
10 -import { hfsEvent } from './misc'
9 +import { HTTP_MESSAGES, xlate, dirname, hfsEvent } from './misc'
10 import i18n from './i18n'
11 const { t, useI18N } = i18n
12
shared/index.ts
+14
@@ -178,6 +178,20 @@ export function fallbackToBasicAuth() {
178 return BigInt === Number
179 }
180
181 +export function basename(path: string) {
182 + return path.match(/([^\\/]+)[\\/]*$/)?.[1] || ''
183 +}
184 +
185 +export function extname(path: string) {
186 + const name = basename(path)
187 + const i = name.lastIndexOf('.')
188 + return i <= 0 ? '' : name.slice(i)
189 +}
190 +
191 +export function dirname(path: string) {
192 + return path.slice(0, Math.max(0, path.lastIndexOf('/', path.length - 1)))
193 +}
194 +
195 type DurationUnit = 'day' | 'hour' | 'minute' | 'second'
196 export function createDurationFormatter({ locale=undefined, unitDisplay='narrow', largest='day', smallest='second', maxTokens, skipZeroes }:
197 { skipZeroes?: boolean, largest?: DurationUnit, smallest?: DurationUnit, locale?: string, unitDisplay?: 'long' | 'short' | 'narrow', maxTokens?: 1 | 2 | 3 }={}) {
src/api.vfs.ts
+40 -29
@@ -2,7 +2,7 @@
2
3 import {
4 getNodeName, isSameFilenameAs, nodeIsFolder, saveVfs, urlToNode, vfs, VfsNode, applyParentToChild,
5 - permsFromParent, VfsNodeStored, isRoot, nodeStats
5 + permsFromParent, isRoot, nodeStats
6 } from './vfs'
7 import _ from 'lodash'
8 import { mkdir } from 'fs/promises'
@@ -10,7 +10,7 @@ import { ApiError, ApiHandlers } from './apiMiddleware'
10 import { dirname, extname, join, resolve } from 'path'
11 import {
12 enforceFinal, enforceStarting, isDirectory, isValidFileName, isWindowsDrive, makeMatcher, PERM_KEYS,
13 - statWithTimeout, VfsNodeAdminSend
13 + VFS_STORED_KEYS, statWithTimeout, VfsNodeAdminSend
14 } from './misc'
15 import {
16 IS_WINDOWS, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE,
@@ -27,9 +27,6 @@ async function urlToNodeOriginal(uri: string) {
27 return n?.isTemp ? n.original : n
28 }
29
30 -const ALLOWED_KEYS: (keyof VfsNodeStored)[] = ['name', 'source', 'masks', 'default', 'accept', 'rename', 'mime', 'url',
31 - 'target', 'comment', 'icon', 'order', ...PERM_KEYS]
32 -
30 export interface LsEntry { n:string, s?:number, m?:string, c?:string, k?:'d' }
31
32 export default {
@@ -66,6 +63,25 @@ export default {
63 }
64 },
65
66 + async set_vfs({ uri, props }) {
67 + const n = uri && await urlToNodeOriginal(uri)
68 + if (!n)
69 + return new ApiError(HTTP_NOT_FOUND, 'path not found')
70 + if (props.name && props.name !== getNodeName(n)) {
71 + if (!isValidFileName(props.name))
72 + return new ApiError(HTTP_BAD_REQUEST, 'bad name')
73 + const parent = await urlToNodeOriginal(dirname(uri))
74 + if (parent?.children?.find(x => getNodeName(x) === props.name))
75 + return new ApiError(HTTP_CONFLICT, 'name already present')
76 + }
77 + Object.assign(n, sanitizeVfsProps(props))
78 + simplifyName(n)
79 + n.isFolder = undefined // reset field, it will be set by saveVfs
80 + await saveVfs()
81 + return n
82 + },
83 +
84 + // legacy – not currently used by the UI
85 async move_vfs({ from, parent }) {
86 if (!from || !parent)
87 return new ApiError(HTTP_BAD_REQUEST)
@@ -84,33 +100,15 @@ export default {
100 return new ApiError(HTTP_CONFLICT, 'item with same name already present in destination')
101 const oldParent = await urlToNodeOriginal(dirname(from))
102 _.pull(oldParent!.children!, fromNode)
87 - if (_.isEmpty(oldParent!.children))
103 + if (_.isEmpty(oldParent!.children)) {
104 delete oldParent!.children
105 + }
106 ;(parentNode.children ||= []).push(fromNode)
107 await saveVfs()
108 return {}
109 },
110
94 - async set_vfs({ uri, props }) {
95 - const n = await urlToNodeOriginal(uri)
96 - if (!n)
97 - return new ApiError(HTTP_NOT_FOUND, 'path not found')
98 - if (props.name && props.name !== getNodeName(n)) {
99 - if (!isValidFileName(props.name))
100 - return new ApiError(HTTP_BAD_REQUEST, 'bad name')
101 - const parent = await urlToNodeOriginal(dirname(uri))
102 - if (parent?.children?.find(x => getNodeName(x) === props.name))
103 - return new ApiError(HTTP_CONFLICT, 'name already present')
104 - }
105 - if (props.masks && typeof props.masks !== 'object')
106 - delete props.masks
107 - Object.assign(n, pickProps(props, ALLOWED_KEYS))
108 - simplifyName(n)
109 - n.isFolder = undefined // reset field, it will be set by saveVfs
110 - await saveVfs()
111 - return n
112 - },
113 -
111 + // legacy – not currently used by the UI
112 async add_vfs({ parent, source, name, ...rest }) {
113 if (!source && !name)
114 return new ApiError(HTTP_BAD_REQUEST, 'name or source required')
@@ -126,7 +124,7 @@ export default {
124 const isFolder = source && await isDirectory(source)
125 if (source && isFolder === undefined)
126 return new ApiError(HTTP_NOT_FOUND, 'source not found')
129 - const child = { source, name, ...pickProps(rest, ALLOWED_KEYS) }
127 + const child = { source, name, ...sanitizeVfsProps(rest) }
128 name = getNodeName(child) // could be not given as input
129 const ext = extname(name)
130 const noExt = ext ? name.slice(0, -ext.length) : name
@@ -144,6 +142,7 @@ export default {
142 return { name, link }
143 },
144
145 + // legacy – not currently used by the UI
146 async del_vfs({ uris }) {
147 if (!uris || !Array.isArray(uris))
148 return new ApiError(HTTP_BAD_REQUEST, 'bad uris')
@@ -276,12 +275,24 @@ export default {
275 export function pickProps(o: any, keys: string[]) {
276 const ret: any = {}
277 if (o && typeof o === 'object')
279 - for (const k of keys)
280 - if (k in o)
278 + for (const k in o)
279 + if (keys.includes(k))
280 ret[k] = o[k] === null || o[k] === '' ? undefined : o[k]
281 return ret
282 }
283
284 +function sanitizeVfsProps(props: any) {
285 + const ret = pickProps(props, VFS_STORED_KEYS)
286 + if (ret.masks && typeof ret.masks !== 'object')
287 + ret.masks = undefined
288 + if (props?.children === null)
289 + delete ret.children
290 + else if (Array.isArray(props?.children))
291 + ret.children = !props.children.length ? undefined
292 + : props.children.map(sanitizeVfsProps)
293 + return ret
294 +}
295 +
296 export function simplifyName(node: VfsNode) {
297 const { name, ...noName } = node
298 if (getNodeName(noName) === name)
src/cross.ts
+3 -8
@@ -89,6 +89,9 @@ export type VfsNodeAdminSend = {
89
90 export const PERM_KEYS = typedKeys(defaultPerms)
91
92 +export const VFS_STORED_KEYS: (keyof VfsNodeStored)[] = ['name', 'source', 'masks', 'default', 'accept', 'rename',
93 + 'mime', 'url', 'target', 'comment', 'icon', 'order', 'children', ...PERM_KEYS]
94 +
95 function constMap<T extends string>(a: T[]): { [K in T]: K } {
96 return Object.fromEntries(a.map(x => [x, x])) as { [K in T]: K };
97 }
@@ -233,14 +236,6 @@ export function pendingPromise<T>() {
236 return Object.assign(ret, takeOut) as PendingPromise<T>
237 }
238
236 -export function basename(path: string) {
237 - return path.match(/([^\\/]+)[\\/]*$/)?.[1] || ''
238 -}
239 -
240 -export function dirname(path: string) {
241 - return path.slice(0, Math.max(0, path.lastIndexOf('/', path.length - 1)))
242 -}
243 -
239 export function tryJson(s?: string, except?: (s?: string) => unknown) {
240 try { return s && JSON.parse(s) }
241 catch { return except?.(s) }
src/serveFile.ts
+3 -3
@@ -32,7 +32,7 @@ export function forceDownload(ctx: Koa.Context, name: string) {
32 }
33
34 export function disposition(ctx: Koa.Context, name: string, forceDownload=false) {
35 - // ctx.attachment is not working well on Windows. Eg: for file "èÖ.txt" it is producing `Content-Disposition: attachment; filename="??.txt"`. Koa uses module content-disposition, that actually produces a better result anyway: ``
35 + // ctx.attachment is not working well on Windows. Eg: for the file "èÖ.txt" it is producing `Content-Disposition: attachment; filename="??.txt"`. Koa uses module content-disposition, that actually produces a better result anyway: ``
36 ctx.set('Content-Disposition', (forceDownload ? 'attachment; ' : '')
37 + `filename="${toAsciiEquivalent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`)
38 }
@@ -41,10 +41,10 @@ export async function serveFileNode(ctx: Koa.Context, node: VfsNode) {
41 const { source, mime } = node
42 const name = getNodeName(node)
43 const mimeString = typeof mime === 'string' ? mime
44 - : _.find(mime, (val,mask) => matches(name, mask))
44 + : _.find(mime, (_val,mask) => matches(name, mask))
45 if (allowedReferer.get()) {
46 const ref = try_(() => new URL(ctx.get('referer')||'').host)
47 - if (ref && ref !== ctx.host // automatically accept if referer is basically the hosting domain
47 + if (ref && ref !== ctx.host // automatically accept if the referer is basically the hosting domain
48 && !matches(ref, allowedReferer.get()))
49 return ctx.status = HTTP_FORBIDDEN
50 }
tests/config.yaml
+3 -6
@@ -31,8 +31,7 @@ vfs:
31 - name: pic
32 mime: png
33 source: ../page/gpl.png
34 - - name: page
35 - source: ../page
34 + - source: ../page
35 default: index.html
36 - name: protected
37 source: ../page/gpl.png
@@ -51,11 +50,9 @@ vfs:
50 can_delete:
51 - admins
52 children:
54 - - name: no-upload
55 - source: ../tmp/no-upload
53 + - source: ../tmp/no-upload
54 can_upload: false
57 - - name: cant-overwrite
58 - source: ../work/cant-overwrite
55 + - source: ../work/cant-overwrite
56 can_upload:
57 - admins
58 can_delete: false
tests/test.ts
+25
@@ -552,6 +552,31 @@ describe('admin', () => {
552 await reqApi('del_vfs', { uris: ['/'+name] }, data => data?.errors?.[0] === 0, { auth })() // remove
553 }
554 })
555 + test('set_vfs.rename and props', async () => {
556 + const name = `set-vfs-${randomId(6)}`
557 + const renamed = `${name}-renamed`
558 + const uri = '/' + name
559 + const renamedUri = '/' + renamed
560 + try {
561 + await reqApi('add_vfs', { source: '.', name }, 200, { auth })()
562 + await reqApi('set_vfs', { uri, props: { name: renamed, comment: 'test note', can_list: false } }, 200, { auth })()
563 + await reqApi('get_vfs', {}, res => {
564 + const children = res?.root?.children || []
565 + const oldNode = _.find(children, { name })
566 + const renamedNode = _.find(children, { name: renamed })
567 + throwIf(oldNode ? 'old node still present'
568 + : !renamedNode ? 'renamed node missing'
569 + : renamedNode.comment !== 'test note' ? 'comment not updated'
570 + : renamedNode.can_list !== false ? 'can_list not updated' : '')
571 + }, { auth })()
572 + }
573 + finally {
574 + await reqApi('del_vfs', { uris: [renamedUri] }, data =>
575 + [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {})
576 + await reqApi('del_vfs', { uris: [uri] }, data =>
577 + [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {})
578 + }
579 + })
580 test('del_vfs.bad uris', reqApi('del_vfs', { uris: ['', '/', '//'] }, (res: any) =>
581 throwIf(res?.errors.some((x: any) => x === 406) ? '' : res?.errors || 'missing'), { auth }))
582 test('plugins.missing', reqApi('set_plugin', { id: 'missing-plugin', enabled: true }, { status: 400, re: /miss/ }, { auth }))