From aebd186faec7eeb9f65f6e06fb3b8ac3243e4cbd Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 9 Jul 2026 09:44:53 +0200 Subject: [PATCH] fix: page-scoped ACL grants also cover the page's dot asset folder Uploads into a page's `.pagename/` asset folder were getting 403'd even when the user had write access to `pagename`/`pagename/**`, because the dot-prefixed folder name never matched the plain-name grant. --- src/utils/auth.js | 24 ++++++++++++++++++++++-- test/utils/auth.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/utils/auth.js b/src/utils/auth.js index 2b18b984..bbc17a11 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -196,15 +196,35 @@ function getIdents(user) { return idents.map((ident) => ident?.toLowerCase()); } +// A page's assets live in a dot-folder next to it (e.g. `foo.html`'s images live under +// `.foo/`). A recursive grant on `foo` or `foo/**` must also cover `.foo/**` so uploading +// to a page's asset folder doesn't require a separate ACL entry. +function dotFolderVariant(prefix) { + const trimmed = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + const slashIdx = trimmed.lastIndexOf('/'); + const lastSegment = trimmed.slice(slashIdx + 1); + if (!lastSegment || lastSegment.startsWith('.')) return null; + return `${trimmed.slice(0, slashIdx + 1)}.${lastSegment}/`; +} + export function getUserActions(pathLookup, user, target) { const idents = getIdents(user); const plVals = idents.map((key) => pathLookup.get(key) || []); const actions = plVals.map((entries) => entries .find(({ path }) => { - if (path.endsWith('/+**')) return target.startsWith(path.slice(0, -3)) || target === path.slice(0, -4); + if (path.endsWith('/+**')) { + const prefix = path.slice(0, -3); + const dotPrefix = dotFolderVariant(prefix); + return target.startsWith(prefix) || target === path.slice(0, -4) + || (dotPrefix && target.startsWith(dotPrefix)); + } if (target.length < path.length) return false; - if (path.endsWith('/**')) return target.startsWith(path.slice(0, -2)); + if (path.endsWith('/**')) { + const prefix = path.slice(0, -2); + const dotPrefix = dotFolderVariant(prefix); + return target.startsWith(prefix) || (dotPrefix && target.startsWith(dotPrefix)); + } if (target.endsWith('.html')) return target.slice(0, -5) === path || target === path; return target === path; })) diff --git a/test/utils/auth.test.js b/test/utils/auth.test.js index f9de0234..0e8ad329 100644 --- a/test/utils/auth.test.js +++ b/test/utils/auth.test.js @@ -1064,6 +1064,35 @@ describe('DA auth', () => { ); }); + it('page-scoped grant also covers the page\'s dot asset folder', () => { + const patharr = [ + { + path: '/westernsydney/learning-futures/+**', + actions: ['write'], + }, + { + path: '/westernsydney/learning-futures.html', + actions: ['write'], + }, + ]; + const pathlookup = new Map(); + pathlookup.set('joe@acme.com', patharr); + + const user = { + email: 'joe@acme.com', + ident: 'AAAA@bbb.e', + }; + + assert.deepStrictEqual( + ['write'], + [...getUserActions(pathlookup, user, '/westernsydney/learning-futures.html').actions], + ); + assert.deepStrictEqual( + ['write'], + [...getUserActions(pathlookup, user, '/westernsydney/.learning-futures/Parramatta.jpg').actions], + ); + }); + it('test logout', async () => { const deleteCalled = []; const deleteFunc = async (id) => {