fix: maskOnly not working for dynamically loaded folders

Massimo Melina committed Dec 31, 2023 at 12:38 UTC fa133b755ad8860f0b4698e071408e5bb9c0108d
6 files changed +60 -37
src/api.file_list.ts
+1 -1
@@ -31,7 +31,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
31 const walker = walkNode(node, ctx, search ? Infinity : 0)
32 const onDirEntryHandlers = mapPlugins(plug => plug.onDirEntry)
33 const can_upload = hasPermission(node, 'can_upload', ctx)
34 - const fakeChild = applyParentToChild({}, node) // can we delete children
34 + const fakeChild = await applyParentToChild(undefined, node) // can we delete children
35 const can_delete = hasPermission(fakeChild, 'can_delete', ctx)
36 const can_archive = hasPermission(node, 'can_archive', ctx)
37 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
+1 -1
@@ -95,7 +95,6 @@ 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 - await setUploadMeta(tempName, ctx)
98 if (dontOverwriteUploading.get() && fs.existsSync(dest)) {
99 const ext = extname(dest)
100 const base = dest.slice(0, -ext.length)
@@ -104,6 +103,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
103 while (fs.existsSync(dest))
104 }
105 return fs.rename(tempName, dest, err => {
106 + setUploadMeta(err ? tempName : dest, ctx)
107 if (err)
108 console.error("couldn't rename temp to", dest, String(err))
109 else if (ctx.query.comment)
src/vfs.ts
+45 -33
@@ -4,12 +4,12 @@ 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
7 + throw_, VfsPerms, Who, isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS, removeStarting
8 } from './misc'
9 import Koa from 'koa'
10 import _ from 'lodash'
11 import { defineConfig, setConfig } from './config'
12 -import { HTTP_FOOL, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, MIME_AUTO } from './const'
12 +import { HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, MIME_AUTO } from './const'
13 import events from './events'
14 import { expandUsername } from './perm'
15 import { getCurrentUsername } from './auth'
@@ -70,17 +70,17 @@ export function isSameFilenameAs(name: string) {
70 lc === (typeof other === 'string' ? other : getNodeName(other)).toLowerCase()
71 }
72
73 -export function applyParentToChild(child: VfsNode | undefined, parent: VfsNode, name?: string) {
73 +export async function applyParentToChild(child: VfsNode | undefined, parent: VfsNode, name?: string) {
74 const ret: VfsNode = {
75 - isFolder: child?.children?.length ? true : undefined, // allow child to overwrite this property
75 + original: child, // leave it possible for child to override this
76 ...child,
77 - original: child,
77 + isFolder: child?.isFolder ?? (!child?.children ? undefined : child?.children.length > 0), // isFolder is hidden in original node, so we must read it to copy it
78 isTemp: true,
79 parent,
80 }
81 name ||= child ? getNodeName(child) : ''
82 inheritMasks(ret, parent, name)
83 - parentMaskApplier(parent)(ret, name)
83 + await parentMaskApplier(parent)(ret, name)
84 inheritFromParent(parent, ret)
85 return ret
86 }
@@ -94,28 +94,11 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
94 if (!name)
95 return parent
96 const rest = nextSlash < 0 ? '' : url.slice(nextSlash+1, url.endsWith('/') ? -1 : undefined)
97 - if (dirTraversal(name) || /[\\/]/.test(name)) {
98 - if (ctx)
99 - ctx.status = HTTP_FOOL
97 + const ret = await getNodeByName(name, parent)
98 + if (!ret)
99 return
101 - }
102 - // does the tree node have a child that goes by this name?
103 - const child = parent.children?.find(isSameFilenameAs(name))
104 - if (!child && !parent.source) return // on tree or on disk, or it doesn't exist
105 -
106 - const ret = applyParentToChild(child, parent, name)
107 - if (child)
100 + if (ret?.original)
101 return urlToNode(rest, ctx, ret, getRest)
109 - let onDisk = name
110 - if (parent.rename) { // reverse the mapping
111 - for (const [from, to] of Object.entries(parent.rename))
112 - if (name === to) {
113 - onDisk = from
114 - break // found, search no more
115 - }
116 - ret.rename = renameUnderPath(parent.rename, name)
117 - }
118 - ret.source = enforceFinal('/', parent.source!) + onDisk
102 if (parent.default)
103 inheritFromParent({ mime: { '*': MIME_AUTO } }, ret)
104 if (rest)
@@ -128,12 +111,37 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
111 catch {
112 if (!getRest)
113 return
131 - getRest(onDisk)
114 + const rest = ret.source.slice(parent.source!.length) // parent has source, otherwise !ret.source || ret.original
115 + getRest(removeStarting('/', rest))
116 return parent
117 }
118 return ret
119 }
120
121 +export async function getNodeByName(name: string, parent: VfsNode) {
122 + if (dirTraversal(name) || /[\\/]/.test(name)) return
123 + // does the tree node have a child that goes by this name, otherwise attempt disk
124 + const child = parent.children?.find(isSameFilenameAs(name)) || childFromDisk()
125 + return child && applyParentToChild(child, parent, name)
126 +
127 + function childFromDisk() {
128 + if (!parent.source) return
129 + const ret: VfsNode = {}
130 + let onDisk = name
131 + if (parent.rename) { // reverse the mapping
132 + for (const [from, to] of Object.entries(parent.rename))
133 + if (name === to) {
134 + onDisk = from
135 + break // found, search no more
136 + }
137 + ret.rename = renameUnderPath(parent.rename, name)
138 + }
139 + ret.source = enforceFinal('/', parent.source) + onDisk
140 + ret.original = undefined // overwrite in applyParentToChild, so we know this is not part of the vfs
141 + return ret
142 + }
143 +}
144 +
145 export let vfs: VfsNode = {}
146 defineConfig<VfsNode>('vfs', {}).sub(data =>
147 vfs = (function recur(node) {
@@ -295,14 +303,18 @@ export function masksCouldGivePermission(masks: Masks | undefined, perm: keyof V
303 export function parentMaskApplier(parent: VfsNode) {
304 const matchers = onlyTruthy(Object.entries(parent.masks || {}).map(([k, { maskOnly, ...mods }]) => {
305 k = k.startsWith('**/') ? k.slice(3) : !k.includes('/') ? k : '' // ** globstar matches also zero subfolders, so this mask must be applied here too
298 - return k && { mods, maskOnly, matcher: makeMatcher(k) }
306 + // k is stored into the object for debugging purposes
307 + return k && { k, mods, matcher: makeMatcher(k), mustBeFolder: maskOnly && (maskOnly === 'folders') }
308 }))
300 - return (item: VfsNode, virtualBasename=getNodeName(item)) => {
301 - for (const { matcher, mods, maskOnly } of matchers) {
302 - if (maskOnly === 'folders' && !item.isFolder || maskOnly === 'files' && item.isFolder) continue
309 + return async (item: VfsNode, virtualBasename=getNodeName(item)) => {
310 + let isFolder: boolean | undefined = undefined
311 + for (const { matcher, mods, mustBeFolder } of matchers) {
312 + if (mustBeFolder !== undefined) {
313 + isFolder ??= await nodeIsDirectory(item)
314 + if (mustBeFolder !== isFolder) continue
315 + }
316 if (!matcher(virtualBasename)) continue
304 - if (item.masks)
305 - item.masks = _.merge(_.cloneDeep(mods.masks), item.masks) // item.masks must take precedence
317 + item.masks &&= _.merge(_.cloneDeep(mods.masks), item.masks) // item.masks must take precedence
318 _.defaults(item, mods)
319 }
320 }
tests/config.yaml
+7
@@ -108,6 +108,13 @@ vfs:
108 children:
109 - name: hi
110 source: tests
111 + masks:
112 + "**/*":
113 + maskOnly: files
114 + can_list: false
115 + can_read: false
116 + "*/page":
117 + can_list: false
118 - name: cantSeeThisButChildrenMasks
119 can_see: false
120 masks:
tests/test.ts
+4
@@ -80,6 +80,10 @@ describe('basics', () => {
80 it('cantSeeThisButChildrenMasks', reqList('/', { outList:['cantSeeThisButChildrenMasks/'] }))
81 it('cantSeeThisButChildrenMasks.children', reqList('/cantSeeThisButChildrenMasks', { inList:['hi/'] }))
82
83 + it('masks.only', reqList('/cantSeeThisButChildren/hi', { inList:['page/'] }))
84 + it('masks.only.fromDisk', reqList('/cantSeeThisButChildren/hi/page', 403))
85 + it('masks.only.fromDisk.file', req('/cantSeeThisButChildren/hi/page/gpl.png', 403))
86 +
87 it('protectFromAbove', req('/protectFromAbove/child/alfa.txt', 403))
88 it('protectFromAbove.list', reqList('/protectFromAbove/child/', { inList:['alfa.txt'] }))
89