fix: view page source permission error for guests

scarlett
NGPixel 2 weeks ago
parent 1c7d709b1a
commit ff9aa13e3d
No known key found for this signature in database

@ -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),

@ -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
}
)
}

@ -116,8 +116,13 @@
@click="viewPageHistory">
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
</w-btn>
<!--
`read:source` likewise, granted per path by a rule — so guests have it wherever a rule says so,
and the API answers the overlay behind this button on exactly that permission.
-->
<w-btn
class="h-12"
v-if="canViewSource"
flat
icon="la:code"
:color="editorStore.isActive ? `white` : `grey`"
@ -284,6 +289,15 @@ const isRedirect = computed(() => 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() {

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

Loading…
Cancel
Save