upload
Massimo Melina committed
Jan 8, 2023 at 01:49 UTC
7f500a2a764aac5e76b370a3f4b1c229fbe2ce4a
11 files changed
+282
-45
README.md
+2
@@ -34,6 +34,7 @@ You won't find all previous features here (yet), but still we got:
34
- search
35
- accounts
36
- resumable downloads
37
+- upload
38
- download folders as zip archive
39
- simple website serving
40
- plug-ins
@@ -177,6 +178,7 @@ Valid keys in a node are:
178
- `"*"`: any account can, i.e. anyone who logged in.
179
- `[ frank, peter ]`: the list of accounts who can.
180
- `can_see`: specify who can see this entry. Even if a user can download you can still make the file not appear in the list.
181
+- `can_upload` specify who can upload. Applies to folders with a source. Default is none.
182
Remember that to see in the list you must also be able to download, or else you won't see it anyway. Value is a `WhoCan` descriptor, refer above.
183
- `masks`: maps a file mask to a set of properties as the one documented in this section. E.g.
184
```
admin/src/FileForm.ts
+11
-14
@@ -18,7 +18,7 @@ export default function FileForm({ file }: { file: VfsNode }) {
18
const { parent, children, isRoot, ...rest } = file
19
const [values, setValues] = useState(rest)
20
useEffect(() => {
21
- setValues(Object.assign({ can_see: null, can_read: null }, rest))
21
+ setValues(Object.assign({ can_see: null, can_read: null, can_upload: null }, rest))
22
}, [file]) //eslint-disable-line
23
24
const { source } = file
@@ -26,17 +26,13 @@ export default function FileForm({ file }: { file: VfsNode }) {
26
const hasSource = source !== undefined // we need a boolean
27
const realFolder = hasSource && isDir
28
const inheritedPerms = useMemo(() => {
29
- const ret = { can_read: true, can_see: true }
30
- // reconstruct parents backward
31
- const parents = []
29
+ const ret = {}
30
let run = parent
31
while (run) {
34
- parents.unshift(run)
32
+ _.defaults(ret, run)
33
run = run.parent
34
}
37
- for (const node of parents)
38
- Object.assign(ret, node)
39
- return ret
35
+ return _.defaults(ret, { can_read: true, can_see: true, can_upload: false })
36
}, [parent])
37
const showCanSee = (values.can_read ?? inheritedPerms.can_read) === true
38
const showTimestamps = hasSource && Boolean(values.ctime)
@@ -80,12 +76,9 @@ export default function FileForm({ file }: { file: VfsNode }) {
76
: { k: 'name', required: true, helperText: source && "You can decide a name that's different from the one on your disk" },
77
isRoot ? { k: 'source', comp: FileField, files: false, helperText: "If you specify a folder here, its files will be listed in the home" }
78
: (hasSource && { k: 'source', comp: FileField, folders: true, multiline: true }),
83
- { k: 'can_read', label:"Who can download", xl: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
84
- helperText: "Note: who can't download won't see it in the list"
85
- },
86
- showCanSee && { k: 'can_see', label:"Who can see", xl: 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_see,
87
- helperText: "If you hide this element it will not be listed, but will still be accessible if you have a direct link"
88
- },
79
+ perm('can_read', "Who can download", "Note: who can't download won't see it in the list"),
80
+ showCanSee && perm('can_see', "Who can see", "You can hide and keep it downloadable if you have a direct link"),
81
+ hasSource && perm('can_upload', "Who can upload"),
82
hasSource && !realFolder && { k: 'size', comp: DisplayField, toField: formatBytes },
83
showTimestamps && { k: 'ctime', comp: DisplayField, lg: 6, label: 'Created', toField: formatTimestamp },
84
showTimestamps && { k: 'mtime', comp: DisplayField, lg: 6, label: 'Modified', toField: formatTimestamp },
@@ -97,6 +90,10 @@ export default function FileForm({ file }: { file: VfsNode }) {
90
helperText: "This is a special field. Leave it empty unless you know what you are doing." }
91
]
92
})
93
+
94
+ function perm(perm: keyof typeof inheritedPerms, label: string, helperText='', props={}) {
95
+ return { k: perm, xl: 6, comp: WhoField, parent, accounts, label, inherit: inheritedPerms[perm], helperText, ...props }
96
+ }
97
}
98
99
function formatTimestamp(x: string) {
frontend/src/index.scss
+6
@@ -265,6 +265,12 @@ button label {
265
}
266
}
267
268
+.upload-list {
269
+ & td:nth-child(1) { width: 0; }
270
+ & td:nth-child(2) { text-align: right; width: 0; white-space: nowrap; padding-left: 0.5em; }
271
+ & td:nth-child(3) { padding: .2em .5em; word-break: break-word; }
272
+}
273
+
274
/* Works on Firefox */
275
* {
276
scrollbar-width: thin;
frontend/src/menu.ts
+11
-2
@@ -11,9 +11,12 @@ import showUserPanel from './UserPanel'
11
import { useNavigate } from 'react-router-dom'
12
import _ from 'lodash'
13
import { closeDialog } from '@hfs/shared/dialogs'
14
+import { showUpload, uploadState } from './upload'
15
+import { useSnapshot } from 'valtio'
16
17
export function MenuPanel() {
16
- const { showFilter, remoteSearch, stopSearch, stoppedSearch, patternFilter, selected } = useSnapState()
18
+ const { showFilter, remoteSearch, stopSearch, stoppedSearch, patternFilter, selected, can_upload } = useSnapState()
19
+ const { uploading } = useSnapshot(uploadState)
20
const [filter, setFilter] = useState(patternFilter)
21
;[state.patternFilter] = useDebounce(showFilter ? filter : '', 300)
22
useEffect(() => {
@@ -70,7 +73,13 @@ export function MenuPanel() {
73
}, "Select some files"),
74
}
75
}
73
- })
76
+ }),
77
+ can_upload && h(MenuButton, {
78
+ icon: 'upload',
79
+ label: 'Upload',
80
+ className: uploading && 'ani-working',
81
+ onClick: showUpload,
82
+ }),
83
),
84
remoteSearch && h('div', { id: 'searched' },
85
(stopSearch ? 'Searching' : 'Searched') + ': ' + remoteSearch + prefix(' (', stoppedSearch && 'interrupted', ')')),
frontend/src/state.ts
+2
@@ -28,7 +28,9 @@ export const state = proxy<{
28
serverConfig?: any,
29
loginRequired?: boolean, // force user to login before proceeding
30
messageOnly?: string, // no gui, just show this message
31
+ can_upload: boolean
32
}>({
33
+ can_upload: false,
34
iconsClass: '',
35
username: '',
36
list: [],
frontend/src/upload.ts
new
+191
@@ -0,0 +1,191 @@
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 { createElement as h, useState } from 'react'
4
+import { Flex, FlexV } from './components'
5
+import { formatBytes, hIcon, newDialog, prefix } from './misc'
6
+import _ from 'lodash'
7
+import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8
+import { alertDialog } from './dialog'
9
+import { reloadList } from './useFetchList'
10
+
11
+export const uploadState = proxy<{
12
+ done: number
13
+ doneByte: number
14
+ errors: number
15
+ qs: { to: string, files: File[] }[]
16
+ paused: boolean
17
+ uploading?: File
18
+}>({
19
+ paused: false,
20
+ qs: [],
21
+ errors: 0,
22
+ doneByte: 0,
23
+ done: 0,
24
+})
25
+
26
+let reloadOnClose = false
27
+
28
+export function showUpload() {
29
+ if (!uploadState.qs.length)
30
+ Object.assign(uploadState, {
31
+ errors: 0,
32
+ done: 0,
33
+ doneByte: 0,
34
+ })
35
+ newDialog({
36
+ dialogProps: { style: { minWidth: 'min(20em, 100vw - 1em)' } },
37
+ title: "Upload",
38
+ icon: () => hIcon('upload'),
39
+ Content,
40
+ onClose() {
41
+ if (!reloadOnClose) return
42
+ reloadOnClose = false
43
+ reloadList()
44
+ }
45
+ })
46
+
47
+ function Content(){
48
+ const [files, setFiles] = useState([] as File[])
49
+ const { qs, done, doneByte, paused } = useSnapshot(uploadState)
50
+ return h(FlexV, {},
51
+ h(Flex, { gap: '.5em', flexWrap: 'wrap', justifyContent: 'center', position: 'sticky', top: -4, background: 'var(--bg)', boxShadow: '0 3px 3px #000' },
52
+ h('button',{ onClick: () => selectFiles() }, "Add file(s)"),
53
+ h('button',{ onClick: () => selectFiles(true) }, "Add folder"),
54
+ files.length > 1 && h('button', { onClick() { setFiles([]) } }, "Clear"),
55
+ files.length > 0 && h('button', {
56
+ onClick() {
57
+ const to = location.pathname
58
+ const ready = _.find(uploadState.qs, { to })
59
+ if (!ready)
60
+ uploadState.qs.push({ to, files: files.map(ref) })
61
+ else {
62
+ _.remove(ready.files, f => { // avoid duplicates
63
+ const match = path(f)
64
+ return Boolean(_.find(files, x => match === path(x)))
65
+ })
66
+ ready.files.push(...files.map(ref))
67
+ }
68
+ setFiles([])
69
+ }
70
+ }, `Send ${files.length} file(s), ${formatBytes(files.reduce((a, f) => a + f.size, 0))}`),
71
+ ),
72
+ h(FilesList, {
73
+ files,
74
+ remove(f) {
75
+ setFiles(files.filter(x => x !== f))
76
+ }
77
+ }),
78
+ done > 0 && h('div', {}, `Finished ${done} files, ${formatBytes(doneByte)}`),
79
+ qs.length > 0 && h('div', {},
80
+ h(Flex, { alignItems: 'center', justifyContent: 'center', borderTop: '1px dashed', padding: '.5em' },
81
+ "Queue",
82
+ `(${_.sumBy(qs, q => q.files.length)})`,
83
+ h('button',{
84
+ onClick(){
85
+ abortCurrentUpload()
86
+ uploadState.qs = []
87
+ }
88
+ }, "Clear"),
89
+ h('button',{
90
+ onClick(){
91
+ uploadState.paused = !uploadState.paused
92
+ }
93
+ }, paused ? "Resume" : "Pause"),
94
+ ),
95
+ qs.map((q,idx) =>
96
+ h('div', { key: q.to },
97
+ h('div', {}, "Destination ", q.to),
98
+ h(FilesList, {
99
+ files: Array.from(q.files),
100
+ remove(f) {
101
+ if (f === uploadState.uploading)
102
+ abortCurrentUpload()
103
+ else
104
+ _.pull(uploadState.qs[idx].files, f)
105
+ }
106
+ }),
107
+ ))
108
+ )
109
+ )
110
+
111
+ function selectFiles(folder=false) {
112
+ const el = Object.assign(document.createElement('input'), {
113
+ type: 'file',
114
+ name: 'file',
115
+ multiple: true,
116
+ webkitdirectory: folder,
117
+ })
118
+ el.addEventListener('change', () =>
119
+ setFiles([ ...files, ...el.files ||[] ] ))
120
+ el.click()
121
+ }
122
+ }
123
+
124
+}
125
+
126
+function path(f: File, pre='') {
127
+ return (prefix('', pre, '/') + (f.webkitRelativePath || f.name)).replaceAll('//','/')
128
+}
129
+
130
+function FilesList({ files, remove }: { files: File[], remove: (f:File) => any }) {
131
+ const { uploading } = useSnapshot(uploadState)
132
+ return !files.length ? null : h('table', { className: 'upload-list', width: '100%' },
133
+ h('tbody', {},
134
+ files.map((f,i) =>
135
+ h('tr', { key: i },
136
+ h('td', {}, iconBtn('trash', () => remove(f))),
137
+ h('td', {}, formatBytes(f.size)),
138
+ h('td', { className: f === uploading ? 'ani-working' : undefined }, path(f)),
139
+ ))
140
+ )
141
+ )
142
+}
143
+
144
+function iconBtn(icon: string, onClick: () => any, { small=true, style={}, ...props }={}) {
145
+ return h('button', { onClick, ...props, ...small && { style: { padding: '.1em', ...style } } }, hIcon(icon))
146
+}
147
+
148
+/// Manage uploadQ
149
+
150
+let controller: AbortController | undefined
151
+subscribe(uploadState, () => {
152
+ const [cur] = uploadState.qs
153
+ if (cur && !uploadState.uploading && !uploadState.paused)
154
+ startUpload(cur.files[0], cur.to)
155
+})
156
+
157
+function startUpload(f: File | undefined, to: string) {
158
+ if (!f) return
159
+ uploadState.uploading = f
160
+ controller = new AbortController()
161
+ const full = path(f, to)
162
+ fetch(full, {
163
+ method: 'PUT',
164
+ body: f,
165
+ signal: controller.signal,
166
+ }).then(async res => {
167
+ if (!res.ok)
168
+ throw Error("Upload failed for " + full)
169
+ uploadState.done++
170
+ uploadState.doneByte += f.size
171
+ reloadOnClose = true
172
+ }).finally(() => {
173
+ uploadState.uploading = undefined
174
+ const { qs } = uploadState
175
+ qs[0].files.shift()
176
+ if (!qs[0].files.length)
177
+ qs.shift()
178
+ if (!qs.length) {
179
+ reloadList()
180
+ reloadOnClose = false
181
+ }
182
+ }).catch(e => {
183
+ if (e.code === e.ABORT_ERR) return
184
+ if (!uploadState.errors++)
185
+ alertDialog(e).then()
186
+ })
187
+}
188
+
189
+function abortCurrentUpload() {
190
+ controller?.abort()
191
+}
\ No newline at end of file
frontend/src/useFetchList.ts
+7
-4
@@ -39,6 +39,7 @@ export default function useFetchList() {
39
state.selected = {}
40
state.loading = true
41
state.error = undefined
42
+ state.can_upload = false
43
// buffering entries is necessary against burst of events that will hang the browser
44
const buffer: DirList = []
45
const flush = () => {
@@ -61,10 +62,12 @@ export default function useFetchList() {
62
lastReq.current = undefined
63
return
64
case 'msg':
64
- data.forEach((data: any) => {
65
- if (data.add)
66
- return buffer.push(data.add)
67
- const { error } = data
65
+ data.forEach((entry: any) => {
66
+ if (entry.props)
67
+ return Object.assign(state, _.pick(entry.props, ['can_upload']))
68
+ if (entry.add)
69
+ return buffer.push(entry.add)
70
+ const { error } = entry
71
if (error === 405) { // "method not allowed" happens when we try to directly access an unauthorized file, and we get a login prompt, and then file_list the file (because we didn't know it was file or folder)
72
state.messageOnly = "Your download should now start"
73
window.location.reload() // reload will start the download, because now we got authenticated
src/api.file_list.ts
+7
-1
@@ -26,9 +26,15 @@ export const file_list: ApiHandler = async ({ path, offset, limit, search, omit,
26
const filter = pattern2filter(search)
27
const walker = walkNode(node, ctx, search ? Infinity : 0)
28
const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
29
+ const can_upload = hasPermission(node, 'can_upload', ctx)
30
if (!sse)
30
- return { list: await asyncGeneratorToArray(produceEntries()) }
31
+ return {
32
+ can_upload,
33
+ list: await asyncGeneratorToArray(produceEntries())
34
+ }
35
setTimeout(async () => {
36
+ if (can_upload)
37
+ list.custom({ props: { can_upload } })
38
for await (const entry of produceEntries())
39
list.add(entry)
40
list.close()
src/api.vfs.ts
+2
-2
@@ -1,6 +1,6 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from './vfs'
3
+import { defaultPerms, getNodeName, nodeIsDirectory, saveVfs, urlToNode, vfs, VfsNode } from './vfs'
4
import _ from 'lodash'
5
import { stat } from 'fs/promises'
6
import { ApiError, ApiHandlers } from './apiMiddleware'
@@ -60,7 +60,7 @@ const apis: ApiHandlers = {
60
const n = await urlToNodeOriginal(uri)
61
if (!n)
62
return new ApiError(HTTP_NOT_FOUND, 'path not found')
63
- props = pickProps(props, ['name','source','can_see','can_read','masks','default'])
63
+ props = pickProps(props, ['name','source','masks','default', ...Object.keys(defaultPerms)])
64
props = objSameKeys(props, v => v === null ? undefined : v) // null is a way to serialize undefined, that will restore default values
65
if (props.masks && typeof props.masks !== 'object')
66
delete props.masks
src/middlewares.ts
+19
-2
@@ -8,10 +8,10 @@ import {
8
ADMIN_URI,
9
BUILD_TIMESTAMP,
10
DEV,
11
- HTTP_FORBIDDEN,
11
SESSION_DURATION,
12
+ HTTP_FORBIDDEN,
13
HTTP_UNAUTHORIZED,
14
- HTTP_NOT_FOUND
14
+ HTTP_NOT_FOUND,
15
} from './const'
16
import { FRONTEND_URI } from './const'
17
import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs, VfsNode } from './vfs'
@@ -76,6 +76,13 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
76
return ctx.redirect(ADMIN_URI)
77
if (path.startsWith(ADMIN_URI))
78
return serveAdminPrefixed(ctx,next)
79
+ if (ctx.method === 'PUT') { // curl -T file url/
80
+ let rest = basename(path)
81
+ const folder = await urlToNode(dirname(path), ctx, vfs, v => rest = v+'/'+rest)
82
+ if (!folder)
83
+ return ctx.status = HTTP_NOT_FOUND
84
+ return await getUpload(folder, rest, ctx.req, ctx)
85
+ }
86
const node = await urlToNode(path, ctx)
87
if (!node)
88
return ctx.status = HTTP_NOT_FOUND
@@ -109,6 +116,16 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
116
return serveFrontendFiles(ctx, next)
117
}
118
119
+async function getUpload(base: VfsNode, path: string, stream: Readable, ctx: Koa.Context) {
120
+ if (!base.source || !hasPermission(base, 'can_upload', ctx))
121
+ return ctx.status = base.can_upload === false ? HTTP_FORBIDDEN : HTTP_UNAUTHORIZED
122
+ path = join(base.source, path)
123
+ mkdirSync(dirname(path), { recursive: true })
124
+ const dest = createWriteStream(path)
125
+ await pipeline(stream, dest)
126
+ ctx.body = '{}'
127
+}
128
+
129
let proxyDetected = false
130
export const someSecurity: Koa.Middleware = async (ctx, next) => {
131
ctx.request.ip = normalizeIp(ctx.ip)
src/vfs.ts
+24
-20
@@ -22,8 +22,9 @@ type Who = typeof WHO_ANYONE
22
| AccountList
23
24
interface VfsPerm {
25
- can_see: Who
25
can_read: Who
26
+ can_see: Who // use this to hide something you can_read
27
+ can_upload: Who
28
}
29
30
type Masks = Record<string, VfsNode>
@@ -39,13 +40,13 @@ export interface VfsNode extends Partial<VfsPerm> {
40
// fields that are only filled at run-time
41
isTemp?: true // this node doesn't belong to the tree and was created by necessity
42
url?: string // what url brought to this node
42
- parents?: VfsNode[]
43
original?: VfsNode // if this is a temp node but reflecting an existing node
44
}
45
46
export const defaultPerms: VfsPerm = {
47
can_see: WHO_ANYONE,
48
can_read: WHO_ANYONE,
49
+ can_upload: WHO_NO_ONE,
50
}
51
52
export const MIME_AUTO = 'auto'
@@ -63,7 +64,7 @@ function inheritFromParent(parent: VfsNode, child: VfsNode) {
64
return child
65
}
66
66
-export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=vfs) : Promise<VfsNode | undefined> {
67
+export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=vfs, getRest?: (rest: string) => any) : Promise<VfsNode | undefined> {
68
let initialSlashes = 0
69
while (url[initialSlashes] === '/')
70
initialSlashes++
@@ -77,23 +78,23 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
78
ctx.status = HTTP_FOOL
79
return
80
}
80
- const parents = parent.parents || [] // don't waste time cloning the array, as we won't keep intermediate nodes
81
+ // does the tree node have a child that goes by this name?
82
+ const sameName = !IS_WINDOWS ? (x:string) => x === name // easy
83
+ : with_(name.toLowerCase(), lc =>
84
+ (x: string) => x.toLowerCase() === lc)
85
+ const child = parent.children?.find(x => sameName(getNodeName(x)))
86
+
87
const ret: VfsNode = {
88
+ ...child,
89
+ original: child,
90
isTemp: true,
91
url: enforceFinal('/', parent.url || '') + name,
84
- parents,
92
}
86
- parents.push(parent)
93
inheritFromParent(parent, ret)
94
inheritMasks(ret, parent, name)
95
applyMasks(ret, parent, name)
90
- // does the tree node have a child that goes by this name?
91
- const sameName = !IS_WINDOWS ? (x:string) => x === name // easy
92
- : with_(name.toLowerCase(), lc =>
93
- (x: string) => x.toLowerCase() === lc)
94
- const child = parent.children?.find(x => sameName(getNodeName(x)))
96
if (child) // yes
96
- return urlToNode(rest, ctx, Object.assign(ret, child, { original: child }))
97
+ return urlToNode(rest, ctx, ret, getRest)
98
// not in the tree, we can see consider continuing on the disk
99
if (!parent.source) return // but then we need the current node to be linked to the disk, otherwise, we give up
100
let onDisk = name
@@ -109,10 +110,15 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
110
if (parent.default)
111
inheritFromParent({ mime: { '*': MIME_AUTO } }, ret)
112
if (rest)
112
- return urlToNode(rest, ctx, ret)
113
+ return urlToNode(rest, ctx, ret, getRest)
114
if (ret.source)
115
try { await fs.stat(ret.source) } // check existence
115
- catch { return }
116
+ catch {
117
+ if (!getRest)
118
+ return
119
+ getRest(onDisk)
120
+ return parent
121
+ }
122
return ret
123
}
124
@@ -140,7 +146,7 @@ export async function nodeIsDirectory(node: VfsNode) {
146
147
export function hasPermission(node: VfsNode, perm: keyof VfsPerm, ctx: Koa.Context): boolean {
148
return matchWho(node[perm] ?? defaultPerms[perm], ctx)
143
- && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // for can_see you must also can_read
149
+ && (perm !== 'can_see' || hasPermission(node, 'can_read', ctx)) // can_see is used to hide something you nonetheless can_read, so you MUST also can_read
150
}
151
152
export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=0, prefixPath:string=''): AsyncIterableIterator<VfsNode> {
@@ -176,11 +182,9 @@ export async function* walkNode(parent:VfsNode, ctx?: Koa.Context, depth:number=
182
const name = getNodeName(item)
183
// we basename for depth>0 where we already have the rest of the path in the parent's url, and would be duplicated
184
const virtualBasename = basename(name)
179
- const url = enforceFinal('/', parent.url || '') + virtualBasename
185
Object.assign(item, {
186
isTemp: true,
182
- url,
183
- parents: [ ...parent.parents||[], parent],
187
+ url: enforceFinal('/', parent.url || '') + virtualBasename,
188
})
189
inheritFromParent(parent, item)
190
applyMasks(item, parent, virtualBasename)
@@ -240,8 +244,8 @@ events.on('accountRenamed', (from, to) => {
244
saveVfs()
245
246
function recur(n: VfsNode) {
243
- replace(n.can_see)
244
- replace(n.can_read)
247
+ for (const k of typedKeys(defaultPerms))
248
+ replace(n[k])
249
250
if (n.masks)
251
Object.values(n.masks).forEach(recur)