admin/options: show_uploader
Massimo Melina committed
May 13, 2026 at 11:28 UTC
597e36cdb2f628c98f326d53e847711c3fd5be14
9 files changed
+119
-81
admin/src/FileForm.ts
+30
-16
@@ -6,10 +6,10 @@ import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList, useThem
6
import {
7
BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, NumberField, SelectField, StringField
8
} from '@hfs/mui-grid-form'
9
-import { apiCall, UseApi } from './api'
9
+import { apiCall, UseApi, useApiEx } from './api'
10
import {
11
basename, defaultPerms, formatBytes, formatTimestamp, isWhoObject, newDialog, objSameKeys,
12
- onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, xlate, md, Callback, MASK_IN_TESTS,
12
+ onlyTruthy, prefix, VfsPerms, wantArray, WhoVfs, WhoObject, matches, xlate, md, Callback, MASK_IN_TESTS,
13
useRequestRender, splitAt, IMAGE_FILEMASK, copyTextToClipboard, normalizeHost, CFG, try_, WHO_ANY_ACCOUNT,
14
} from './misc'
15
import { isModifiedConfig } from './AccountForm'
@@ -28,7 +28,8 @@ import { AddVfsBtn } from './VfsMenuBar'
28
import { SYS_ICONS } from '@hfs/frontend/src/sysIcons'
29
import { hIcon } from '@hfs/frontend/src/misc'
30
import { TextEditorField } from './TextEditor'
31
-import { Account, account2icon } from './AccountsPage'
31
+import { account2icon } from './AccountsPage'
32
+import apiAccounts from '../../src/api.accounts'
33
34
const ACCEPT_LINK = "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept"
35
@@ -36,11 +37,11 @@ interface FileFormProps {
37
file: VfsNodeAdmin
38
addToBar?: ReactNode
39
statusApi: UseApi
39
- accounts: Account[]
40
+ accountsApi: AccountsApi
41
saved: Callback
42
isSideBreakpoint: boolean
43
}
43
-export default function FileForm({ file, addToBar, statusApi, accounts, saved, isSideBreakpoint }: FileFormProps) {
44
+export default function FileForm({ file, addToBar, statusApi, accountsApi, saved, isSideBreakpoint }: FileFormProps) {
45
const { parent, children, isRoot, byMasks, ...rest } = file
46
const [values, setValues] = useState(rest)
47
useEffect(() => {
@@ -235,12 +236,13 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved, i
236
return {
237
comp: WhoField,
238
k: perm, sm: 6, lg: 12, xl: 4,
238
- parent, accounts, helperText, isDir,
239
+ parent, accountsApi, helperText, isDir,
240
otherPerms: others.map(x => ({ value: x, label: who2desc(x) })),
241
label: "Who can " + perm2word(perm),
242
inherit,
243
byMasks: byMasks?.[perm],
243
- fromField: (v?: Who) => v ?? null,
244
+ offerInheritance: true,
245
+ fromField: (v?: WhoVfs) => v ?? null,
246
...props
247
}
248
}
@@ -251,29 +253,41 @@ function perm2word(perm: string) {
253
return xlate(perm.split('_')[1], { read: 'download', archive: 'zip', list: 'access list' })
254
}
255
254
-interface WhoFieldProps extends FieldProps<Who | undefined> {
255
- accounts: Account[],
256
- otherPerms: any[],
256
+type AccountsApi = ReturnType<typeof useAccountsApi>
257
+export function useAccountsApi() {
258
+ return useApiEx<typeof apiAccounts.get_accounts>('get_accounts', {}, {
259
+ onResponse(_res, data) {
260
+ if (!data) return
261
+ data.list = _.sortBy(data.list, 'username')
262
+ }
263
+ })
264
+}
265
+
266
+interface WhoFieldProps extends FieldProps<WhoVfs | undefined> {
267
+ accountsApi?: AccountsApi,
268
+ otherPerms?: any[],
269
isChildren?: boolean,
270
isDir: boolean
271
contentText?: string
272
}
261
-function WhoField({ value, onChange, parent, inherit, accounts, helperText, otherPerms, byMasks,
262
- hideValues, isChildren, isDir, contentText="folder content", setApi, ...rest }: WhoFieldProps): ReactElement {
273
+export function WhoField({ value, onChange, parent, inherit, accountsApi, helperText, otherPerms, byMasks,
274
+ hideValues, isChildren, isDir, contentText="folder content", setApi, offerInheritance, ...rest }: WhoFieldProps): ReactElement {
275
const defaultLabel = who2desc(byMasks ?? inherit)
276
+ prefix(' (', byMasks !== undefined ? "from masks" : parent !== undefined ? "as parent folder" : "default", ')')
277
const objectMode = isWhoObject(value)
278
const thisValue = objectMode ? value.this : value
279
+ accountsApi ??= useAccountsApi() // it's important that the "accounts" prop is stable in the truthy sense
280
+ const accounts = accountsApi?.data?.list
281
282
const options = useMemo(() =>
283
onlyTruthy([
270
- { value: null, label: defaultLabel },
284
+ offerInheritance && { value: null, label: defaultLabel },
285
{ value: true },
286
{ value: false },
287
{ value: '*' },
274
- ...otherPerms,
288
+ ...otherPerms || [],
289
{ value: [], label: "Select accounts" },
276
- ].map(x => !hideValues?.includes(x.value)
290
+ ].map(x => x && !hideValues?.includes(x.value)
291
&& { label: who2desc(x.value), ...x })), // default label
292
[inherit, parent, thisValue, ...wantArray(hideValues)])
293
@@ -312,7 +326,7 @@ function WhoField({ value, onChange, parent, inherit, accounts, helperText, othe
326
!isChildren && h(Collapse, { in: objectMode, timeout },
327
h(WhoField, {
328
label: "Permission for " + contentText,
315
- parent, inherit, accounts, otherPerms, isDir,
329
+ parent, inherit, accountsApi, otherPerms, isDir,
330
value: objectMode ? value?.children : undefined,
331
isChildren: true,
332
hideValues: [thisValue ?? inherit, thisValue],
admin/src/OptionsPage.ts
+4
-2
@@ -23,6 +23,7 @@ import { proxyWarning } from './HomePage'
23
import _ from 'lodash';
24
import { proxy, subscribe, useSnapshot } from 'valtio'
25
import { TextEditorField } from './TextEditor'
26
+import { WhoField } from './FileForm';
27
28
let loaded: Dict | undefined
29
let exposedReloadStatus: undefined | (() => void)
@@ -44,7 +45,7 @@ export default function OptionsPage() {
45
const status = statusApi.data
46
const reloadStatus = exposedReloadStatus = statusApi.reload
47
useEffect(() => void reloadStatus(), [data]) //eslint-disable-line
47
- useEffect(() => () => exposedReloadStatus = undefined, []) // clear on unmount
48
+ useEffect(() => () => exposedReloadStatus = undefined, []) // clear this on unmount
49
const sm = useBreakpoint('sm')
50
const saveBtnRef = useRef<HTMLButtonElement>(null)
51
@@ -209,8 +210,9 @@ export default function OptionsPage() {
210
{ k: 'folders_first', comp: BoolField, xs: 6, md: 3 },
211
{ k: 'sort_numerics', comp: BoolField, xs: 6, md: 3, label: "Sort numeric names" },
212
{ k: 'title_with_path', comp: BoolField, xs: 6, md: 3 },
212
- { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, xs: 12, sm: 9,
213
+ { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, xs: 12, sm: 6,
214
helperText: "The icon associated to your website" },
215
+ { k: CFG.show_uploader, comp: WhoField, xs: true },
216
{ k: 'page_size', comp: NumberField, xs: true, min: 1, required: true, helperText: "Entries per page" },
217
218
h(Section, { title: "Uploads" }),
admin/src/VfsPage.ts
+6
-8
@@ -11,13 +11,12 @@ import { markVfsModified, prepareVfsUndo, state, useSnapState } from './state'
11
import VfsTree, { vfsNodeIcon } from './VfsTree'
12
import {
13
CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix, VfsNodeAdminSend, HIDE_IN_TESTS, wait,
14
- isWhoObject, PERM_KEYS, VfsPerms, Who,
14
+ isWhoObject, PERM_KEYS, VfsPerms, WhoVfs,
15
} from './misc'
16
import { Flex, useBreakpoint } from './mui'
17
import { reactJoin } from '@hfs/shared'
18
import _ from 'lodash'
19
-import apiAccounts from '../../src/api.accounts'
20
-import FileForm from './FileForm'
19
+import FileForm, { useAccountsApi } from './FileForm'
20
import { Add, Delete } from '@mui/icons-material'
21
import { toast } from './dialog'
22
import { PageProps } from './App'
@@ -51,8 +50,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
50
ret.unshift(b)
51
return ret
52
}, [status, config])
54
- const accountsApi = useApiEx<typeof apiAccounts.get_accounts>('get_accounts') // load accounts once and for all, or !isSideBreakpoint will cause a call for each selection
55
- const accounts = useMemo(() => _.sortBy(accountsApi?.data?.list, 'username'), [accountsApi.data])
53
+ const accountsApi = useAccountsApi() // load accounts once and for all, or !isSideBreakpoint will cause a call for each selection
54
const diskContent = useApiList<LsEntry>(vfsShowDiskContentFor && 'get_ls', { path: vfsShowDiskContentFor })
55
56
// this will take care of closing the dialog, for the user's convenience, after "cut" button is pressed
@@ -81,7 +79,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
79
), [hintElement]))
80
81
const single = selectedFiles?.length < 2 && selectedFiles[0] as VfsNodeAdmin
84
- const sideContent = useMemo(() => accountsApi.element || !vfs ? null
82
+ const sideContent = useMemo(() => !vfs ? null
83
: diskContent.enabled ? diskContent.element || h(Box, {},
84
h(Box, { sx: { fontSize: 'xx-large', wordBreak: 'break-all' } }, "From ", vfsShowDiskContentFor),
85
h(List, { dense: true },
@@ -95,7 +93,7 @@ export default function VfsPage({ setTitleSide }: PageProps) {
93
addToBar: isSideBreakpoint && h(Box, { sx: { flex: 1, textAlign: 'right', mr: 1, color: '#8883' } }, vfsNodeIcon(single)),
94
statusApi,
95
saved: () => closeDialogRef.current(),
98
- accounts: accounts ?? [],
96
+ accountsApi,
97
file: single
98
})
99
: !selectedFiles.length ? null
@@ -230,7 +228,7 @@ export function getInheritedPerms(child: VfsNodeAdmin | undefined) {
228
}
229
return _.isEmpty(ret) ? undefined : ret
230
233
- function getInheritedPerm(cursor: VfsNodeAdmin | undefined, perm: keyof VfsPerms): Who | undefined {
231
+ function getInheritedPerm(cursor: VfsNodeAdmin | undefined, perm: keyof VfsPerms): WhoVfs | undefined {
232
while (cursor) {
233
let inheritedPerm = cursor[perm]
234
if (inheritedPerm != null) {
admin/src/VfsTree.ts
+2
-2
@@ -9,7 +9,7 @@ import {
9
} from '@mui/icons-material'
10
import { Box, Typography } from '@mui/material'
11
import { deleteVfs, id2vfsNode, isDescendantUri, reindexVfs, VfsNodeAdmin } from './VfsPage'
12
-import { onlyTruthy, pathEncode, prefix, toMutable, wantArray, Who, with_ } from './misc'
12
+import { onlyTruthy, pathEncode, prefix, toMutable, wantArray, WhoVfs, with_ } from './misc'
13
import { Flex, iconTooltip, useToggleButton } from './mui'
14
import VfsMenuBar from './VfsMenuBar'
15
import { ApiObject } from './api'
@@ -96,7 +96,7 @@ export default function VfsTree({ statusApi }:{ statusApi: ApiObject }) {
96
...node.children?.map(x => h(Branch, { key: x.id, node: x })) || []
97
)
98
99
- function isRestricted(who: Who | undefined) {
99
+ function isRestricted(who: WhoVfs | undefined) {
100
return who != null && who !== true
101
}
102
config.md
+19
-13
@@ -43,6 +43,13 @@ Configuration can be done in several ways
43
`NAME` stands for the property name that you want to change. See the complete list below.
44
45
### Configuration properties
46
+
47
+Some properties use a `Who` descriptor, with one of these values:
48
+- `true`: anyone can, even people who didn't log in.
49
+- `false`: no one can.
50
+- `"*"`: any account can, i.e. anyone who logged in.
51
+- `[ frank, peter ]`: the list of accounts who can.
52
+
53
- `port` where to accept http connections. Default is 80.
54
- `vfs` the files and folders you want to expose. For details see the dedicated following section.
55
- `log` path of the log file. Default is `access.log`.
@@ -108,6 +115,7 @@ Configuration can be done in several ways
115
- `sort_numerics` starting value for sort-numeric-names. Default is false.
116
- `folders_first` starting value for sort-folders-first. Default is true.
117
- `invert_order` starting value for invert-order. Default is false.
118
+- `show_uploader` who can see who uploaded files. Value is a `Who` descriptor. Default is false.
119
- `update_to_beta` includes beta versions searching for updates. Default is false.
120
- `roots` maps hosts (or mask of hosts) to a root different from the home folder. Default is none. E.g.
121
```
@@ -173,20 +181,18 @@ Valid keys in a node are:
181
The value must be the name of the file to serve. E.g.: `index.html`.
182
The value must be an absolute or relative path in the VFS, not a path on disk. It works also with other type of files.
183
Using this will make `mime` default to "auto".
176
-- `can_read`: specify who can download this entry. Value is a `WhoCan` descriptor, which is one of these values
177
- - `true`: anyone can, even people who didn't log in. This is normally the default value.
178
- - `false`: no one can.
179
- - `"*"`: any account can, i.e. anyone who logged in.
180
- - `[ frank, peter ]`: the list of accounts who can.
181
- - `can_SOMETHING`: copy the permission from another permission. This is convenient to have same value for different permissions. E.g. `can_see`
182
- - `{ this?: WhoCan, children?: WhoCan }`: this form is useful only for folders. By using it, you can have
183
- different permission for the folder itself and its children. For example, having only the `this` property
184
- will make the permission limited to the folder and not be inherited by children. Otherwise, having only
185
- the `children` will make the permission have no effect on the folder, but only on its content.
186
- - `this` specifies permission for this folder
187
- - `children` specifies permission for the content.
184
+- `can_read`: specify who can download this entry. Value is a `Who` descriptor, or a VFS-specific extension. Default is `true`.
185
+
186
+ VFS permissions also accept these extra forms:
187
+ - `can_SOMETHING`: copy the permission from another permission. This is convenient to have same value for different permissions. E.g. `can_see`
188
+ - `{ this?: ..., children?: ... }`: this form is useful only for folders. Each value uses the same permission descriptor. By using it, you can have
189
+ different permission for the folder itself and its children. For example, having only the `this` property
190
+ will make the permission limited to the folder and not be inherited by children. Otherwise, having only
191
+ the `children` will make the permission have no effect on the folder, but only on its content.
192
+ - `this` specifies permission for this folder
193
+ - `children` specifies permission for the content.
194
- `can_see`: specify who can see this element. Even if a user can download you can still make the file not appear in the list.
189
- Value is a `WhoCan` descriptor, refer above. Default is `can_read`.
195
+ Value uses the same permission descriptor described above. Default is `can_read`.
196
- `can_upload`: specify who can upload. Applies to folders with a source. Default is none.
197
- `can_list`: specify who can see the content of a folder. Default is `can_read`.
198
- `can_archive`: specify who can get the zip a folder or a set of files. Default is `can_read`.
src/cross.ts
+10
-11
@@ -33,7 +33,7 @@ export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_u
33
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
34
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
35
'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html', 'comments_storage',
36
- 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy', 'mapped_port', 'upnp_enabled'])
36
+ 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy', 'mapped_port', 'upnp_enabled', 'show_uploader'])
37
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
38
export type Dict<T=any> = Record<string, T>
39
export type Falsy = false | null | undefined | '' | 0
@@ -44,12 +44,12 @@ export type Promisable<T> = T | Promise<T>
44
export type Functionable<T, Args extends any[] = any[]> = T | ((...args: Args) => T)
45
export type Timeout = ReturnType<typeof setTimeout>
46
export interface VfsPerms {
47
- can_see?: Who
48
- can_read?: Who
49
- can_list?: Who
50
- can_upload?: Who
51
- can_delete?: Who
52
- can_archive?: Who
47
+ can_see?: WhoVfs
48
+ can_read?: WhoVfs
49
+ can_list?: WhoVfs
50
+ can_upload?: WhoVfs
51
+ can_delete?: WhoVfs
52
+ can_archive?: WhoVfs
53
}
54
export const WHO_ANYONE = true
55
export const WHO_NO_ONE = false
@@ -58,10 +58,9 @@ type AccountList = string[]
58
export type Who = typeof WHO_ANYONE
59
| typeof WHO_NO_ONE
60
| typeof WHO_ANY_ACCOUNT
61
- | keyof VfsPerms
62
- | WhoObject
61
| AccountList // use false instead of empty array to keep the type boolean-able
64
-export interface WhoObject { this?: Who, children?: Who }
62
+export type WhoVfs = Who | keyof VfsPerms | WhoObject
63
+export interface WhoObject { this?: WhoVfs, children?: WhoVfs }
64
export type Jsonify<T> = T extends string | number | boolean | null | undefined ? T : // undefined is necessary to preserve union types, like number|undefined
65
T extends Date ? string :
66
T extends (infer U)[] ? Jsonify<U>[] :
@@ -98,7 +97,7 @@ function constMap<T extends string>(a: T[]): { [K in T]: K } {
97
return Object.fromEntries(a.map(x => [x, x])) as { [K in T]: K };
98
}
99
101
-export function isWhoObject(v: undefined | Who): v is WhoObject {
100
+export function isWhoObject(v: undefined | WhoVfs): v is WhoObject {
101
return v !== null && typeof v === 'object' && !Array.isArray(v)
102
}
103
src/frontEndApis.ts
+18
-14
@@ -11,19 +11,22 @@ import {
11
HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_UNAUTHORIZED
12
} from './const'
13
import {
14
- hasPermission, isRoot, nodeIsFolder, nodeStats, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
14
+ hasPermission, isRoot, nodeIsFolder, nodeStats,
15
+ simpleWhoToError, statusCodeForMissingPerm, urlToNode, VfsNode, walkNode
16
} from './vfs'
17
import fs from 'fs'
18
import { mkdir, rename, copyFile, unlink } from 'fs/promises'
19
import { basename, dirname, join } from 'path'
20
import { getUploadMeta } from './upload'
20
-import { apiAssertTypes, moveStoredFileAttrs, pathDecode, pathEncode, popKey } from './misc'
21
+import { apiAssertTypes, CFG, moveStoredFileAttrs, pathDecode, pathEncode, popKey } from './misc'
22
+import { defineConfig } from './config'
23
import { getCommentFor, setCommentFor } from './comments'
24
import { SendListReadable } from './SendList'
25
import { ctxAdminAccess } from './adminApis'
26
import _ from 'lodash'
27
28
const partialFolderSize: any = {}
29
+const showUploader = defineConfig(CFG.show_uploader, false)
30
31
export const frontEndApis: ApiHandlers = {
32
get_file_list,
@@ -45,18 +48,19 @@ export const frontEndApis: ApiHandlers = {
48
return new ApiError(HTTP_BAD_REQUEST, 'bad uris')
49
const isAdmin = ctxAdminAccess(ctx)
50
return {
48
- details: await Promise.all(uris.map(async (uri: any) => {
49
- if (typeof uri !== 'string')
50
- return false // false means error
51
- const node = await urlToNode(uri, ctx)
52
- if (!node || !hasPermission(node, 'can_see', ctx))
53
- return false
54
- let upload = node.source && await getUploadMeta(node.source).catch(() => undefined)
55
- if (!upload) return
56
- if (!isAdmin)
57
- upload = _.omit(upload, 'ip')
58
- return { upload }
59
- }))
51
+ details: simpleWhoToError(showUploader.get(), ctx) ? [] // return early because at the moment we only have the uploader
52
+ : await Promise.all(uris.map(async (uri: any) => {
53
+ if (typeof uri !== 'string')
54
+ return false // false means error
55
+ const node = await urlToNode(uri, ctx)
56
+ if (!node || !hasPermission(node, 'can_see', ctx))
57
+ return false
58
+ let upload = node.source && await getUploadMeta(node.source).catch(() => undefined)
59
+ if (!upload) return
60
+ if (!isAdmin)
61
+ upload = _.omit(upload, 'ip')
62
+ return { upload }
63
+ }))
64
}
65
},
66
src/vfs.ts
+21
-10
@@ -3,9 +3,9 @@
3
import fs from 'fs/promises'
4
import { basename, dirname, join, resolve } from 'path'
5
import {
6
- makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, Who, debounceAsync,
6
+ makeMatcher, setHidden, onlyTruthy, isValidFileName, throw_, VfsPerms, WhoVfs, debounceAsync,
7
isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, HTTP_SERVER_ERROR, try_, matches, Promisable,
8
- statWithTimeout, safeDecodeURIComponent, getUncHost,
8
+ statWithTimeout, safeDecodeURIComponent, getUncHost, Who,
9
} from './misc'
10
import Koa from 'koa'
11
import _ from 'lodash'
@@ -51,7 +51,7 @@ export function permsFromParent(parent: VfsNode, child: VfsNode) {
51
const ret: VfsPerms = {}
52
for (const k of PERM_KEYS) {
53
let p: VfsNode | undefined = parent
54
- let inheritedPerm: Who | undefined
54
+ let inheritedPerm: WhoVfs | undefined
55
while (p) {
56
inheritedPerm = p[k]
57
// in case of object without children, parent is skipped in favor of the parent's parent
@@ -277,7 +277,7 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerms, ct
277
|| !node.source && perm === 'can_upload') // Upload possible only if we know where to store. First check node.source because is supposedly faster.
278
return HTTP_FORBIDDEN
279
// calculate value of permission resolving references to other permissions, avoiding infinite loop
280
- let who: Who | undefined
280
+ let who: WhoVfs | undefined
281
let max = PERM_KEYS.length
282
let cur = perm
283
do {
@@ -293,6 +293,8 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerms, ct
293
}
294
cur = who
295
} while (1)
296
+ if (isWhoObject(who) || isWhoVfsPerms(who))
297
+ throw Error(`permission type-guard: ${JSON.stringify(who)}`)
298
const eventName = 'checkVfsPermission'
299
if (events.anyListener(eventName)) {
300
const first = _.max(events.emit(eventName, { who, node, perm, ctx }))
@@ -300,14 +302,23 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerms, ct
302
return first
303
}
304
303
- if (Array.isArray(who))
304
- return ctxBelongsTo(ctx, who) ? 0 : HTTP_UNAUTHORIZED
305
- return typeof who === 'boolean' ? (who ? 0 : HTTP_FORBIDDEN)
306
- : who === WHO_ANY_ACCOUNT ? (getCurrentUsername(ctx) ? 0 : HTTP_UNAUTHORIZED)
307
- : throw_(Error(`invalid permission: ${perm}=${try_(() => JSON.stringify(who))}`))
305
+ return simpleWhoToError(who, ctx)
306
+ ?? throw_(Error(`invalid permission: ${perm}=${try_(() => JSON.stringify(who))}`))
307
}
308
}
309
310
+export function simpleWhoToError(who: Who, ctx: Koa.Context) {
311
+ if (Array.isArray(who))
312
+ return ctxBelongsTo(ctx, who) ? 0 : HTTP_UNAUTHORIZED
313
+ return typeof who === 'boolean' ? (who ? 0 : HTTP_FORBIDDEN)
314
+ : who === WHO_ANY_ACCOUNT ? (getCurrentUsername(ctx) ? 0 : HTTP_UNAUTHORIZED)
315
+ : undefined
316
+}
317
+
318
+function isWhoVfsPerms(who: WhoVfs | undefined): who is keyof VfsPerms {
319
+ return typeof who === 'string' && (PERM_KEYS as readonly string[]).includes(who)
320
+}
321
+
322
interface WalkNodeOptions {
323
ctx?: Koa.Context,
324
depth?: number,
@@ -513,7 +524,7 @@ events.on('accountRenamed', ({ from, to }) => {
524
})(vfs)
525
saveVfs()
526
516
- function renameInPerm(a?: Who) {
527
+ function renameInPerm(a?: WhoVfs) {
528
if (!Array.isArray(a)) return
529
for (let i=0; i < a.length; i++)
530
if (a[i] === from)
tests/test.ts
+9
-5
@@ -161,10 +161,10 @@ describe('basics', () => {
161
headers: { 'x-hfs-anti-csrf': '1', 'content-type': 'application/json' },
162
body: '{'
163
}))
164
- test('file_details.missing', reqApi('get_file_details', { uris: ['/missing'] }, res => res?.details?.[0] === false))
165
- test('file_details.hidden', reqApi('get_file_details', { uris: ['/tests/config.yaml'] }, res => res?.details?.[0] === false))
166
- test('file_details.for-admins', reqApi('get_file_details', { uris: ['/for-admins/alfa.txt'] }, res => res?.details?.[0] === false))
167
- test('file_details.traversal', reqApi('get_file_details', { uris: ['/f1/%2e%2e/for-admins/alfa.txt'] }, res => res?.details?.[0] === false))
164
+ test('file_details.missing', reqApi('get_file_details', { uris: ['/missing'] }, noVisibleDetails))
165
+ test('file_details.hidden', reqApi('get_file_details', { uris: ['/tests/config.yaml'] }, noVisibleDetails))
166
+ test('file_details.for-admins', reqApi('get_file_details', { uris: ['/for-admins/alfa.txt'] }, noVisibleDetails))
167
+ test('file_details.traversal', reqApi('get_file_details', { uris: ['/f1/%2e%2e/for-admins/alfa.txt'] }, noVisibleDetails))
168
test('file_list.traversal', reqApi('get_file_list', { uri: '/f1/%2e%2e/for-admins' }, 404))
169
test('file_list.bad encoding', reqApi('get_file_list', { uri: '/f1/%E0%A4%A' }, 404))
170
test('forbidden list', req('/cantListPage/page/', 403))
@@ -910,7 +910,7 @@ describe('after-login', () => {
910
const u = res?.details?.[0]?.upload
911
throwIf(!u?.ip ? 'ip' : u?.username !== username ? 'username' : '')
912
}))
913
- test('file_details.non-admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => res?.details?.[0] === false, { jar: {} }))
913
+ test('file_details.non-admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, noVisibleDetails, { jar: {} }))
914
test('percent name apis.details', async () => {
915
const percentName = `x%25-${randomId(4)}`
916
const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}`
@@ -1530,6 +1530,10 @@ function isInList(res:any, name:string) {
1530
return Array.isArray(res?.list) && (res.list as any[]).some(x => x.n===name)
1531
}
1532
1533
+function noVisibleDetails(res: any) {
1534
+ return Array.isArray(res?.details) && res.details.length === 0
1535
+}
1536
+
1537
function rmAny(path: string) {
1538
return path && rm(path, { recursive: true, force: true }).catch(() => {})
1539
}