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