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