new permission format: { this, children }
Massimo Melina committed
Jul 22, 2023 at 15:25 UTC
446b1f16c0ccb1402e703e26cc9a563fa7cd7795
8 files changed
+120
-67
admin/src/FileForm.ts
+61
-37
@@ -1,8 +1,8 @@
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 { state } from './state'
4
-import { createElement as h, ReactNode, useEffect, useMemo, useState } from 'react'
5
-import { Alert, Box, Link, MenuItem, MenuList, } from '@mui/material'
4
+import { createElement as h, ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'
5
+import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList, } from '@mui/material'
6
import {
7
BoolField,
8
DisplayField,
@@ -77,6 +77,7 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, status
77
return element
78
const accounts = data.list
79
80
+ const needSourceWarning = !hasSource && "Works only on folders with source! "
81
return h(Form, {
82
values,
83
set(v, k) {
@@ -119,23 +120,12 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, status
120
placeholder: "Not on disk, this is a virtual folder",
121
},
122
perm('can_read', "Who can see but not download will be asked to login"),
122
- perm('can_see', "If you don't see, you may still download with a direct link", {
123
- after: isDir && values.can_see != null
124
- && h(BoolField, {
125
- size: 'small',
126
- label: `Propagate permission inside this folder`,
127
- value: values.propagate?.can_see !== false,
128
- onChange(v) {
129
- const o = { ...values.propagate, can_see: v ? undefined : false } // new "propagate" object
130
- setValues({ ...values, propagate: _.every(o, v => v === undefined) ? null : o })
131
- }
132
- })
133
- }),
134
- isDir && perm('can_list', "Permission to see content of folders"),
135
- isDir && perm('can_delete', [hasSource ? '' : "Works only on folders with source. ", "Who can delete can also rename."]),
136
- isDir && perm('can_upload', hasSource ? '' : "Works only on folders with source", { lg: showAccept ? 6 : 12 }),
137
- showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything",
138
- helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip"), lg: 6 },
123
+ perm('can_see', "If you can't see, you may still download with a direct link"),
124
+ isDir && perm('can_list', "Permission to see content of folders", { contentText: "subfolders" }),
125
+ isDir && perm('can_delete', [needSourceWarning, "Those who can delete can also rename"]),
126
+ isDir && perm('can_upload', needSourceWarning, { lg: showAccept ? 6 : 12, contentText: "subfolders" }),
127
+ showAccept && { k: 'accept', label: "Accept on upload", placeholder: "anything", lg: 6,
128
+ helperText: h(Link, { href: ACCEPT_LINK, target: '_blank' }, "Example: .zip") },
129
showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
130
showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: showSize && 4, label: 'Created', toField: formatTimestamp },
131
showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: showSize && 4, label: 'Modified', toField: formatTimestamp },
@@ -159,6 +149,7 @@ export default function FileForm({ file, anyMask, defaultPerms, addToBar, status
149
label: "Who can " + perm2word(perm),
150
inherit: inheritedPerms[perm],
151
byMasks: byMasks?.[perm],
152
+ isDir,
153
...props
154
}
155
}
@@ -174,10 +165,21 @@ function formatTimestamp(x: string) {
165
return x ? new Date(x).toLocaleString() : '-'
166
}
167
177
-interface WhoFieldProps extends FieldProps<Who> { accounts: Account[], otherPerms: any[] }
178
-function WhoField({ value, onChange, parent, inherit, accounts, helperText, showInherited, otherPerms, byMasks, ...rest }: WhoFieldProps) {
168
+interface WhoFieldProps extends FieldProps<Who | undefined> {
169
+ accounts: Account[],
170
+ otherPerms: any[],
171
+ isChildren?: boolean,
172
+ isDir: boolean
173
+ contentText?: string
174
+}
175
+function WhoField({ value, onChange, parent, inherit, accounts, helperText, showInherited, otherPerms, byMasks,
176
+ isChildren, isDir, contentText="folder content", ...rest }: WhoFieldProps): ReactElement {
177
const defaultLabel = (byMasks !== undefined ? "As per mask: " : parent !== undefined ? "As parent: " : "Default: " )
178
+ who2desc(byMasks ?? inherit)
179
+ const objectMode = value != null && typeof value === 'object' && !Array.isArray(value)
180
+ const childrenValue = objectMode && value.children
181
+ const thisValue = objectMode ? value.this : value
182
+
183
const options = useMemo(() =>
184
onlyTruthy([
185
{ value: null, label: defaultLabel },
@@ -187,28 +189,50 @@ function WhoField({ value, onChange, parent, inherit, accounts, helperText, show
189
...otherPerms,
190
{ value: [], label: "Select accounts" },
191
// don't offer inherited value twice, unless it was already selected, or it is forced
190
- ].map(x => (x.value === value || showInherited || x.value !== inherit)
192
+ ].map(x => (x.value === thisValue || showInherited || x.value !== inherit)
193
&& { label: _.capitalize(who2desc(x.value)), ...x })), // default label
192
- [inherit, parent, value])
194
+ [inherit, parent, thisValue])
195
194
- const arrayMode = Array.isArray(value)
195
- return h('div', {},
196
- h(SelectField as Field<Who>, {
196
+ const timeout = 500
197
+ const arrayMode = Array.isArray(thisValue)
198
+ // a large side band will convey union across the fields
199
+ return h(Box, { sx: { borderRight: objectMode ? '8px solid #8884' : undefined, transition: `all ${timeout}ms` } },
200
+ h(SelectField as Field<typeof thisValue>, {
201
...rest,
198
- helperText: !arrayMode && helperText,
199
- value: arrayMode ? [] : value,
200
- onChange(v, { was, event }) {
201
- onChange(v, { was , event })
202
+ value: arrayMode ? [] : thisValue,
203
+ onChange(v, { event }) {
204
+ onChange(objectMode ? { this: v, children: childrenValue } : v, { was: value, event })
205
},
203
- options
206
+ options,
207
}),
205
- arrayMode && h(MultiSelectField as Field<string[]>, {
206
- label: accounts?.length ? "Choose accounts for " + rest.label : "You didn't create any account yet",
207
- value,
208
- onChange,
208
+ h(Collapse, { in: arrayMode, timeout },
209
+ arrayMode && h(MultiSelectField as Field<string[]>, {
210
+ label: accounts?.length ? "Choose accounts for " + rest.label : "You didn't create any account yet",
211
+ value: thisValue,
212
+ onChange,
213
+ options: accounts?.map(a => ({ value: a.username, label: a.username })) || [],
214
+ }) ),
215
+ h(FormHelperText, {},
216
helperText,
210
- options: accounts?.map(a => ({ value: a.username, label: a.username })) || [],
211
- })
217
+ !isChildren && isDir && h(Link, {
218
+ sx: { display: 'block', cursor: 'pointer', mt: -.5 },
219
+ onClick(event) {
220
+ if (thisValue === undefined) return
221
+ onChange(objectMode ? thisValue : { this: value }, { was: value, event })
222
+ }
223
+ }, objectMode ? "Different permission for " : "Same permission for ", contentText)
224
+ ),
225
+ !isChildren && h(Collapse, { in: objectMode, timeout },
226
+ h(WhoField, {
227
+ label: "Permission for " + contentText,
228
+ parent, inherit, accounts, showInherited, otherPerms, isDir,
229
+ isChildren: true,
230
+ value: childrenValue ?? null,
231
+ onChange(v, { event }) {
232
+ onChange({ this: thisValue, children: v as any }, { was: value, event })
233
+ }
234
+ })
235
+ ),
236
)
237
}
238
admin/src/VfsPage.ts
+3
-2
@@ -178,7 +178,6 @@ export interface VfsNode extends VfsPerms {
178
default?: string
179
children?: VfsNode[]
180
parent?: VfsNode
181
- propagate?: Partial<Record<keyof VfsPerm, boolean>> | null
181
website?: true
182
masks?: any
183
byMasks?: VfsPerms
@@ -190,7 +189,9 @@ const WHO_ANYONE = true
189
const WHO_NO_ONE = false
190
const WHO_ANY_ACCOUNT = '*'
191
type AccountList = string[]
193
-export type Who = typeof WHO_ANYONE
192
+export type Who = SimpleWho | { this?: SimpleWho, children?: SimpleWho }
193
+export type SimpleWho = typeof WHO_ANYONE
194
| typeof WHO_NO_ONE
195
| typeof WHO_ANY_ACCOUNT
196
| AccountList
197
+ | null
\ No newline at end of file
config.md
+6
-6
@@ -61,16 +61,16 @@ Valid keys in a node are:
61
- `false`: no one can.
62
- `"*"`: any account can, i.e. anyone who logged in.
63
- `[ frank, peter ]`: the list of accounts who can.
64
+ - `{ this?: WhoCan, children?: WhoCan }`: this form is useful only for folders. By using it, you can have
65
+ different permission for the folder itself and its children. For example, having only the `this` property
66
+ will make the permission limited to the folder and not be inherited by children. Otherwise, having only
67
+ the `children` will make the permission have no effect on the folder, but only on its content.
68
+ - `this` specifies permission for this folder
69
+ - `children` specifies permission for the content.
70
- `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.
71
Value is a `WhoCan` descriptor, refer above.
72
- `can_upload`: specify who can upload. Applies to folders with a source. Default is none.
73
- `can_delete`: specify who can delete. Applies to folders with a source. Default is none.
68
-- `propagate`: by default, permissions propagate. Use this to stop propagation of some permissions assigned to this node.
69
- For each permission you don't want to propagate you specify the name and set it to false. E.g.
70
- ```
71
- can_see: false
72
- ```
73
- Default is "all propagates".
74
- `masks`: maps a file mask to a set of properties as the one documented in this section. E.g.
75
```
76
myfile.txt:
src/api.monitor.ts
+3
-3
@@ -2,7 +2,7 @@
2
3
import _ from 'lodash'
4
import { Connection, getConnections } from './connections'
5
-import { isLocalHost, pendingPromise, typedKeys, wait } from './misc'
5
+import { pendingPromise, typedEntries, wait } from './misc'
6
import { ApiHandlers, SendListReadable } from './apiMiddleware'
7
import Koa from 'koa'
8
import { totalGot, totalInSpeed, totalOutSpeed, totalSent } from './throttler'
@@ -56,8 +56,8 @@ const apis: ApiHandlers = {
56
change.opProgress = _.round(change.opProgress, 3)
57
// avoid sending non-changes
58
const last = conn[sent]
59
- for (const k of typedKeys(change))
60
- if (change[k] === last[k])
59
+ for (const [k, v] of typedEntries(change))
60
+ if (v === last[k])
61
delete change[k]
62
if (_.isEmpty(change)) return
63
Object.assign(last, change)
src/api.vfs.ts
+1
-1
@@ -104,7 +104,7 @@ const apis: ApiHandlers = {
104
const n = await urlToNodeOriginal(uri)
105
if (!n)
106
return new ApiError(HTTP_NOT_FOUND, 'path not found')
107
- props = pickProps(props, ['name','source','masks','default','accept','propagate', ...PERM_KEYS]) // sanitize
107
+ props = pickProps(props, ['name','source','masks','default','accept', ...PERM_KEYS]) // sanitize
108
if (props.name && props.name !== getNodeName(n)) {
109
const parent = await urlToNodeOriginal(dirname(uri))
110
if (parent?.children?.find(x => getNodeName(x) === props.name))
src/misc.ts
+4
@@ -165,6 +165,10 @@ export function typedKeys<T extends {}>(o: T) {
165
return Object.keys(o) as (keyof T)[]
166
}
167
168
+export function typedEntries<T extends {}>(o: T): [keyof T, T[keyof T]][] {
169
+ return Object.entries(o) as [keyof T, T[keyof T]][];
170
+}
171
+
172
export function hasProp<T extends object>(obj: T, key: PropertyKey): key is keyof T {
173
return key in obj;
174
}
src/vfs.ts
+38
-11
@@ -11,7 +11,8 @@ import {
11
typedKeys,
12
makeMatcher,
13
setHidden,
14
- onlyTruthy
14
+ onlyTruthy,
15
+ typedEntries
16
} from './misc'
17
import Koa from 'koa'
18
import _ from 'lodash'
@@ -28,7 +29,9 @@ export type Who = typeof WHO_ANYONE
29
| typeof WHO_NO_ONE
30
| typeof WHO_ANY_ACCOUNT
31
| keyof VfsPerm
32
+ | WhoObject
33
| AccountList // empty array shouldn't be used to keep the type boolean-able
34
+interface WhoObject { this?: Who, children?: Who }
35
36
export interface VfsPerm {
37
can_read: Who
@@ -49,7 +52,7 @@ export interface VfsNode extends Partial<VfsPerm> {
52
rename?: Record<string, string>
53
masks?: Masks // express fields for descendants that are not in the tree
54
accept?: string
52
- propagate?: Record<keyof VfsPerm, boolean>
55
+ propagate?: Record<keyof VfsPerm, boolean> // legacy pre-0.47
56
// fields that are only filled at run-time
57
isTemp?: true // this node doesn't belong to the tree and was created by necessity
58
original?: VfsNode // if this is a temp node but reflecting an existing node
@@ -71,12 +74,18 @@ export const MIME_AUTO = 'auto'
74
75
function inheritFromParent(parent: VfsNode, child: VfsNode) {
76
for (const k of typedKeys(defaultPerms)) {
74
- let dueParent: VfsNode | undefined = parent
75
- while (dueParent?.propagate?.[k] === false)
76
- dueParent = dueParent.parent
77
- const v = dueParent?.[k]
78
- if (v !== undefined) // small optimization: don't expand the object
79
- child[k] ??= v
77
+ let p: VfsNode | undefined = parent
78
+ let inheritedPerm: Who | undefined
79
+ while (p) {
80
+ inheritedPerm = p[k]
81
+ // // in case of object without children, parent is skipped in favor of the parent's parent
82
+ if (!isWhoObject(inheritedPerm)) break
83
+ inheritedPerm = inheritedPerm.children
84
+ if (inheritedPerm !== undefined) break
85
+ p = p.parent
86
+ }
87
+ if (inheritedPerm !== undefined) // small optimization: don't expand the object
88
+ child[k] ??= inheritedPerm
89
}
90
if (typeof parent.mime === 'object' && typeof child.mime === 'object')
91
_.defaults(child.mime, parent.mime)
@@ -86,6 +95,10 @@ function inheritFromParent(parent: VfsNode, child: VfsNode) {
95
return child
96
}
97
98
+function isWhoObject(v: undefined | Who): v is WhoObject {
99
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
100
+}
101
+
102
export function isSameFilenameAs(name: string) {
103
const lc = name.toLowerCase()
104
return (other: string | VfsNode) =>
@@ -158,7 +171,18 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
171
172
export let vfs: VfsNode = {}
173
defineConfig<VfsNode>('vfs', {}).sub(data =>
161
- vfs = data)
174
+ vfs = (function recur(node) {
175
+ if (node.propagate) { // legacy pre-0.47
176
+ for (const [k,v] of typedEntries(node.propagate))
177
+ if (v === false)
178
+ node[k] = { this: node[k] }
179
+ delete node.propagate
180
+ }
181
+ if (node.children)
182
+ for (const c of node.children)
183
+ recur(c)
184
+ return node
185
+ })(data) )
186
187
export function saveVfs() {
188
return setConfig({ vfs: _.cloneDeep(vfs) }, true)
@@ -205,10 +229,13 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerm, ctx
229
if (!node.source && perm === 'can_upload') // Upload possible only if we know where to store. First check node.source because is supposedly faster.
230
return HTTP_FORBIDDEN
231
// calculate value of permission resolving references to other permissions, avoiding infinite loop
208
- let who: Who
232
+ let who: Who | undefined
233
let max = PERM_KEYS.length
234
do {
211
- who = node[perm] ?? defaultPerms[perm]
235
+ who = node[perm]
236
+ if (isWhoObject(who))
237
+ who = who.this
238
+ who ??= defaultPerms[perm]
239
if (!max-- || typeof who !== 'string' || who === WHO_ANY_ACCOUNT)
240
break
241
perm = who
tests/config.yaml
+4
-7
@@ -32,10 +32,8 @@ vfs:
32
- name: for-admins
33
can_read:
34
- admins
35
- can_list:
36
- - admins
37
- can_see:
38
- - admins
35
+ can_list: can_read
36
+ can_see: can_read
37
children:
38
- name: upload
39
source: tests
@@ -99,9 +97,8 @@ vfs:
97
children:
98
- name: hi
99
- name: cantSeeThisButChildren
102
- can_see: false
103
- propagate:
104
- can_see: false
100
+ can_see:
101
+ this: false
102
children:
103
- name: hi
104
- name: cantSeeThisButChildrenMasks