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