can_list
Massimo Melina committed
Mar 16, 2023 at 10:36 UTC
7f6d165b7a32e4a49dbd5de78656853e308a930e
7 files changed
+78
-46
admin/src/FileForm.ts
+7
-7
@@ -14,7 +14,7 @@ import {
14
StringField
15
} from '@hfs/mui-grid-form'
16
import { apiCall, useApiEx } from './api'
17
-import { formatBytes, IconBtn, isEqualLax, modifiedSx, newDialog, onlyTruthy, prefix } from './misc'
17
+import { formatBytes, IconBtn, isEqualLax, modifiedSx, newDialog, objSameKeys, onlyTruthy, prefix } from './misc'
18
import { reloadVfs, VfsNode, VfsPerms, Who } from './VfsPage'
19
import md from './md'
20
import _ from 'lodash'
@@ -37,7 +37,7 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
37
const { parent, children, isRoot, ...rest } = file
38
const [values, setValues] = useState(rest)
39
useEffect(() => {
40
- setValues(Object.assign({ can_see: null, can_read: null, can_upload: null, can_delete: null }, rest))
40
+ setValues(Object.assign(objSameKeys(defaultPerms, () => null), rest))
41
}, [file]) //eslint-disable-line
42
43
const { source } = file
@@ -60,8 +60,7 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
60
const { data, element } = useApiEx<{ list: Account[] }>('get_accounts')
61
if (element || !data)
62
return element
63
- const allAccounts = data.list
64
- const can_read = (values.can_read ?? inheritedPerms.can_read)
63
+ const accounts = data.list
64
65
return h(Form, {
66
values,
@@ -104,9 +103,10 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
103
placeholder: "Not on disk, this is a virtual folder",
104
},
105
perm('can_read', "Who can download", "Who can see but not download will be asked to login"),
107
- perm('can_see', "Who can see", "You can hide and keep it downloadable if you have a direct link"),
106
+ perm('can_see', "Who can see", "If you don't see, you may download with a direct link"),
107
+ isDir && perm('can_list', "Who can list", "Permission to see content of folders"),
108
isDir && perm('can_upload', "Who can upload", hasSource ? '' : "Works only on folders with source"),
109
- isDir && perm('can_delete', "Who can delete", hasSource ? '' : "Works only on folders with source"),
109
+ isDir && perm('can_delete', "Who can delete", hasSource ? '' : "Works only on folders with source", { lg: 12 }),
110
showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
111
showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: showSize && 4, label: 'Created', toField: formatTimestamp },
112
showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: showSize && 4, label: 'Modified', toField: formatTimestamp },
@@ -121,7 +121,7 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
121
]
122
})
123
124
- function perm(perm: keyof typeof inheritedPerms, label: string, helperText='', { accounts=allAccounts, ...props }={}) {
124
+ function perm(perm: keyof typeof inheritedPerms, label: string, helperText='', props: Partial<WhoFieldProps>={}) {
125
return { showInherited: anyMask, // with masks, you may need to set a permission to override the mask
126
k: perm, lg: 6, comp: WhoField, parent, accounts, label, inherit: inheritedPerms[perm], helperText, ...props }
127
}
admin/src/VfsPage.ts
+1
@@ -161,6 +161,7 @@ export async function deleteFiles() {
161
export interface VfsPerms {
162
can_see?: Who
163
can_read?: Who
164
+ can_list?: Who
165
can_upload?: Who
166
can_delete?: Who
167
}
src/api.file_list.ts
+13
-5
@@ -1,6 +1,14 @@
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 { cantReadStatusCode, getNodeName, hasPermission, nodeIsDirectory, urlToNode, VfsNode, walkNode } from './vfs'
3
+import {
4
+ getNodeName,
5
+ hasPermission,
6
+ nodeIsDirectory,
7
+ statusCodeForMissingPerm,
8
+ urlToNode,
9
+ VfsNode,
10
+ walkNode
11
+} from './vfs'
12
import { ApiError, ApiHandler, SendListReadable } from './apiMiddleware'
13
import { stat } from 'fs/promises'
14
import { mapPlugins } from './plugins'
@@ -13,8 +21,9 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
21
const list = new SendListReadable()
22
if (!node)
23
return fail(HTTP_NOT_FOUND)
16
- if (!hasPermission(node,'can_read',ctx))
17
- return fail(cantReadStatusCode(node))
24
+ const res = statusCodeForMissingPerm(node,'can_list',ctx)
25
+ if (res)
26
+ return fail(res)
27
if (dirTraversal(search))
28
return fail(HTTP_FOOL)
29
if (node.default)
@@ -47,8 +56,7 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
56
function fail(code: any) {
57
if (!sse)
58
return new ApiError(code)
50
- list.error(code)
51
- list.close()
59
+ list.error(code, true)
60
return list
61
}
62
src/middlewares.ts
+13
-16
@@ -11,7 +11,7 @@ import {
11
HTTP_FORBIDDEN, HTTP_NOT_FOUND, HTTP_FOOL, API_URI,
12
} from './const'
13
import { FRONTEND_URI } from './const'
14
-import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs } from './vfs'
14
+import { statusCodeForMissingPerm, nodeIsDirectory, urlToNode, vfs } from './vfs'
15
import { dirTraversal, newObj, stream2string, tryJson } from './misc'
16
import { zipStreamFromFolder } from './zip'
17
import { serveFile, serveFileNode } from './serveFile'
@@ -116,15 +116,13 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
116
await once(form, 'end').catch(()=> {})
117
return
118
}
119
- const canRead = hasPermission(node, 'can_read', ctx)
120
- const isFolder = await nodeIsDirectory(node)
121
- if (isFolder && !path.endsWith('/'))
119
+ if (!await nodeIsDirectory(node))
120
+ return !node.source && await next()
121
+ || statusCodeForMissingPerm(node, 'can_read', ctx)
122
+ || serveFileNode(ctx, node)
123
+ if (!path.endsWith('/'))
124
return ctx.redirect(ctx.state.revProxyPath + ctx.originalUrl + '/')
123
- if (canRead && !isFolder)
124
- return node.source ? serveFileNode(ctx, node)
125
- : next()
126
- if (!canRead) {
127
- ctx.status = cantReadStatusCode(node)
125
+ if (statusCodeForMissingPerm(node, 'can_list', ctx)) {
126
if (ctx.status === HTTP_FORBIDDEN)
127
return
128
const browserDetected = ctx.get('Upgrade-Insecure-Requests') || ctx.get('Sec-Fetch-Mode') // ugh, heuristics
@@ -137,13 +135,12 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
135
const { get } = ctx.query
136
if (get === 'zip')
137
return await zipStreamFromFolder(node, ctx)
140
- if (node.default) {
141
- const def = await urlToNode(path + node.default, ctx)
142
- return !def ? next()
143
- : hasPermission(def, 'can_read', ctx) ? serveFileNode(ctx, def)
144
- : ctx.status = cantReadStatusCode(def)
145
- }
146
- return serveFrontendFiles(ctx, next)
138
+ if (!node.default)
139
+ return serveFrontendFiles(ctx, next)
140
+ const defNode = await urlToNode(path + node.default, ctx)
141
+ if (defNode)
142
+ statusCodeForMissingPerm(defNode, 'can_read', ctx) || serveFileNode(ctx, defNode)
143
+ await next()
144
}
145
146
let proxyDetected = false
src/upload.ts
+4
-5
@@ -1,11 +1,9 @@
1
-import { hasPermission, VfsNode } from './vfs'
1
+import { statusCodeForMissingPerm, VfsNode } from './vfs'
2
import Koa from 'koa'
3
import {
4
- HTTP_FORBIDDEN,
4
HTTP_PAYLOAD_TOO_LARGE,
5
HTTP_RANGE_NOT_SATISFIABLE,
6
HTTP_SERVER_ERROR,
8
- HTTP_UNAUTHORIZED
7
} from './const'
8
import { basename, dirname, extname, join } from 'path'
9
import fs from 'fs'
@@ -24,8 +22,9 @@ const dontOverwriteUploading = defineConfig('dont_overwrite_uploading', false)
22
const waitingToBeDeleted: Record<string, ReturnType<typeof setTimeout>> = {}
23
24
export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
27
- if (!hasPermission(base, 'can_upload', ctx))
28
- return fail(base.can_upload === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED)
25
+ const res = statusCodeForMissingPerm(base, 'can_upload', ctx)
26
+ if (res)
27
+ return fail(res)
28
const fullPath = join(base.source!, path)
29
const dir = dirname(fullPath)
30
const min = minAvailableMb.get() * (1 << 20)
src/vfs.ts
+33
-9
@@ -7,16 +7,15 @@ import { dirStream, dirTraversal, enforceFinal, getOrSet, isDirectory, typedKeys
7
import Koa from 'koa'
8
import _ from 'lodash'
9
import { defineConfig, setConfig } from './config'
10
-import { HTTP_FOOL, HTTP_FORBIDDEN, IS_WINDOWS, HTTP_UNAUTHORIZED } from './const'
10
+import { HTTP_FOOL, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED } from './const'
11
import events from './events'
12
import { getCurrentUsernameExpanded } from './perm'
13
-import { with_ } from './misc'
13
14
const WHO_ANYONE = true
15
const WHO_NO_ONE = false
16
const WHO_ANY_ACCOUNT = '*'
17
type AccountList = string[]
19
-type Who = typeof WHO_ANYONE
18
+export type Who = typeof WHO_ANYONE
19
| typeof WHO_NO_ONE
20
| typeof WHO_ANY_ACCOUNT
21
| AccountList
@@ -24,6 +23,7 @@ type Who = typeof WHO_ANYONE
23
interface VfsPerm {
24
can_read: Who
25
can_see: Who
26
+ can_list: Who
27
can_upload: Who
28
can_delete: Who
29
}
@@ -46,6 +46,7 @@ export interface VfsNode extends Partial<VfsPerm> {
46
export const defaultPerms: VfsPerm = {
47
can_see: WHO_ANYONE,
48
can_read: WHO_ANYONE,
49
+ can_list: WHO_ANYONE,
50
can_upload: WHO_NO_ONE,
51
can_delete: WHO_NO_ONE,
52
}
@@ -156,7 +157,20 @@ export function hasPermission(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Conte
157
&& matchWho(node[perm] ?? defaultPerms[perm], ctx)
158
}
159
159
-export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
160
+export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context) {
161
+ if (hasPermission(node, perm, ctx))
162
+ return false
163
+ return ctx.status = node[perm] === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED
164
+}
165
+
166
+// it's responsibility of the caller to verify you have list permission on parent, as callers have different needs.
167
+// Too many parameters: consider object, but benchmark against degraded recursion on huge folders.
168
+export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=0, prefixPath:string='', requiredPerm?: keyof VfsPerm): AsyncIterableIterator<VfsNode> {
169
+ if (requiredPerm && ctx
170
+ && !hasPermission(parent, requiredPerm, ctx)
171
+ && !masksCouldGivePermission(parent.masks))
172
+ return // no permission, no reason to continue
173
+
174
const { children, source } = parent
175
const took = prefixPath ? undefined : new Set()
176
if (children)
@@ -200,8 +214,22 @@ export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=
214
yield item
215
if (!recur) return
216
inheritMasks(item, parent, virtualBasename)
203
- yield* walkNode(item, ctx, depth - 1, name + '/')
217
+ if (!ctx || hasPermission(item, 'can_list', ctx)) // check perm before recursion
218
+ yield* walkNode(item, ctx, depth - 1, name + '/')
219
}
220
+
221
+ function masksCouldGivePermission(masks: Masks | undefined) {
222
+ if (!masks) return false
223
+ for (const [,props] of Object.entries(masks)) {
224
+ const v = props[requiredPerm!]
225
+ if (v && (!ctx || matchWho(v, ctx))) // without ctx we can't say, so it could
226
+ return true
227
+ if (masksCouldGivePermission(props.masks))
228
+ return true
229
+ }
230
+ return false
231
+ }
232
+
233
}
234
function applyMasks(item: VfsNode, parent: VfsNode, virtualBasename: string) {
235
const { masks } = parent
@@ -242,10 +270,6 @@ function matchWho(who: Who, ctx: Koa.Context) {
270
who.includes(u) ))()
271
}
272
245
-export function cantReadStatusCode(node: VfsNode) {
246
- return node.can_read === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED
247
-}
248
-
273
events.on('accountRenamed', (from, to) => {
274
recur(vfs)
275
saveVfs()
src/zip.ts
+7
-4
@@ -11,6 +11,7 @@ import { basename, dirname } from 'path'
11
import { getRange } from './serveFile'
12
import { HTTP_OK } from './const'
13
14
+// expects 'node' to have had permissions checked by caller
15
export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
16
ctx.status = HTTP_OK
17
ctx.mime = 'zip'
@@ -19,14 +20,15 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
20
const name = list?.length === 1 ? basename(list[0]!) : getNodeName(node)
21
ctx.attachment((isWindowsDrive(name) ? name[0] : (name || 'archive')) + '.zip')
22
const filter = pattern2filter(String(ctx.query.search||''))
22
- const walker = !list ? walkNode(node, ctx, Infinity)
23
+ const walker = !list ? walkNode(node, ctx, Infinity, '', 'can_read')
24
: (async function*(): AsyncIterableIterator<VfsNode> {
25
for await (const el of list) {
26
const subNode = await urlToNode(el, ctx, node)
26
- if (!subNode || !hasPermission(subNode,'can_read',ctx))
27
+ if (!subNode)
28
continue
28
- if (await nodeIsDirectory(subNode)) {// a directory needs to walked
29
- yield* walkNode(subNode, ctx, Infinity, el + '/')
29
+ if (await nodeIsDirectory(subNode)) { // a directory needs to walked
30
+ if (hasPermission(subNode, 'can_list',ctx))
31
+ yield* walkNode(subNode, ctx, Infinity, el + '/', 'can_read')
32
continue
33
}
34
let folder = dirname(el)
@@ -35,6 +37,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
37
}
38
})()
39
const mappedWalker = filterMapGenerator(walker, async (el:VfsNode) => {
40
+ if (!hasPermission(el, 'can_read', ctx)) return // the fact you see it doesn't mean you can read it
41
const { source } = el
42
const name = getNodeName(el)
43
if (!source || ctx.req.aborted || !filter(name))