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',