copy-permission

Massimo Melina committed Apr 18, 2023 at 00:09 UTC f1476494940f09a81552713dc2fde69ae0ba5a7f
6 files changed +70 -38
admin/src/FileForm.ts
+26 -12
@@ -104,11 +104,11 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
104 { k: 'source', label: "Source on disk", comp: FileField, files: !isDir, folders: isDir, multiline: true,
105 placeholder: "Not on disk, this is a virtual folder",
106 },
107 - perm('can_read', "Who can download", "Who can see but not download will be asked to login"),
108 - perm('can_see', "Who can see", "If you don't see, you may download with a direct link"),
109 - isDir && perm('can_list', "Who can list", "Permission to see content of folders"),
110 - isDir && perm('can_delete', "Who can delete", hasSource ? '' : "Works only on folders with source"),
111 - isDir && perm('can_upload', "Who can upload", hasSource ? '' : "Works only on folders with source", { lg: showAccept ? 6 : 12 }),
107 + perm('can_read', "Who can see but not download will be asked to login"),
108 + perm('can_see', "If you don't see, you may download with a direct link"),
109 + isDir && perm('can_list', "Permission to see content of folders"),
110 + isDir && perm('can_delete', hasSource ? '' : "Works only on folders with source"),
111 + isDir && perm('can_upload', hasSource ? '' : "Works only on folders with source", { lg: showAccept ? 6 : 12 }),
112 showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything",
113 helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip"), lg: 6 },
114 showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
@@ -125,24 +125,37 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, urls }
125 ]
126 })
127
128 - function perm(perm: keyof typeof inheritedPerms, label: string, helperText='', props: Partial<WhoFieldProps>={}) {
129 - return { showInherited: anyMask, // with masks, you may need to set a permission to override the mask
130 - k: perm, lg: 6, comp: WhoField, parent, accounts, label, inherit: inheritedPerms[perm], helperText, ...props }
128 + function perm(perm: keyof typeof inheritedPerms, helperText='', props: Partial<WhoFieldProps>={}) {
129 + return {
130 + showInherited: anyMask, // with masks, you may need to set a permission to override the mask
131 + otherPerms: _.without(Object.keys(defaultPerms), perm).map(x => ({ value: x, label: "As " +perm2word(x) })),
132 + k: perm, lg: 6, comp: WhoField, parent, accounts, helperText,
133 + label: "Who can " + perm2word(perm),
134 + inherit: inheritedPerms[perm],
135 + ...props
136 + }
137 }
138 +
139 +}
140 +
141 +function perm2word(perm: string) {
142 + const word = perm.split('_')[1]
143 + return word === 'read' ? 'download' : word
144 }
145
146 function formatTimestamp(x: string) {
147 return x ? new Date(x).toLocaleString() : '-'
148 }
149
138 -interface WhoFieldProps extends FieldProps<Who> { accounts: Account[] }
139 -function WhoField({ value, onChange, parent, inherit, accounts, helperText, showInherited, ...rest }: WhoFieldProps) {
150 +interface WhoFieldProps extends FieldProps<Who> { accounts: Account[], otherPerms: any[] }
151 +function WhoField({ value, onChange, parent, inherit, accounts, helperText, showInherited, otherPerms, ...rest }: WhoFieldProps) {
152 const options = useMemo(() =>
153 onlyTruthy([
142 - { value: null, label: (parent ? "Same as parent: " : "Default: " ) + who2desc(inherit) },
154 + { value: null, label: (parent ? "As parent: " : "Default: " ) + who2desc(inherit) },
155 { value: true },
156 { value: false },
157 { value: '*' },
158 + ...otherPerms,
159 { value: [], label: "Select accounts" },
160 // don't offer inherited value twice, unless it was already selected, or it is forced
161 ].map(x => (x.value === value || showInherited || x.value !== inherit)
@@ -175,7 +188,8 @@ function who2desc(who: any) {
188 : who === true ? "anyone"
189 : who === '*' ? "any account (login required)"
190 : Array.isArray(who) ? who.join(', ')
178 - : "*UNKNOWN*" + JSON.stringify(who)
191 + : typeof who === 'string' ? "as " + perm2word(who)
192 + : "*UNKNOWN*" + JSON.stringify(who)
193 }
194
195 interface LinkFieldProps extends FieldProps<string> {
src/api.file_list.ts
+3 -4
@@ -24,9 +24,8 @@ export const file_list: ApiHandler = async ({ uri, offset, limit, search, omit,
24 const list = new SendListReadable()
25 if (!node)
26 return fail(HTTP_NOT_FOUND)
27 - const res = statusCodeForMissingPerm(node,'can_list',ctx)
28 - if (res)
29 - return fail(res)
27 + if (statusCodeForMissingPerm(node,'can_list',ctx))
28 + return fail()
29 if (dirTraversal(search))
30 return fail(HTTP_FOOL)
31 if (node.default)
@@ -54,7 +53,7 @@ export const file_list: ApiHandler = async ({ uri, offset, limit, search, omit,
53 })
54 return list
55
57 - function fail(code: any) {
56 + function fail(code=ctx.status) {
57 if (!sse)
58 return new ApiError(code)
59 list.error(code, true)
src/upload.ts
+5 -5
@@ -22,9 +22,8 @@ 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) {
25 - const res = statusCodeForMissingPerm(base, 'can_upload', ctx)
26 - if (res)
27 - return fail(res)
25 + if (statusCodeForMissingPerm(base, 'can_upload', ctx))
26 + return fail()
27 const fullPath = join(base.source!, path)
28 const dir = dirname(fullPath)
29 const min = minAvailableMb.get() * (1 << 20)
@@ -125,8 +124,9 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
124 delete waitingToBeDeleted[path]
125 }
126
128 - function fail(status: number) {
129 - ctx.status = status
127 + function fail(status?: number) {
128 + if (status)
129 + ctx.status = status
130 notifyClient(ctx, 'upload.status', { [path]: ctx.status }) // allow browsers to detect failure while still sending body
131 }
132 }
src/vfs.ts
+34 -16
@@ -17,6 +17,7 @@ type AccountList = string[]
17 export type Who = typeof WHO_ANYONE
18 | typeof WHO_NO_ONE
19 | typeof WHO_ANY_ACCOUNT
20 + | keyof VfsPerm
21 | AccountList // empty array shouldn't be used to keep the type boolean-able
22
23 export interface VfsPerm {
@@ -44,9 +45,9 @@ export interface VfsNode extends Partial<VfsPerm> {
45 }
46
47 export const defaultPerms: VfsPerm = {
47 - can_see: WHO_ANYONE,
48 + can_see: 'can_read',
49 can_read: WHO_ANYONE,
49 - can_list: WHO_ANYONE,
50 + can_list: 'can_read',
51 can_upload: WHO_NO_ONE,
52 can_delete: WHO_NO_ONE,
53 }
@@ -156,14 +157,39 @@ export async function nodeIsDirectory(node: VfsNode) {
157 }
158
159 export function hasPermission(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context): boolean {
159 - return (node.source || perm !== 'can_upload') // Upload possible only if we know where to store. First check node.source because is supposedly faster.
160 - && matchWho(node[perm] ?? defaultPerms[perm], ctx)
160 + return !statusCodeForMissingPerm(node, perm, ctx, false)
161 }
162
163 -export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context) {
164 - if (hasPermission(node, perm, ctx))
165 - return false
166 - return ctx.status = node[perm] === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED
163 +export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context, assign=true) {
164 + const ret = getCode()
165 + if (ret && assign)
166 + ctx.status = ret
167 + return ret
168 +
169 + function getCode() {
170 + if (!node.source && perm === 'can_upload') // Upload possible only if we know where to store. First check node.source because is supposedly faster.
171 + return HTTP_FORBIDDEN
172 + // calculate value of permission resolving references to other permissions, avoiding infinite loop
173 + let who: Who
174 + let max = Object.keys(defaultPerms).length
175 + do {
176 + who = node[perm] ?? defaultPerms[perm]
177 + if (!max-- || typeof who !== 'string' || who === WHO_ANY_ACCOUNT)
178 + break
179 + perm = who
180 + } while (1)
181 +
182 + if (Array.isArray(who)) {
183 + const arr = who // shut up ts
184 + // check if I or any ancestor match `who`, but cache ancestors' usernames inside context state
185 + const some = getOrSet(ctx.state, 'usernames', () => getCurrentUsernameExpanded(ctx))
186 + .some((u: string) => arr.includes(u))
187 + return some ? 0 : HTTP_UNAUTHORIZED
188 + }
189 + return typeof who === 'boolean' ? (who ? 0 : HTTP_FORBIDDEN)
190 + : who === WHO_ANY_ACCOUNT ? (ctx.state.account ? 0 : HTTP_UNAUTHORIZED)
191 + : (() => { throw Error('invalid permission: ' + who) })()
192 + }
193 }
194
195 // it's responsibility of the caller to verify you have list permission on parent, as callers have different needs.
@@ -278,14 +304,6 @@ function renameUnderPath(rename:undefined | Record<string,string>, path: string)
304 return _.isEmpty(rename) ? undefined : rename
305 }
306
281 -function matchWho(who: Who, ctx: Koa.Context) {
282 - return who === WHO_ANYONE
283 - || who === WHO_ANY_ACCOUNT && Boolean(ctx.state.account)
284 - || Array.isArray(who) // check if I or any ancestor match `who`, but cache ancestors' usernames inside context state
285 - && getOrSet(ctx.state, 'usernames', () => getCurrentUsernameExpanded(ctx)).some((u:string) =>
286 - who.includes(u) )
287 -}
288 -
307 events.on('accountRenamed', (from, to) => {
308 recur(vfs)
309 saveVfs()
tests/config.yaml
+1
@@ -13,6 +13,7 @@ vfs:
13 - name: child
14 children:
15 - source: tests/alfa.txt
16 + can_see: true
17 - name: renamed
18 source: tests/alfa.txt
19 - name: f1
tests/test.ts
+1 -1
@@ -54,7 +54,7 @@ describe('basics', () => {
54 it('cantListBut.parent', reqList('/', { permInList: { 'cantListBut/': 'l' } }))
55 it('cantListBut.child masked', reqList('/cantListBut/page', 200))
56
57 - it('cantReadBut', reqList('/cantReadBut/', 200))
57 + it('cantReadBut', reqList('/cantReadBut/', 403))
58 it('cantReadBut.can', req('/cantReadBut/alfa.txt', 200))
59 it('cantReadBut.parent', reqList('/', { permInList: { 'cantReadBut/': '!r' } }))
60 it('cantReadButChild', req('/cantReadButChild/alfa.txt', 401))