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