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 }