fix: maskOnly not working for dynamically loaded folders

Massimo Melina committed Dec 31, 2023 at 12:38 UTC 044e50c3e2ff78961cce04ce1942596a7998f34d
6 files changed +54 -38
src/api.get_file_list.ts
+1 -1
@@ -34,7 +34,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
34 const walker = walkNode(node, { ctx: admin ? undefined : ctx, onlyFolders, depth: search ? Infinity : 0 })
35 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
36 const can_upload = admin || hasPermission(node, 'can_upload', ctx)
37 - const fakeChild = applyParentToChild({}, node) // can we delete children
37 + const fakeChild = await applyParentToChild(undefined, node) // can we delete children
38 const can_delete = admin || hasPermission(fakeChild, 'can_delete', ctx)
39 const can_archive = admin || hasPermission(node, 'can_archive', ctx)
40 const can_comment = can_upload && areCommentsEnabled()
src/api.vfs.ts
+2 -2
@@ -50,8 +50,8 @@ const apis: ApiHandlers = {
50 || undefined,
51 name: node === vfs ? '' : getNodeName(node),
52 type: isDir ? 'folder' : undefined,
53 - children: node.children && await Promise.all(node.children.map(child =>
54 - recur(applyParentToChild(child, node)) ))
53 + children: node.children && await Promise.all(node.children.map(async child =>
54 + recur(await applyParentToChild(child, node)) ))
55 }
56 }
57 },
src/upload.ts
+3 -3
@@ -95,7 +95,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
95 ret.once('close', async () => {
96 if (!ctx.req.aborted) {
97 let dest = fullPath
98 - if (dontOverwriteUploading.get() && fs.existsSync(dest) && !overwriteAnyway()) {
98 + if (dontOverwriteUploading.get() && fs.existsSync(dest) && !await overwriteAnyway()) {
99 const ext = extname(dest)
100 const base = dest.slice(0, -ext.length)
101 let i = 1
@@ -120,9 +120,9 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
120 })
121 return ret
122
123 - function overwriteAnyway() {
123 + async function overwriteAnyway() {
124 if (ctx.query.overwrite === undefined) return
125 - const n = getNodeByName(path, base)
125 + const n = await getNodeByName(path, base)
126 return n && hasPermission(n, 'can_delete', ctx)
127 }
128
src/vfs.ts
+37 -32
@@ -2,14 +2,12 @@
2
3 import fs from 'fs/promises'
4 import { basename, dirname, join, resolve } from 'path'
5 -import {
6 - dirStream, dirTraversal, enforceFinal, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy,
7 - throw_, VfsPerms, Who, isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting
8 -} from './misc'
5 +import { dirStream, dirTraversal, enforceFinal, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy,
6 + throw_, VfsPerms, Who, isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting } from './misc'
7 import Koa from 'koa'
8 import _ from 'lodash'
9 import { defineConfig, setConfig } from './config'
12 -import { HTTP_FOOL, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, MIME_AUTO } from './const'
10 +import { HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, MIME_AUTO } from './const'
11 import events from './events'
12 import { expandUsername } from './perm'
13 import { getCurrentUsername } from './auth'
@@ -70,17 +68,17 @@ export function isSameFilenameAs(name: string) {
68 lc === (typeof other === 'string' ? other : getNodeName(other)).toLowerCase()
69 }
70
73 -export function applyParentToChild(child: VfsNode | undefined, parent: VfsNode, name?: string) {
71 +export async function applyParentToChild(child: VfsNode | undefined, parent: VfsNode, name?: string) {
72 const ret: VfsNode = {
75 - isFolder: child?.children?.length ? true : undefined, // allow child to overwrite this property
73 + original: child, // leave it possible for child to override this
74 ...child,
77 - original: child,
75 + isFolder: child?.isFolder ?? (!child?.children ? undefined : child?.children.length > 0), // isFolder is hidden in original node, so we must read it to copy it
76 isTemp: true,
77 parent,
78 }
79 name ||= child ? getNodeName(child) : ''
80 inheritMasks(ret, parent, name)
83 - parentMaskApplier(parent)(ret, name)
81 + await parentMaskApplier(parent)(ret, name)
82 inheritFromParent(parent, ret)
83 return ret
84 }
@@ -94,7 +92,7 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
92 if (!name)
93 return parent
94 const rest = nextSlash < 0 ? '' : url.slice(nextSlash+1, url.endsWith('/') ? -1 : undefined)
97 - const ret = getNodeByName(name, parent)
95 + const ret = await getNodeByName(name, parent)
96 if (!ret)
97 return
98 if (ret?.original)
@@ -118,25 +116,28 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
116 return ret
117 }
118
121 -export function getNodeByName(name: string, parent: VfsNode) {
119 +export async function getNodeByName(name: string, parent: VfsNode) {
120 if (dirTraversal(name) || /[\\/]/.test(name)) return
123 - // does the tree node have a child that goes by this name?
124 - const child = parent.children?.find(isSameFilenameAs(name))
125 - if (!child && !parent.source) return // on tree or on disk, or it doesn't exist
126 - const ret = applyParentToChild(child, parent, name)
127 - if (child)
121 + // does the tree node have a child that goes by this name, otherwise attempt disk
122 + const child = parent.children?.find(isSameFilenameAs(name)) || childFromDisk()
123 + return child && applyParentToChild(child, parent, name)
124 +
125 + function childFromDisk() {
126 + if (!parent.source) return
127 + const ret: VfsNode = {}
128 + let onDisk = name
129 + if (parent.rename) { // reverse the mapping
130 + for (const [from, to] of Object.entries(parent.rename))
131 + if (name === to) {
132 + onDisk = from
133 + break // found, search no more
134 + }
135 + ret.rename = renameUnderPath(parent.rename, name)
136 + }
137 + ret.source = enforceFinal('/', parent.source) + onDisk
138 + ret.original = undefined // overwrite in applyParentToChild, so we know this is not part of the vfs
139 return ret
129 - let onDisk = name
130 - if (parent.rename) { // reverse the mapping
131 - for (const [from, to] of Object.entries(parent.rename))
132 - if (name === to) {
133 - onDisk = from
134 - break // found, search no more
135 - }
136 - ret.rename = renameUnderPath(parent.rename, name)
140 }
138 - ret.source = enforceFinal('/', parent.source!) + onDisk
139 - return ret
141 }
142
143 export let vfs: VfsNode = {}
@@ -308,14 +309,18 @@ export function masksCouldGivePermission(masks: Masks | undefined, perm: keyof V
309 export function parentMaskApplier(parent: VfsNode) {
310 const matchers = onlyTruthy(Object.entries(parent.masks || {}).map(([k, { maskOnly, ...mods }]) => {
311 k = k.startsWith('**/') ? k.slice(3) : !k.includes('/') ? k : '' // ** globstar matches also zero subfolders, so this mask must be applied here too
311 - return k && { mods, maskOnly, matcher: makeMatcher(k) }
312 + // k is stored into the object for debugging purposes
313 + return k && { k, mods, matcher: makeMatcher(k), mustBeFolder: maskOnly && (maskOnly === 'folders') }
314 }))
313 - return (item: VfsNode, virtualBasename=getNodeName(item)) => {
314 - for (const { matcher, mods, maskOnly } of matchers) {
315 - if (maskOnly === 'folders' && !item.isFolder || maskOnly === 'files' && item.isFolder) continue
315 + return async (item: VfsNode, virtualBasename=getNodeName(item)) => {
316 + let isFolder: boolean | undefined = undefined
317 + for (const { matcher, mods, mustBeFolder } of matchers) {
318 + if (mustBeFolder !== undefined) {
319 + isFolder ??= await nodeIsDirectory(item)
320 + if (mustBeFolder !== isFolder) continue
321 + }
322 if (!matcher(virtualBasename)) continue
317 - if (item.masks)
318 - item.masks = _.merge(_.cloneDeep(mods.masks), item.masks) // item.masks must take precedence
323 + item.masks &&= _.merge(_.cloneDeep(mods.masks), item.masks) // item.masks must take precedence
324 _.defaults(item, mods)
325 }
326 }
tests/config.yaml
+7
@@ -109,6 +109,13 @@ vfs:
109 children:
110 - name: hi
111 source: tests
112 + masks:
113 + "**/*":
114 + maskOnly: files
115 + can_list: false # ineffective because of masksOnly
116 + can_read: false
117 + "*/page":
118 + can_list: false
119 - name: cantSeeThisButChildrenMasks
120 can_see: false
121 masks:
tests/test.ts
+4
@@ -86,6 +86,10 @@ describe('basics', () => {
86 it('cantSeeThisButChildrenMasks', reqList('/', { outList:['cantSeeThisButChildrenMasks/'] }))
87 it('cantSeeThisButChildrenMasks.children', reqList('/cantSeeThisButChildrenMasks', { inList:['hi/'] }))
88
89 + it('masks.only', reqList('/cantSeeThisButChildren/hi', { inList:['page/'] }))
90 + it('masks.only.fromDisk', reqList('/cantSeeThisButChildren/hi/page', 403))
91 + it('masks.only.fromDisk.file', req('/cantSeeThisButChildren/hi/page/gpl.png', 403))
92 +
93 it('protectFromAbove', req('/protectFromAbove/child/alfa.txt', 403))
94 it('protectFromAbove.list', reqList('/protectFromAbove/child/', { inList:['alfa.txt'] }))
95