fix: view page source permission error for guests

scarlett
NGPixel 2 days 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) 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. * Every page permission this requester holds at a path.
* *
@ -604,7 +621,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Get a single page', summary: 'Get a single page',
description: 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'], tags: ['Pages'],
params: { params: {
type: 'object', type: 'object',
@ -626,7 +643,8 @@ async function routes(app: FastifyInstance) {
withContent: { withContent: {
type: 'boolean', type: 'boolean',
default: false, 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: { locale: {
type: 'string', type: 'string',
@ -646,8 +664,15 @@ async function routes(app: FastifyInstance) {
siteId: req.params.siteId, siteId: req.params.siteId,
...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }), ...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }),
locale: req.query.locale, 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, publicOnly: !actor,
// -> Answered once the page is known, since a hash does not say which page it is yet // -> Answered once the page is known, since a hash does not say which page it is yet
unlocked: (pageId) => unlockedFor(req, pageId), unlocked: (pageId) => unlockedFor(req, pageId),

@ -1152,7 +1152,12 @@ class Pages {
id?: string id?: string
hash?: string hash?: string
locale?: 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. */ /** Restrict to what a reader with no session may see: published pages. */
publicOnly?: boolean publicOnly?: boolean
unlocked?: boolean | ((pageId: string) => boolean) unlocked?: boolean | ((pageId: string) => boolean)
@ -1193,6 +1198,14 @@ class Pages {
return null return null
} }
const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked 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( return this.toPage(
{ {
...row.page, ...row.page,
@ -1206,7 +1219,11 @@ class Pages {
publicOnly publicOnly
}) })
}, },
{ withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked } {
withContent: includeContent,
withPassword,
locked: Boolean(row.page.password) && !isUnlocked
}
) )
} }

@ -116,8 +116,13 @@
@click="viewPageHistory"> @click="viewPageHistory">
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip> <w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
</w-btn> </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 <w-btn
class="h-12" class="h-12"
v-if="canViewSource"
flat flat
icon="la:code" icon="la:code"
:color="editorStore.isActive ? `white` : `grey`" :color="editorStore.isActive ? `white` : `grey`"
@ -284,6 +289,15 @@ const isRedirect = computed(() => pageStore.editor === 'redirect')
*/ */
const hasPageActions = computed(() => flagsStore.experimental || userStore.can('write:pages')) 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 // METHODS
function togglePageProperties() { function togglePageProperties() {

@ -123,8 +123,8 @@ async function load() {
if (!pageData?.id) { if (!pageData?.id) {
throw new Error(t('pageSource.notFound')) throw new Error(t('pageSource.notFound'))
} }
// -> The source is withheld from a reader without a session, the field being left out entirely // -> The source is withheld from a reader whose rules do not grant `read:source` here, the field
// rather than blanked an empty string is a page that genuinely has no content // being left out entirely rather than blanked an empty string is a page with no content
if (pageData.content === undefined) { if (pageData.content === undefined) {
state.notice = t('pageSource.unavailable') state.notice = t('pageSource.unavailable')
return return
@ -134,8 +134,7 @@ async function load() {
// contentType was stored // contentType was stored
state.contentType = pageData.contentType || pageData.editor || '' state.contentType = pageData.contentType || pageData.editor || ''
} catch (err) { } catch (err) {
const message = const message = err.response?.status === 404 ? t('pageSource.notFound') : apiErrorMessage(err)
err.response?.status === 404 ? t('pageSource.notFound') : apiErrorMessage(err)
state.notice = message state.notice = message
notify({ notify({
type: 'negative', type: 'negative',

Loading…
Cancel
Save