From ff9aa13e3d6d5cefbfed43c74bd6b88c39ec5da1 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 12 Sep 2026 22:01:58 -0400 Subject: [PATCH] fix: view page source permission error for guests --- backend/api/pages.ts | 33 ++++++++++++++++--- backend/models/pages.ts | 21 ++++++++++-- frontend/src/components/PageActionsCol.vue | 14 ++++++++ frontend/src/components/PageSourceOverlay.vue | 7 ++-- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/backend/api/pages.ts b/backend/api/pages.ts index ee47bea3f..065be678a 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -127,6 +127,23 @@ export function mayOnPage( return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page) } +/** + * Whether this requester may be handed a page's SOURCE. + * + * `read:source` is the permission that exists to say so, and a rule grants it to whoever it names — + * the guests group included, which is how a wiki opens "view source" to the public. Whoever may write + * the page is covered as well, since the editor loads the source in order to edit it: a rule granting + * `write:pages` without `read:source` would otherwise be a page that cannot be edited. + */ +const SOURCE_PERMISSIONS = ['read:source', 'write:pages', 'manage:pages'] + +export function mayReadSource( + req: FastifyRequest, + page: { path: string; locale?: string; tags?: string[] } +): boolean { + return SOURCE_PERMISSIONS.some((permission) => mayOnPage(req, permission, page)) +} + /** * Every page permission this requester holds at a path. * @@ -604,7 +621,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get a single page', description: - "Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages, and never their source. Per-page access rules are not implemented yet.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.", + "Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.", tags: ['Pages'], params: { type: 'object', @@ -626,7 +643,8 @@ async function routes(app: FastifyInstance) { withContent: { type: 'boolean', default: false, - description: 'Include the source, which only an editor needs.' + description: + 'Include the source. Withheld from a requester who may neither read the source nor write the page here.' }, locale: { type: 'string', @@ -646,8 +664,15 @@ async function routes(app: FastifyInstance) { siteId: req.params.siteId, ...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }), locale: req.query.locale, - // -> The source is what an editor loads, and editing is not something an anonymous reader does - withContent: Boolean(req.query.withContent) && Boolean(actor), + /* + -> The source is a page rule's to grant, not a session's to have: `mayReadSource` is the + whole of it, and the guests group holds it wherever a rule says so. Asked as a predicate + because the answer depends on the page, which a request addressing one by hash does not + have in hand yet. + */ + withContent: req.query.withContent + ? (target: { path: string; locale: string; tags: string[] }) => mayReadSource(req, target) + : false, publicOnly: !actor, // -> Answered once the page is known, since a hash does not say which page it is yet unlocked: (pageId) => unlockedFor(req, pageId), diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 63fd540e3..701468cd1 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -1152,7 +1152,12 @@ class Pages { id?: string hash?: string locale?: string - withContent?: boolean + /** + * Include the source. A predicate for a caller whose answer depends on the page — `read:source` + * is granted by a page rule, and a rule is chosen by path, locale and tags, none of which a + * request addressing a page by hash has in hand before the row is read. + */ + withContent?: boolean | ((page: { path: string; locale: string; tags: string[] }) => boolean) /** Restrict to what a reader with no session may see: published pages. */ publicOnly?: boolean unlocked?: boolean | ((pageId: string) => boolean) @@ -1193,6 +1198,14 @@ class Pages { return null } const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked + const includeContent = + typeof withContent === 'function' + ? withContent({ + path: row.page.path, + locale: row.page.locale, + tags: row.page.tags ?? [] + }) + : withContent return this.toPage( { ...row.page, @@ -1206,7 +1219,11 @@ class Pages { publicOnly }) }, - { withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked } + { + withContent: includeContent, + withPassword, + locked: Boolean(row.page.password) && !isUnlocked + } ) } diff --git a/frontend/src/components/PageActionsCol.vue b/frontend/src/components/PageActionsCol.vue index 71242b0f3..9201b58bb 100644 --- a/frontend/src/components/PageActionsCol.vue +++ b/frontend/src/components/PageActionsCol.vue @@ -116,8 +116,13 @@ @click="viewPageHistory"> Page History + pageStore.editor === 'redirect') */ const hasPageActions = computed(() => flagsStore.experimental || userStore.can('write:pages')) +/* + Whoever may write the page may read its source too — the editor is what opens it — so the button is + not hidden from an author whose rule grants the one and not the other. The API decides the same way. +*/ +const canViewSource = computed( + () => + userStore.can('read:source') || userStore.can('write:pages') || userStore.can('manage:pages') +) + // METHODS function togglePageProperties() { diff --git a/frontend/src/components/PageSourceOverlay.vue b/frontend/src/components/PageSourceOverlay.vue index aa188d9f3..4f197d350 100644 --- a/frontend/src/components/PageSourceOverlay.vue +++ b/frontend/src/components/PageSourceOverlay.vue @@ -123,8 +123,8 @@ async function load() { if (!pageData?.id) { throw new Error(t('pageSource.notFound')) } - // -> The source is withheld from a reader without a session, the field being left out entirely - // rather than blanked — an empty string is a page that genuinely has no content + // -> The source is withheld from a reader whose rules do not grant `read:source` here, the field + // being left out entirely rather than blanked — an empty string is a page with no content if (pageData.content === undefined) { state.notice = t('pageSource.unavailable') return @@ -134,8 +134,7 @@ async function load() { // contentType was stored state.contentType = pageData.contentType || pageData.editor || '' } catch (err) { - const message = - err.response?.status === 404 ? t('pageSource.notFound') : apiErrorMessage(err) + const message = err.response?.status === 404 ? t('pageSource.notFound') : apiErrorMessage(err) state.notice = message notify({ type: 'negative',