fix: revise and harden permissions system + various fixes

scarlett
NGPixel 1 month ago
parent 4ec69a75c0
commit db99e3d6ef
No known key found for this signature in database

@ -1,6 +1,6 @@
import { CustomError } from '../helpers/common.ts' import { CustomError } from '../helpers/common.ts'
import { actorFrom, mayBypassPassword, unlockedFor } from './pages.ts' import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor } from './pages.ts'
import type { ApprovalPageRef, ApprovalRulePatch } from '../models/approvals.ts' import type { ApprovalPageRef, ApprovalRulePatch, ReviewerScope } from '../models/approvals.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/** /**
@ -14,7 +14,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
*/ */
async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId: string) { async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId: string) {
const actor = actorFrom(req) const actor = actorFrom(req)
return WIKI.models.pages.getPage({ const page = await WIKI.models.pages.getPage({
siteId, siteId,
id: pageId, id: pageId,
withContent: true, withContent: true,
@ -22,16 +22,38 @@ async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId:
unlocked: (id: string) => unlockedFor(req, id), unlocked: (id: string) => unlockedFor(req, id),
withPassword: mayBypassPassword(req) withPassword: mayBypassPassword(req)
}) })
/*
Reading the page comes first, for suggesting an edit to it and for reviewing one alike: neither is
something to be done to a page the caller may not see, and answering as though it were not there
is how every other page-scoped route treats that.
*/
if (!page || !mayOnPage(req, 'read:pages', page)) {
return null
}
return page
} }
/** /**
* Who is reviewing, as the rules see them: the groups on their session, plus whether they hold * Who is reviewing, as the approval rules see them: the groups on their session, plus whether they
* `manage:system` which sees every queue, here as everywhere else. * review everything regardless of which groups a rule names.
*
* Two different kinds of rule meet here. An APPROVAL rule says which pages take suggestions and who
* reviews them; a group's PAGE rules say what a member may do to a page, `review:pages` among them.
* Holding that permission is the second way of being a reviewer, because reviewing is the entire
* content of it a group granted it and named in no approval rule could otherwise review nothing.
*
* Page permissions are per page, so `reviewsAll` is answered for a page when there is one. Without
* one the site-wide queue in the inbox it is answered at the site root, which is the only thing
* a queue spanning every page could ask about; the per-page check then still applies to each entry
* through the approval rules that produced it.
*/ */
function reviewerFor(req: FastifyRequest): { groupIds: string[]; isAdmin: boolean } { function reviewerFor(req: FastifyRequest, page?: { path: string; tags?: string[] }): ReviewerScope {
const actor = WIKI.models.groups.actorForRequest(req)
return { return {
groupIds: WIKI.models.approvals.getActorGroupIds(req), groupIds: WIKI.models.approvals.getActorGroupIds(req),
isAdmin: Boolean(req.session?.permissions?.includes('manage:system')) reviewsAll:
actor.permissions.includes('manage:system') ||
WIKI.models.groups.checkAccess(actor, 'review:pages', page ?? { path: '' })
} }
} }
@ -59,7 +81,15 @@ function validateRule({
if (!name || name.trim().length < 1) { if (!name || name.trim().length < 1) {
return new CustomError('approvalRuleEmptyName', 'A rule name is required.') return new CustomError('approvalRuleEmptyName', 'A rule name is required.')
} }
if (!path || path.trim().length < 1) { /*
Empty is only meaningful for `START`, where it is every path and therefore the whole site -- which
is how a rule covers a site without naming a folder.
Every other mode still needs something. An empty `EXACT` matches no page at all; an empty `END` or
`REGEX` matches every one of them, but by accident of the operator rather than by intent, and a
rule whose reach nobody meant to write is exactly what this refuses.
*/
if (match !== 'START' && (!path || path.trim().length < 1)) {
return new CustomError( return new CustomError(
'approvalRuleEmptyPath', 'approvalRuleEmptyPath',
match === 'TAG' || match === 'TAGALL' match === 'TAG' || match === 'TAGALL'
@ -548,6 +578,71 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* PENDING SUBMISSIONS FOR A PAGE
*/
app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/submissions',
{
/*
No route-level `permissions`: those are page permissions, granted by a group's rules rather
than group-wide. `canReview` below is the real answer, and an ineligible caller gets `false`
rather than a refusal the button simply does not appear.
*/
schema: {
summary: "Edit suggestions waiting on a page, for that page's reviewers",
description:
'What the review button on a page view is drawn from. `canReview` says whether this caller reviews this page at all — an enabled rule covers it and either names one of their groups or they hold `review:pages` or `manage:system` — and is what decides whether the button is shown; `submissions` is what is waiting, oldest first, and is empty for everybody else.\n\nA reviewer with an empty queue still gets `canReview: true`: the button belongs to the page, not to whatever happens to be pending on it.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
pageId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'pageId']
},
response: {
200: {
description: 'Whether the caller reviews this page, and what is waiting on it',
type: 'object',
properties: {
canReview: { type: 'boolean' },
submissions: {
type: 'array',
items: { $ref: 'PageEditSubmission#' }
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
const page = await loadSuggestablePage(req, req.params.siteId, req.params.pageId)
if (!page) {
return reply.notFound('This page does not exist.')
}
const scope = reviewerFor(req, { path: page.path, tags: page.tags ?? [] })
const canReview = await WIKI.models.approvals.canReviewPage(
req.params.siteId,
{ path: page.path, tags: page.tags ?? [] },
scope
)
if (!canReview) {
return { canReview: false, submissions: [] }
}
return {
canReview: true,
submissions: await WIKI.models.approvals.getReviewableSubmissions(req.params.siteId, {
...scope,
pageId: req.params.pageId
})
}
}
)
/** /**
* GET OWN SUGGESTION STATE FOR A PAGE * GET OWN SUGGESTION STATE FOR A PAGE
* *
@ -616,7 +711,12 @@ async function routes(app: FastifyInstance) {
const actor = actorFrom(req) const actor = actorFrom(req)
const groupIds = WIKI.models.approvals.getActorGroupIds(req) const groupIds = WIKI.models.approvals.getActorGroupIds(req)
const pageRef: ApprovalPageRef = { id: page.id, path: page.path, tags: page.tags ?? [] } const pageRef: ApprovalPageRef = {
id: page.id,
path: page.path,
tags: page.tags ?? [],
allowContributions: page.allowContributions
}
const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds) const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds)
if (!rule) { if (!rule) {
return { canSubmit: false, isGuest: !actor, submission: null } return { canSubmit: false, isGuest: !actor, submission: null }
@ -691,7 +791,12 @@ async function routes(app: FastifyInstance) {
const actor = actorFrom(req) const actor = actorFrom(req)
const groupIds = WIKI.models.approvals.getActorGroupIds(req) const groupIds = WIKI.models.approvals.getActorGroupIds(req)
const pageRef: ApprovalPageRef = { id: page.id, path: page.path, tags: page.tags ?? [] } const pageRef: ApprovalPageRef = {
id: page.id,
path: page.path,
tags: page.tags ?? [],
allowContributions: page.allowContributions
}
const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds) const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds)
if (!rule) { if (!rule) {
return reply.forbidden('This page does not accept edit suggestions from you.') return reply.forbidden('This page does not accept edit suggestions from you.')

@ -1,4 +1,6 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import { decodeTreePath } from '../helpers/common.ts'
/** Extensions a browser may render inline. Everything else is sent as a download. */ /** Extensions a browser may render inline. Everything else is sent as a download. */
const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg']) const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'])
@ -21,6 +23,24 @@ const assetIdParam = {
/** /**
* Assets API Routes * Assets API Routes
*/ */
/**
* Whether the caller holds an asset permission on an asset, judged on where it sits.
*
* Assets live in the same tree as pages and are addressed by the same rules a rule over a branch
* covers the files in it as well as the pages, which is why the asset permissions are offered
* alongside the page ones in the group editor.
*/
function mayOnAsset(
req: FastifyRequest,
permission: string,
asset: { folderPath?: string | null; fileName: string }
): boolean {
const folder = asset.folderPath ?? ''
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
path: folder ? `${folder}/${asset.fileName}` : asset.fileName
})
}
async function routes(app: FastifyInstance) { async function routes(app: FastifyInstance) {
// -> An upload is the raw file rather than a multipart form: one file per request, with the name and // -> An upload is the raw file rather than a multipart form: one file per request, with the name and
// the destination in the query string. The catch-all only claims content types nothing else // the destination in the query string. The catch-all only claims content types nothing else
@ -46,9 +66,10 @@ async function routes(app: FastifyInstance) {
}>( }>(
'/sites/:siteId/assets', '/sites/:siteId/assets',
{ {
config: { /*
permissions: ['write:assets', 'manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and asset permissions come
}, from a group's RULES, which address the folder the file is in. Checked below.
*/
schema: { schema: {
summary: 'Upload an asset', summary: 'Upload an asset',
description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.`, description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.`,
@ -113,6 +134,16 @@ async function routes(app: FastifyInstance) {
return reply.badRequest('No file was sent.') return reply.badRequest('No file was sent.')
} }
const folder = req.query.folderId
? await WIKI.models.tree.getFolderById(req.query.folderId)
: null
const folderPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : ''
const destination = folder ? [folderPath, folder.fileName].filter(Boolean).join('/') : ''
if (
!mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName })
) {
return reply.forbidden('You are not allowed to upload a file here.')
}
const asset = await WIKI.models.assets.upload({ const asset = await WIKI.models.assets.upload({
siteId: req.params.siteId, siteId: req.params.siteId,
locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en', locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en',
@ -137,9 +168,10 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; assetId: string } }>( app.get<{ Params: { siteId: string; assetId: string } }>(
'/sites/:siteId/assets/:assetId', '/sites/:siteId/assets/:assetId',
{ {
config: { /*
permissions: ['read:assets', 'manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and asset permissions come
}, from a group's RULES, which address the folder the file is in. Checked below.
*/
schema: { schema: {
summary: 'Get a single asset', summary: 'Get a single asset',
description: 'Metadata only. `/content` serves the file itself.', description: 'Metadata only. `/content` serves the file itself.',
@ -152,7 +184,8 @@ async function routes(app: FastifyInstance) {
}, },
async (req, reply) => { async (req, reply) => {
const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
if (!asset) { // -> Not readable is answered as not there, so the endpoint cannot be used to probe for files
if (!asset || !mayOnAsset(req, 'read:assets', asset)) {
return reply.notFound('This asset does not exist.') return reply.notFound('This asset does not exist.')
} }
return asset return asset
@ -165,9 +198,10 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; assetId: string } }>( app.get<{ Params: { siteId: string; assetId: string } }>(
'/sites/:siteId/assets/:assetId/content', '/sites/:siteId/assets/:assetId/content',
{ {
config: { /*
permissions: ['read:assets', 'manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and asset permissions come
}, from a group's RULES, which address the folder the file is in. Checked below.
*/
schema: { schema: {
summary: 'Download an asset', summary: 'Download an asset',
description: description:
@ -191,7 +225,7 @@ async function routes(app: FastifyInstance) {
}, },
async (req, reply) => { async (req, reply) => {
const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
if (!asset) { if (!asset || !mayOnAsset(req, 'read:assets', asset)) {
return reply.notFound('This asset does not exist.') return reply.notFound('This asset does not exist.')
} }
const content = await WIKI.models.assets.getContent(req.params.assetId) const content = await WIKI.models.assets.getContent(req.params.assetId)
@ -218,9 +252,10 @@ async function routes(app: FastifyInstance) {
app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>( app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>(
'/sites/:siteId/assets/:assetId', '/sites/:siteId/assets/:assetId',
{ {
config: { /*
permissions: ['manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and asset permissions come
}, from a group's RULES, which address the folder the file is in. Checked below.
*/
schema: { schema: {
summary: 'Rename an asset', summary: 'Rename an asset',
description: description:
@ -257,6 +292,13 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req, reply) => { async (req, reply) => {
const existing = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
if (!existing) {
return reply.notFound('This asset does not exist.')
}
if (!mayOnAsset(req, 'manage:assets', existing)) {
return reply.forbidden('You are not allowed to rename this file.')
}
const asset = await WIKI.models.assets.renameAsset( const asset = await WIKI.models.assets.renameAsset(
req.params.siteId, req.params.siteId,
req.params.assetId, req.params.assetId,
@ -279,9 +321,10 @@ async function routes(app: FastifyInstance) {
app.delete<{ Params: { siteId: string; assetId: string } }>( app.delete<{ Params: { siteId: string; assetId: string } }>(
'/sites/:siteId/assets/:assetId', '/sites/:siteId/assets/:assetId',
{ {
config: { /*
permissions: ['manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and asset permissions come
}, from a group's RULES, which address the folder the file is in. Checked below.
*/
schema: { schema: {
summary: 'Delete an asset', summary: 'Delete an asset',
tags: ['Assets'], tags: ['Assets'],
@ -294,6 +337,13 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req, reply) => { async (req, reply) => {
const doomed = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
if (!doomed) {
return reply.notFound('This asset does not exist.')
}
if (!mayOnAsset(req, 'manage:assets', doomed)) {
return reply.forbidden('You are not allowed to delete this file.')
}
if (!(await WIKI.models.assets.deleteAsset(req.params.siteId, req.params.assetId))) { if (!(await WIKI.models.assets.deleteAsset(req.params.siteId, req.params.assetId))) {
return reply.notFound('This asset does not exist.') return reply.notFound('This asset does not exist.')
} }

@ -10,6 +10,9 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string }; Querystring: { visibleOnly?: boolean } }>( app.get<{ Params: { siteId: string }; Querystring: { visibleOnly?: boolean } }>(
'/sites/:siteId/auth/strategies', '/sites/:siteId/auth/strategies',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'List all site authentication strategies', summary: 'List all site authentication strategies',
description: description:
@ -135,6 +138,9 @@ async function routes(app: FastifyInstance) {
}>( }>(
'/sites/:siteId/auth/login', '/sites/:siteId/auth/login',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Login', summary: 'Login',
tags: ['Authentication'], tags: ['Authentication'],
@ -214,6 +220,9 @@ async function routes(app: FastifyInstance) {
}>( }>(
'/sites/:siteId/auth/changePassword', '/sites/:siteId/auth/changePassword',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Change Password From Login', summary: 'Change Password From Login',
tags: ['Authentication'], tags: ['Authentication'],
@ -305,6 +314,9 @@ async function routes(app: FastifyInstance) {
}>( }>(
'/sites/:siteId/auth/tfa', '/sites/:siteId/auth/tfa',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Submit a 2FA Security Code From Login', summary: 'Submit a 2FA Security Code From Login',
description: description:
@ -390,6 +402,9 @@ async function routes(app: FastifyInstance) {
app.post<{ Params: { siteId: string } }>( app.post<{ Params: { siteId: string } }>(
'/sites/:siteId/auth/passkey/challenge', '/sites/:siteId/auth/passkey/challenge',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Get the options for logging in with a passkey', summary: 'Get the options for logging in with a passkey',
description: description:
@ -449,6 +464,9 @@ async function routes(app: FastifyInstance) {
app.put<{ Params: { siteId: string }; Body: { authResponse: Record<string, any> } }>( app.put<{ Params: { siteId: string }; Body: { authResponse: Record<string, any> } }>(
'/sites/:siteId/auth/passkey/login', '/sites/:siteId/auth/passkey/login',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Login With a Passkey', summary: 'Login With a Passkey',
description: description:
@ -515,6 +533,9 @@ async function routes(app: FastifyInstance) {
app.post<{ Params: { siteId: string } }>( app.post<{ Params: { siteId: string } }>(
'/sites/:siteId/auth/logout', '/sites/:siteId/auth/logout',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Logout', summary: 'Logout',
description: description:

@ -7,6 +7,9 @@ async function routes(app: FastifyInstance) {
app.get( app.get(
'/', '/',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'List all locales', summary: 'List all locales',
tags: ['Locales'] tags: ['Locales']
@ -20,6 +23,9 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { code: string } }>( app.get<{ Params: { code: string } }>(
'/:code/strings', '/:code/strings',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Get locale strings', summary: 'Get locale strings',
tags: ['Locales'] tags: ['Locales']

@ -69,15 +69,25 @@ export function actorFrom(req: FastifyRequest): PageActor | null {
const PASSWORD_BYPASS = ['write:pages', 'manage:pages', 'manage:system'] const PASSWORD_BYPASS = ['write:pages', 'manage:pages', 'manage:system']
/** /**
* Every page permission a group can be granted, i.e. the whole set `manage:system` amounts to. Mirrors * Every page permission a rule can grant, i.e. the whole set `manage:system` amounts to. Mirrors the
* the page rules offered in the group editor. * page rules offered in the group editor, and is what the interface asks about per path.
*/ */
const PAGE_PERMISSIONS = [ const PAGE_PERMISSIONS = [
'read:pages', 'read:pages',
'write:pages', 'write:pages',
'review:pages', 'review:pages',
'manage:pages', 'manage:pages',
'delete:pages' 'delete:pages',
'write:styles',
'write:scripts',
'read:source',
'read:history',
'read:assets',
'write:assets',
'manage:assets',
'read:comments',
'write:comments',
'manage:comments'
] ]
export function mayBypassPassword(req: FastifyRequest): boolean { export function mayBypassPassword(req: FastifyRequest): boolean {
@ -95,6 +105,21 @@ export function unlockedFor(req: FastifyRequest, pageId: string): boolean {
return mayBypassPassword(req) || Boolean(req.session?.unlockedPages?.includes(pageId)) return mayBypassPassword(req) || Boolean(req.session?.unlockedPages?.includes(pageId))
} }
/**
* Whether this requester holds a page permission ON THIS PAGE.
*
* Page permissions are granted by a group's rules, not by the group-wide permission list, so this is
* a different question from the one the route-level `config.permissions` hook answers and the only
* correct one for anything page-scoped. `helpers/pageRules.ts` sets out how a rule is chosen.
*/
export function mayOnPage(
req: FastifyRequest,
permission: string,
page: { path: string; locale?: string; tags?: string[] }
): boolean {
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page)
}
/** /**
* A page, as this requester is allowed to see it or null when they are not allowed to see it at all. * A page, as this requester is allowed to see it or null when they are not allowed to see it at all.
* *
@ -104,12 +129,17 @@ export function unlockedFor(req: FastifyRequest, pageId: string): boolean {
*/ */
async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: string) { async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: string) {
const actor = actorFrom(req) const actor = actorFrom(req)
return WIKI.models.pages.getPage({ const page = await WIKI.models.pages.getPage({
siteId, siteId,
id: pageId, id: pageId,
publicOnly: !actor, publicOnly: !actor,
unlocked: (id: string) => unlockedFor(req, id) unlocked: (id: string) => unlockedFor(req, id)
}) })
// -> Not readable is indistinguishable from not there, for anything hanging off the page
if (!page || !mayOnPage(req, 'read:pages', page)) {
return null
}
return page
} }
/** /**
@ -122,13 +152,14 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string } }>( app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/pages', '/sites/:siteId/pages',
{ {
config: { /*
permissions: ['read:pages', 'manage:pages'] No route-level `permissions`: page permissions come from a group's RULES, and this would have
}, to filter per page against them. It has nothing to filter yet see the description.
*/
schema: { schema: {
summary: 'List all pages', summary: 'List all pages',
description: description:
'Not implemented yet — always answers with an empty list. Browse the tree instead, which is what the file manager and the navigation use.', 'Not implemented yet — always answers with an empty list. Browse the tree instead, which is what the file manager and the navigation use, and which filters what it lists by the page rules.',
tags: ['Pages'], tags: ['Pages'],
params: siteIdParam, params: siteIdParam,
response: { response: {
@ -276,6 +307,8 @@ async function routes(app: FastifyInstance) {
offset: req.query.offset, offset: req.query.offset,
limit: req.query.limit, limit: req.query.limit,
publicOnly: !actor, publicOnly: !actor,
// -> So that a page the caller could not open never shows up as a result
actor: WIKI.models.groups.actorForRequest(req),
// -> An unpublished page is only of interest to someone who could have written it // -> An unpublished page is only of interest to someone who could have written it
includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) => includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) =>
permissions.includes(p) permissions.includes(p)
@ -336,6 +369,9 @@ async function routes(app: FastifyInstance) {
if (!page) { if (!page) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
if (!mayOnPage(req, 'read:pages', page)) {
return reply.forbidden('You are not allowed to read this page.')
}
return { return {
path: page.path, path: page.path,
locale: page.locale, locale: page.locale,
@ -410,6 +446,9 @@ async function routes(app: FastifyInstance) {
if (!page) { if (!page) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
if (!mayOnPage(req, 'read:pages', page)) {
return reply.forbidden('You are not allowed to read this page.')
}
return page return page
} }
) )
@ -500,9 +539,11 @@ async function routes(app: FastifyInstance) {
app.post<{ Params: { siteId: string }; Body: PageInput }>( app.post<{ Params: { siteId: string }; Body: PageInput }>(
'/sites/:siteId/pages', '/sites/:siteId/pages',
{ {
config: { /*
permissions: ['write:pages', 'manage:pages'] No route-level `permissions`: that hook reads the group-wide list, and page permissions are
}, granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/
schema: { schema: {
summary: 'Create a page', summary: 'Create a page',
description: description:
@ -533,6 +574,10 @@ async function routes(app: FastifyInstance) {
if (!actor) { if (!actor) {
return reply.unauthorized('Saving a page requires a logged in user.') return reply.unauthorized('Saving a page requires a logged in user.')
} }
// -> Against where the page is going: there is no page to ask about yet
if (!mayOnPage(req, 'write:pages', { path: req.body.path, locale: req.body.locale })) {
return reply.forbidden('You are not allowed to create a page here.')
}
const page = await WIKI.models.pages.createPage(req.params.siteId, req.body, actor) const page = await WIKI.models.pages.createPage(req.params.siteId, req.body, actor)
return { return {
ok: true, ok: true,
@ -548,9 +593,11 @@ async function routes(app: FastifyInstance) {
app.patch<{ Params: { siteId: string; pageId: string }; Body: Partial<PageInput> }>( app.patch<{ Params: { siteId: string; pageId: string }; Body: Partial<PageInput> }>(
'/sites/:siteId/pages/:pageId', '/sites/:siteId/pages/:pageId',
{ {
config: { /*
permissions: ['write:pages', 'manage:pages'] No route-level `permissions`: that hook reads the group-wide list, and page permissions are
}, granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/
schema: { schema: {
summary: 'Update a page', summary: 'Update a page',
description: description:
@ -576,6 +623,16 @@ async function routes(app: FastifyInstance) {
if (!actor) { if (!actor) {
return reply.unauthorized('Saving a page requires a logged in user.') return reply.unauthorized('Saving a page requires a logged in user.')
} }
const target = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId
})
if (!target) {
return reply.notFound('This page does not exist.')
}
if (!mayOnPage(req, 'write:pages', target)) {
return reply.forbidden('You are not allowed to edit this page.')
}
const page = await WIKI.models.pages.updatePage( const page = await WIKI.models.pages.updatePage(
req.params.siteId, req.params.siteId,
req.params.pageId, req.params.pageId,
@ -602,9 +659,11 @@ async function routes(app: FastifyInstance) {
}>( }>(
'/sites/:siteId/pages/:pageId/path', '/sites/:siteId/pages/:pageId/path',
{ {
config: { /*
permissions: ['manage:pages'] No route-level `permissions`: that hook reads the group-wide list, and page permissions are
}, granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/
schema: { schema: {
summary: 'Move a page to another path', summary: 'Move a page to another path',
description: description:
@ -645,6 +704,16 @@ async function routes(app: FastifyInstance) {
if (!actor) { if (!actor) {
return reply.unauthorized('Moving a page requires a logged in user.') return reply.unauthorized('Moving a page requires a logged in user.')
} }
const target = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId
})
if (!target) {
return reply.notFound('This page does not exist.')
}
if (!mayOnPage(req, 'manage:pages', target)) {
return reply.forbidden('You are not allowed to move this page.')
}
const page = await WIKI.models.pages.movePage( const page = await WIKI.models.pages.movePage(
req.params.siteId, req.params.siteId,
req.params.pageId, req.params.pageId,
@ -668,9 +737,11 @@ async function routes(app: FastifyInstance) {
app.post<{ Params: { siteId: string; pageId: string } }>( app.post<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/render', '/sites/:siteId/pages/:pageId/render',
{ {
config: { /*
permissions: ['write:pages', 'manage:pages'] No route-level `permissions`: that hook reads the group-wide list, and page permissions are
}, granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/
schema: { schema: {
summary: 'Render a page again from its source', summary: 'Render a page again from its source',
description: description:
@ -695,6 +766,17 @@ async function routes(app: FastifyInstance) {
if (!actor) { if (!actor) {
return reply.unauthorized('Rendering a page requires a logged in user.') return reply.unauthorized('Rendering a page requires a logged in user.')
} }
const target = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId
})
if (!target) {
return reply.notFound('This page does not exist.')
}
// -> Rewrites what the page shows, so it is an edit and takes the same permission as one
if (!mayOnPage(req, 'write:pages', target)) {
return reply.forbidden('You are not allowed to edit this page.')
}
const page = await WIKI.models.pages.rerenderPage(req.params.siteId, req.params.pageId, actor) const page = await WIKI.models.pages.rerenderPage(req.params.siteId, req.params.pageId, actor)
if (!page) { if (!page) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
@ -713,9 +795,11 @@ async function routes(app: FastifyInstance) {
app.delete<{ Params: { siteId: string; pageId: string } }>( app.delete<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId', '/sites/:siteId/pages/:pageId',
{ {
config: { /*
permissions: ['delete:pages', 'manage:pages'] No route-level `permissions`: that hook reads the group-wide list, and page permissions are
}, granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/
schema: { schema: {
summary: 'Delete a page', summary: 'Delete a page',
tags: ['Pages'], tags: ['Pages'],
@ -732,6 +816,16 @@ async function routes(app: FastifyInstance) {
if (!actor) { if (!actor) {
return reply.unauthorized('Deleting a page requires a logged in user.') return reply.unauthorized('Deleting a page requires a logged in user.')
} }
const target = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId
})
if (!target) {
return reply.notFound('This page does not exist.')
}
if (!mayOnPage(req, 'delete:pages', target)) {
return reply.forbidden('You are not allowed to delete this page.')
}
if (!(await WIKI.models.pages.deletePage(req.params.siteId, req.params.pageId, actor))) { if (!(await WIKI.models.pages.deletePage(req.params.siteId, req.params.pageId, actor))) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
@ -745,10 +839,14 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; pageId: string } }>( app.get<{ Params: { siteId: string; pageId: string } }>(
'/sites/:siteId/pages/:pageId/history', '/sites/:siteId/pages/:pageId/history',
{ {
/*
No route-level `permissions`: that hook reads the group-wide list, and `read:history` is a
page permission granted by a rule. Checked against this page below instead.
*/
schema: { schema: {
summary: "Get a page's version history", summary: "Get a page's version history",
description: description:
'Every recorded version of the page, newest first — the first entry is the page as it stands now.\n\nGated on being able to read the page, no more: history is part of a page, so whoever may read the page may read what it used to say. That means an anonymous reader sees the history of a published page and nothing of a draft, and that a password-protected page answers only once the session has satisfied `POST …/unlock`.', 'Every recorded version of the page, newest first — the first entry is the page as it stands now.\n\nNeeds `read:history` ON THIS PAGE, granted by a group rule — the permission that says who may see what a page used to contain. Reading the page itself is required on top, so a page the caller could not open answers 404 and a password-protected one answers only once the session has satisfied `POST …/unlock`.',
tags: ['Pages'], tags: ['Pages'],
params: pageIdParam, params: pageIdParam,
response: { response: {
@ -765,6 +863,9 @@ async function routes(app: FastifyInstance) {
if (!page) { if (!page) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
if (!mayOnPage(req, 'read:history', page)) {
return reply.forbidden("You are not allowed to read this page's history.")
}
if (page.isLocked) { if (page.isLocked) {
return reply.forbidden('This page is password protected.') return reply.forbidden('This page is password protected.')
} }
@ -778,10 +879,11 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; pageId: string; versionId: string } }>( app.get<{ Params: { siteId: string; pageId: string; versionId: string } }>(
'/sites/:siteId/pages/:pageId/history/:versionId', '/sites/:siteId/pages/:pageId/history/:versionId',
{ {
// -> Checked per page below, for the same reason as the history list above
schema: { schema: {
summary: 'Get a single version of a page', summary: 'Get a single version of a page',
description: description:
'One version in full, source included — one side of a comparison. Readable by whoever may read the page, on the same terms as the history list.', 'One version in full, source included — one side of a comparison. Needs `read:history` and the ability to read the page, on the same terms as the history list.',
tags: ['Pages'], tags: ['Pages'],
params: { params: {
type: 'object', type: 'object',
@ -811,6 +913,9 @@ async function routes(app: FastifyInstance) {
if (!page) { if (!page) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
if (!mayOnPage(req, 'read:history', page)) {
return reply.forbidden("You are not allowed to read this page's history.")
}
if (page.isLocked) { if (page.isLocked) {
return reply.forbidden('This page is password protected.') return reply.forbidden('This page is password protected.')
} }
@ -832,9 +937,11 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; alias: string } }>( app.get<{ Params: { siteId: string; alias: string } }>(
'/sites/:siteId/pages/alias/:alias', '/sites/:siteId/pages/alias/:alias',
{ {
config: { /*
permissions: ['read:pages', 'manage:pages'] No route-level `permissions`: that hook reads the group-wide list, and page permissions are
}, granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/
schema: { schema: {
summary: 'Resolve a page alias to its path', summary: 'Resolve a page alias to its path',
tags: ['Pages'], tags: ['Pages'],
@ -870,6 +977,11 @@ async function routes(app: FastifyInstance) {
if (!target) { if (!target) {
return reply.notFound('No page uses this alias.') return reply.notFound('No page uses this alias.')
} }
// -> Resolving an alias tells the caller a page exists and where it is, which is only theirs
// to know if they may read it
if (!mayOnPage(req, 'read:pages', { path: target.path })) {
return reply.notFound('No page uses this alias.')
}
return target return target
} }
) )
@ -883,7 +995,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Get page user permissions', summary: 'Get page user permissions',
description: description:
"The current user's page permissions, which are not yet scoped per path — every page in the site answers the same.", "Which page permissions the caller holds AT THIS PATH, as their groups' rules decide. This is what the interface hides its controls by, so it answers the same question the endpoints themselves do rather than a broader one.\n\nAn administrator holds all of them. Everybody else gets whatever their rules grant, which for a path nobody wrote a rule for is nothing at all.",
tags: ['Pages'], tags: ['Pages'],
params: siteIdParam, params: siteIdParam,
body: { body: {
@ -912,21 +1024,26 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req) => { async (req) => {
const actor = actorFrom(req)
if (!actor) {
return []
}
/* /*
An administrator holds all of them, and holds them here too. Filtering their permissions by Anonymous included: the guests group has rules of its own, and what the public may do is
name the way the line below does would answer `manage:system` nothing ending in `:pages` exactly what they say. Answering an empty list for a reader without a session would hide
that an administrator has no rights over any page, which is the opposite of true. controls a wiki had deliberately opened to everyone.
*/ */
if (actor.permissions.includes('manage:system')) { const accessActor = WIKI.models.groups.actorForRequest(req)
/*
An administrator holds all of them, and holds them here too. Deriving the list from their
permissions instead would answer `manage:system` nothing ending in `:pages` that an
administrator has no rights over any page, which is the opposite of true.
*/
if (accessActor.permissions.includes('manage:system')) {
return PAGE_PERMISSIONS return PAGE_PERMISSIONS
} }
// FIXME: per-path permission rules are not implemented — a group's page permissions apply to // -> Resolved per permission against this path, since each one may be decided by a different
// the whole site, so this returns what the user holds anywhere rather than here. // rule — a branch can be readable but not writable, and one page within it neither
return actor.permissions.filter((p) => p.endsWith(':pages')) const page = { path: req.body.path.replace(/^\/+/, '') }
return PAGE_PERMISSIONS.filter((permission) =>
WIKI.models.groups.checkAccess(accessActor, permission, page)
)
} }
) )
} }

@ -78,9 +78,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
comments: { comments: {
type: 'boolean' type: 'boolean'
}, },
contributions: {
type: 'boolean'
},
profile: { profile: {
type: 'boolean' type: 'boolean'
}, },

@ -64,6 +64,9 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteIdorHostname: string }; Querystring: { strict?: boolean } }>( app.get<{ Params: { siteIdorHostname: string }; Querystring: { strict?: boolean } }>(
'/:siteIdorHostname', '/:siteIdorHostname',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'Get site info', summary: 'Get site info',
tags: ['Sites'], tags: ['Sites'],

@ -150,6 +150,9 @@ async function routes(app: FastifyInstance) {
app.get( app.get(
'/flags', '/flags',
{ {
config: {
publicAccess: true
},
schema: { schema: {
summary: 'System Flags', summary: 'System Flags',
description: description:

@ -13,13 +13,14 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string }; Querystring: { limit?: number } }>( app.get<{ Params: { siteId: string }; Querystring: { limit?: number } }>(
'/sites/:siteId/tags', '/sites/:siteId/tags',
{ {
config: { /*
permissions: ['read:pages', 'write:pages', 'manage:pages'] No route-level `permissions`: a tag exists because a readable page carries it, so the answer
}, is filtered per page below rather than refused outright.
*/
schema: { schema: {
summary: 'List the tags in use on a site', summary: 'List the tags in use on a site',
description: description:
'Every tag carried by at least one page, most used first. This is what the tag field offers as suggestions while a page is being edited.', 'Every tag carried by at least one page the caller may read, most used first, counted over those pages only. This is what the tag field offers as suggestions while a page is being edited, and what the search screen filters by.',
tags: ['Pages'], tags: ['Pages'],
params: { params: {
type: 'object', type: 'object',
@ -63,7 +64,10 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req) => { async (req) => {
return WIKI.models.tags.getTags(req.params.siteId, { limit: req.query.limit }) return WIKI.models.tags.getTags(req.params.siteId, {
limit: req.query.limit,
actor: WIKI.models.groups.actorForRequest(req)
})
} }
) )
} }

@ -1,4 +1,4 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy } from '../models/tree.ts' import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy } from '../models/tree.ts'
import { decodeTreePath } from '../helpers/common.ts' import { decodeTreePath } from '../helpers/common.ts'
@ -77,6 +77,52 @@ const folderIdParam = {
* pages and assets. Folders are the only kind created here a page or an asset gets its tree entry * pages and assets. Folders are the only kind created here a page or an asset gets its tree entry
* from whatever created it. * from whatever created it.
*/ */
/**
* The entries of a tree listing this caller may see, and the folders leading to them.
*
* Filtered here rather than in the query for the same reason as everywhere else: a page rule can be a
* regular expression or a set of tags, so which rule decides an entry is only knowable per entry.
*
* A folder is judged on its own path, so a DENY over a branch hides the branch itself rather than
* leaving an empty folder to walk into. The consequence worth knowing is the other way round: a
* folder stays listed when the rules deny everything inside it but say nothing about the folder, and
* a reader opening it finds it empty. Hiding those would mean resolving every descendant of every
* folder on every listing, which is not worth what it costs.
*/
function visibleTreeItems<T extends { type?: string; folderPath?: string; fileName?: string }>(
req: FastifyRequest,
items: T[]
): T[] {
const actor = WIKI.models.groups.actorForRequest(req)
return items.filter((item) => {
const path = item.folderPath ? `${item.folderPath}/${item.fileName}` : (item.fileName ?? '')
const permission = item.type === 'asset' ? 'read:assets' : 'read:pages'
return WIKI.models.groups.checkAccess(actor, permission, {
path,
tags: (item as any).tags ?? []
})
})
}
/** A folder's own slash-separated path, which is what a rule over that branch addresses. */
function folderPathOf(folder: { folderPath?: string | null; fileName: string }): string {
const parent = decodeTreePath(folder.folderPath ?? '') ?? ''
return parent ? `${parent}/${folder.fileName}` : folder.fileName
}
/**
* Whether the caller holds a page permission over a folder, judged on the folder's own path.
*
* A folder is not a page and has no permissions of its own, so what governs it is what governs the
* branch it opens: a rule denying `read:pages` under `geography` hides the folder as well as the
* pages in it, and only somebody who may reorganise pages there may rename or remove it.
*/
function mayOnFolder(req: FastifyRequest, permission: string, path: string): boolean {
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
path
})
}
async function routes(app: FastifyInstance) { async function routes(app: FastifyInstance) {
/** /**
* BROWSE THE TREE * BROWSE THE TREE
@ -84,9 +130,11 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string }; Querystring: TreeQuery }>( app.get<{ Params: { siteId: string }; Querystring: TreeQuery }>(
'/sites/:siteId/tree', '/sites/:siteId/tree',
{ {
config: { /*
permissions: ['read:pages', 'read:assets', 'manage:pages', 'manage:assets'] No route-level `permissions`: page permissions come from a group's RULES, and every entry is
}, filtered against them below a caller allowed nowhere gets an empty listing rather than a
refusal, which is the same thing the tree would look like if the pages were not there.
*/
schema: { schema: {
summary: 'Browse the tree', summary: 'Browse the tree',
description: description:
@ -168,7 +216,7 @@ async function routes(app: FastifyInstance) {
}, },
async (req) => { async (req) => {
const q = req.query const q = req.query
return WIKI.models.tree.getTree({ const items = await WIKI.models.tree.getTree({
siteId: req.params.siteId, siteId: req.params.siteId,
parentId: q.parentId, parentId: q.parentId,
parentPath: q.parentPath, parentPath: q.parentPath,
@ -183,6 +231,7 @@ async function routes(app: FastifyInstance) {
includeAncestors: q.includeAncestors, includeAncestors: q.includeAncestors,
includeRootFolders: q.includeRootFolders includeRootFolders: q.includeRootFolders
}) })
return visibleTreeItems(req, items)
} }
) )
@ -258,7 +307,18 @@ async function routes(app: FastifyInstance) {
if (!level) { if (!level) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
return level /*
A browse row carries a whole path rather than a folder/name pair, and stands for a page, a
folder, or both at once. Judged on that path either way: for the page it IS the page, and for
a folder it is the branch, which is what a rule over the branch is talking about.
*/
const actor = WIKI.models.groups.actorForRequest(req)
return {
...level,
items: level.items.filter((item) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', { path: item.path })
)
}
} }
) )
@ -340,7 +400,7 @@ async function routes(app: FastifyInstance) {
if (!WIKI.sites[req.params.siteId]) { if (!WIKI.sites[req.params.siteId]) {
return reply.notFound('This site does not exist.') return reply.notFound('This site does not exist.')
} }
return WIKI.models.tree.listPages({ const pages = await WIKI.models.tree.listPages({
siteId: req.params.siteId, siteId: req.params.siteId,
path: req.query.path, path: req.query.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId), locale: req.query.locale ?? defaultLocale(req.params.siteId),
@ -351,6 +411,15 @@ async function routes(app: FastifyInstance) {
depth: req.query.depth, depth: req.query.depth,
publicOnly: !req.session?.authenticated publicOnly: !req.session?.authenticated
}) })
// -> An index block is drawn inside a page, but it lists other pages: each one still has to be
// the reader's to see
const actor = WIKI.models.groups.actorForRequest(req)
return pages.filter((page) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', {
path: page.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId)
})
)
} }
) )
@ -360,9 +429,7 @@ async function routes(app: FastifyInstance) {
app.get<{ Params: { siteId: string; folderId: string } }>( app.get<{ Params: { siteId: string; folderId: string } }>(
'/sites/:siteId/tree/folders/:folderId', '/sites/:siteId/tree/folders/:folderId',
{ {
config: { // -> Checked against the folder's own path below, not against the group-wide list
permissions: ['read:pages', 'read:assets', 'manage:pages', 'manage:assets']
},
schema: { schema: {
summary: 'Get a single folder', summary: 'Get a single folder',
tags: ['Tree'], tags: ['Tree'],
@ -377,6 +444,11 @@ async function routes(app: FastifyInstance) {
if (!folder || folder.siteId !== req.params.siteId) { if (!folder || folder.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
const folderPath = folderPathOf(folder)
// -> Not visible is the same as not there, so it answers as the id had matched nothing
if (!mayOnFolder(req, 'read:pages', folderPath)) {
return reply.notFound('This folder does not exist.')
}
return { return {
...folder, ...folder,
folderPath: decodeTreePath(folder.folderPath ?? '') ?? '', folderPath: decodeTreePath(folder.folderPath ?? '') ?? '',
@ -391,9 +463,10 @@ async function routes(app: FastifyInstance) {
app.post<{ Params: { siteId: string }; Body: FolderBody }>( app.post<{ Params: { siteId: string }; Body: FolderBody }>(
'/sites/:siteId/tree/folders', '/sites/:siteId/tree/folders',
{ {
config: { /*
permissions: ['write:pages', 'write:assets', 'manage:pages', 'manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and page permissions come
}, from a group's RULES. Checked against the folder's own path below.
*/
schema: { schema: {
summary: 'Create a folder', summary: 'Create a folder',
description: description:
@ -443,7 +516,20 @@ async function routes(app: FastifyInstance) {
} }
} }
}, },
async (req) => { async (req, reply) => {
/*
Against where the folder is going. `parentPath` is the slash-separated path when given; with
`parentId` the parent has to be looked up, and a missing one is left to the model to report.
*/
let parentPath = req.body.parentPath ?? ''
if (req.body.parentId) {
const parent = await WIKI.models.tree.getFolderById(req.body.parentId)
parentPath = parent ? folderPathOf(parent) : parentPath
}
const target = [parentPath, req.body.pathName].filter(Boolean).join('/')
if (!mayOnFolder(req, 'manage:pages', target)) {
return reply.forbidden('You are not allowed to create a folder here.')
}
const folder = await WIKI.models.tree.createFolder({ const folder = await WIKI.models.tree.createFolder({
siteId: req.params.siteId, siteId: req.params.siteId,
locale: req.body.locale ?? defaultLocale(req.params.siteId), locale: req.body.locale ?? defaultLocale(req.params.siteId),
@ -470,9 +556,10 @@ async function routes(app: FastifyInstance) {
app.patch<{ Params: { siteId: string; folderId: string }; Body: FolderBody }>( app.patch<{ Params: { siteId: string; folderId: string }; Body: FolderBody }>(
'/sites/:siteId/tree/folders/:folderId', '/sites/:siteId/tree/folders/:folderId',
{ {
config: { /*
permissions: ['manage:pages', 'manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and page permissions come
}, from a group's RULES. Checked against the folder's own path below.
*/
schema: { schema: {
summary: 'Rename a folder', summary: 'Rename a folder',
description: description:
@ -504,6 +591,9 @@ async function routes(app: FastifyInstance) {
if (!existing || existing.siteId !== req.params.siteId) { if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) {
return reply.forbidden('You are not allowed to rename this folder.')
}
const folder = await WIKI.models.tree.renameFolder({ const folder = await WIKI.models.tree.renameFolder({
folderId: req.params.folderId, folderId: req.params.folderId,
pathName: req.body.pathName, pathName: req.body.pathName,
@ -527,9 +617,10 @@ async function routes(app: FastifyInstance) {
app.delete<{ Params: { siteId: string; folderId: string } }>( app.delete<{ Params: { siteId: string; folderId: string } }>(
'/sites/:siteId/tree/folders/:folderId', '/sites/:siteId/tree/folders/:folderId',
{ {
config: { /*
permissions: ['manage:pages', 'manage:assets'] No route-level `permissions`: that hook reads the group-wide list, and page permissions come
}, from a group's RULES. Checked against the folder's own path below.
*/
schema: { schema: {
summary: 'Delete a folder', summary: 'Delete a folder',
description: description:
@ -548,6 +639,9 @@ async function routes(app: FastifyInstance) {
if (!existing || existing.siteId !== req.params.siteId) { if (!existing || existing.siteId !== req.params.siteId) {
return reply.notFound('This folder does not exist.') return reply.notFound('This folder does not exist.')
} }
if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) {
return reply.forbidden('You are not allowed to delete this folder.')
}
const removed = await WIKI.models.tree.deleteFolder(req.params.folderId) const removed = await WIKI.models.tree.deleteFolder(req.params.folderId)
await WIKI.models.assets.deleteOrphaned(removed.assets) await WIKI.models.assets.deleteOrphaned(removed.assets)
return reply.code(204).send() return reply.code(204).send()

@ -0,0 +1,179 @@
import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups.ts'
/**
* How a page rule is matched against a page, and which rule wins when several match.
*
* ---------------------------------------------------------------------------------------------
* THE RULES OF PAGE PERMISSIONS
* ---------------------------------------------------------------------------------------------
*
* A group grants page permissions through rules, never as a blanket. Every rule names a set of
* permissions (`roles`), a way of addressing pages (`match` + `path`), and what it does with them
* (`mode`). A user's rules are all of their groups' rules pooled together belonging to a second
* group can therefore both widen and narrow what the first one said.
*
* **Nothing is granted by default.** A permission nobody wrote a rule for is denied: no rules at all
* is the same as one DENY rule covering the whole site. This is why an empty group can read nothing.
*
* When more than one rule names the permission being asked about and matches the page, exactly one
* of them decides the answer the most specific. Order in the array means nothing.
*
* 1. SPECIFICITY, highest first. A rule addressing `geography/countries` beats one addressing
* `geography`, because it says something about a smaller part of the site. Measured as the
* length of the path the rule addresses, so the deeper of two paths always wins, and a rule for
* the whole site (empty path) is the least specific thing there is. Tag rules address no path
* at all and are therefore never more specific than a path rule.
*
* 2. MATCH TYPE, when two rules are equally specific. From weakest to strongest:
*
* Has Any Tag < Has All Tags < Path Starts With < Path Ends With <
* Path Matches Regex < Path Is Exactly
*
* The order runs from the vaguest way of naming pages to the most precise: a tag is a property
* a page happens to have, a prefix is a whole branch of the tree, and an exact path is one page
* and nothing else.
*
* 3. MODE, when two rules are equally specific and of the same kind:
*
* ALLOW < DENY < FORCE ALLOW
*
* An ALLOW grants the permission. A DENY overrides any ALLOW. A FORCE ALLOW overrides any DENY,
* which is what makes a hole in an otherwise closed branch possible.
*
* The three are applied in that order: mode only settles a tie between rules of the same kind at the
* same specificity, so a DENY on `geography` does NOT override an ALLOW on `geography/countries`
* the deeper rule was more specific and had already won.
*
* ---------------------------------------------------------------------------------------------
*
* `manage:system` is not evaluated here: it bypasses this entirely, and does so before any rule is
* read. See `models/groups.ts`.
*/
/** A page as a rule sees it. `locale` and `path` place it; `tags` are what tag rules match on. */
export interface RulePageRef {
path: string
locale?: string
tags?: string[]
}
/**
* Match kinds from weakest to strongest, used to break a tie between equally specific rules. The
* index IS the priority, so the order of this array is the order documented above.
*/
const MATCH_PRIORITY: GroupRuleMatch[] = ['TAG', 'TAGALL', 'START', 'END', 'REGEX', 'EXACT']
/** Modes from weakest to strongest, used to break a tie between rules of the same kind. */
const MODE_PRIORITY: GroupRuleMode[] = ['ALLOW', 'DENY', 'FORCEALLOW']
/** Tags are written on a rule as a comma-separated list, in the field a path would otherwise use. */
function ruleTags(rule: GroupRule): string[] {
return rule.path
.split(',')
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean)
}
/** Compared without leading slashes on either side, since neither is stored with one. */
function normalizePath(value: string): string {
return value.replace(/^\/+/, '')
}
/**
* How much of the site a rule is talking about, as a number where higher is narrower.
*
* The length of the path it addresses. A tag rule addresses no path, so it scores zero and can never
* out-specify a rule that names one matching the ordering above, where tags are the vaguest way of
* naming a page.
*/
function specificityOf(rule: GroupRule): number {
if (rule.match === 'TAG' || rule.match === 'TAGALL') {
return 0
}
return normalizePath(rule.path).length
}
/** Whether a rule addresses this page at all, ignoring what it then says about it. */
export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean {
// -> A rule may be limited to particular locales; an empty list means every one of them
if (rule.locales?.length > 0 && page.locale && !rule.locales.includes(page.locale)) {
return false
}
const pagePath = normalizePath(page.path)
const rulePath = normalizePath(rule.path)
const pageTags = (page.tags ?? []).map((tag) => tag.toLowerCase())
switch (rule.match) {
case 'START':
return pagePath.startsWith(rulePath)
case 'EXACT':
return pagePath === rulePath
case 'END':
return pagePath.endsWith(rulePath)
case 'REGEX':
try {
return new RegExp(rulePath).test(pagePath)
} catch {
// -> A rule that cannot compile addresses nothing, rather than everything
return false
}
case 'TAG':
return ruleTags(rule).some((tag) => pageTags.includes(tag))
case 'TAGALL': {
const tags = ruleTags(rule)
return tags.length > 0 && tags.every((tag) => pageTags.includes(tag))
}
default:
return false
}
}
/**
* The rule that decides a permission for a page, out of everything the caller's groups say.
*
* @param rules Every rule from every group the caller belongs to, pooled
* @param permission The single permission being asked about, e.g. `read:pages`
* @returns The deciding rule, or null when nothing addresses this which means denied
*/
export function resolvePageRule(
rules: GroupRule[],
permission: string,
page: RulePageRef
): GroupRule | null {
let winner: GroupRule | null = null
let winnerRank: [number, number, number] = [-1, -1, -1]
for (const rule of rules) {
if (!rule.roles?.includes(permission) || !ruleMatchesPage(rule, page)) {
continue
}
const rank: [number, number, number] = [
specificityOf(rule),
MATCH_PRIORITY.indexOf(rule.match),
MODE_PRIORITY.indexOf(rule.mode)
]
// -> Strictly greater, so the first rule of an otherwise identical pair wins and the outcome
// does not depend on the order they happen to arrive in
if (
rank[0] > winnerRank[0] ||
(rank[0] === winnerRank[0] &&
(rank[1] > winnerRank[1] || (rank[1] === winnerRank[1] && rank[2] > winnerRank[2])))
) {
winner = rule
winnerRank = rank
}
}
return winner
}
/**
* Whether the caller's rules grant a permission on a page.
*
* @returns False when no rule addresses it, which is the default for everything.
*/
export function rulesAllow(rules: GroupRule[], permission: string, page: RulePageRef): boolean {
const rule = resolvePageRule(rules, permission, page)
return rule ? rule.mode !== 'DENY' : false
}

@ -137,6 +137,8 @@ async function postBoot() {
await WIKI.models.authentication.activateStrategies() await WIKI.models.authentication.activateStrategies()
await WIKI.models.locales.reloadCache() await WIKI.models.locales.reloadCache()
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()
// -> Page access is decided from these on every request, so they are in memory from the start
await WIKI.models.groups.reloadCache()
// -> Must follow the sites cache: every site gets a row per installed block // -> Must follow the sites cache: every site gets a row per installed block
await WIKI.models.blocks.refreshFromDisk() await WIKI.models.blocks.refreshFromDisk()
@ -357,10 +359,10 @@ async function initHTTPServer() {
app.register(fastifySwagger, { app.register(fastifySwagger, {
hideUntagged: true, hideUntagged: true,
openapi: { openapi: {
openapi: '3.0.0', openapi: '3.1.0',
info: { info: {
title: 'Wiki.js API', title: 'Wiki.js API',
version: WIKI.config.version version: WIKI.version
}, },
components: { components: {
securitySchemes: { securitySchemes: {
@ -397,9 +399,18 @@ async function initHTTPServer() {
transformedSchema.description = transformedSchema.description =
`${currentDescription}\n\n**Required Permissions:** ${uniq(nestedPermissions).join(' or ')}`.trim() `${currentDescription}\n\n**Required Permissions:** ${uniq(nestedPermissions).join(' or ')}`.trim()
transformedSchema['x-permissions'] = permissions transformedSchema['x-permissions'] = permissions
} else { } else if (route?.config?.publicAccess) {
transformedSchema.description = transformedSchema.description =
`${currentDescription}\n\n**This API is public.** No special permissions required.`.trim() `${currentDescription}\n\n**This API is public.** No special permissions required.`.trim()
} else {
/*
No fixed permission is not the same as public, and saying so was wrong for most of these.
A route without one is usually a route whose answer depends on the caller: the page rules of
their groups, their own account, or the queue they happen to be a reviewer for. What it
serves is scoped, not unrestricted.
*/
transformedSchema.description =
`${currentDescription}\n\n**No fixed permission.** What this returns, and what it acts on, is limited to what the caller is entitled to — their session, their groups' page rules, or their own account. A request that is entitled to nothing gets an empty answer or a refusal rather than an error about permissions.`.trim()
} }
return { schema: transformedSchema, url } return { schema: transformedSchema, url }
@ -407,7 +418,39 @@ async function initHTTPServer() {
}) })
app.register(fastifySwaggerUi, { app.register(fastifySwaggerUi, {
routePrefix: '/_api', routePrefix: '/_api',
logo: {} as any // -> Left empty so the plugin inlines neither its own logo nor one of ours; the stylesheet below
// is what puts the site's logo in the topbar
logo: {} as any,
theme: {
css: [
{
filename: 'wiki.css',
/*
The site's own logo in the topbar, as a background on the link swagger draws its wordmark
in.
A stylesheet rather than the plugin's `logo` option, which takes a buffer and base64-inlines
it into the page when the server boots. This documentation is served for whichever site the
request arrived at, and an administrator can change that site's logo at any time a URL
resolves both of those per request, and a buffer chosen at boot resolves neither.
`contain` in a box wider than it is tall, so a square mark and a wordmark both sit sensibly
without the logo being distorted to fit.
*/
content: `
.swagger-ui .topbar-wrapper a.link > * {
display: none;
}
.swagger-ui .topbar-wrapper a.link {
display: block;
width: 160px;
height: 40px;
background: url('/_site/current/logo') left center / contain no-repeat;
}
`
}
]
}
}) })
// ---------------------------------------- // ----------------------------------------

@ -95,9 +95,10 @@
"admin.approval.nameHint": "How this rule is identified in the list, e.g. Documentation suggestions", "admin.approval.nameHint": "How this rule is identified in the list, e.g. Documentation suggestions",
"admin.approval.nameRequired": "A rule name is required.", "admin.approval.nameRequired": "A rule name is required.",
"admin.approval.newRule": "New Rule", "admin.approval.newRule": "New Rule",
"admin.approval.noRules": "No approval rules yet. Pages accept no edit suggestions until a rule covers them.", "admin.approval.noRules": "No approval rules yet. Pages accept no edit suggestions from read-only users until a rule covers them.",
"admin.approval.path": "Path", "admin.approval.path": "Path",
"admin.approval.pathHint": "Without the leading slash, e.g. docs/getting-started", "admin.approval.pathHint": "Without the leading slash, e.g. docs/getting-started",
"admin.approval.pathHintStart": "Without the leading slash, e.g. docs/. Leave empty to cover the whole site.",
"admin.approval.pathInvalidRegex": "Not a valid regular expression: {reason}", "admin.approval.pathInvalidRegex": "Not a valid regular expression: {reason}",
"admin.approval.pathRequired": "A path is required.", "admin.approval.pathRequired": "A path is required.",
"admin.approval.reviewers": "Reviews submissions", "admin.approval.reviewers": "Reviews submissions",
@ -107,7 +108,7 @@
"admin.approval.submitters": "Can submit edits", "admin.approval.submitters": "Can submit edits",
"admin.approval.submittersHint": "Members of these groups can submit edit suggestions for matching pages.", "admin.approval.submittersHint": "Members of these groups can submit edit suggestions for matching pages.",
"admin.approval.submittersRequired": "Select at least one group that can submit edits.", "admin.approval.submittersRequired": "Select at least one group that can submit edits.",
"admin.approval.subtitle": "Define which pages accept edit suggestions, and who reviews them", "admin.approval.subtitle": "Define which pages accept edit suggestions from read-only users, and who reviews them",
"admin.approval.tags": "Tags", "admin.approval.tags": "Tags",
"admin.approval.tagsHint": "Comma-separated list of tags.", "admin.approval.tagsHint": "Comma-separated list of tags.",
"admin.approval.tagsRequired": "At least one tag is required.", "admin.approval.tagsRequired": "At least one tag is required.",
@ -281,8 +282,6 @@
"admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?", "admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?",
"admin.general.allowComments": "Allow Comments", "admin.general.allowComments": "Allow Comments",
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
"admin.general.allowContributions": "Allow Contributions",
"admin.general.allowContributionsHint": "Can users with read access permissions propose changes for pages? Can be restricted using Page Rules.",
"admin.general.allowProfile": "Allow Profile Editing", "admin.general.allowProfile": "Allow Profile Editing",
"admin.general.allowProfileHint": "Can users edit their own profile? If profile data is managed by an external identity provider, you should turn this off.", "admin.general.allowProfileHint": "Can users edit their own profile? If profile data is managed by an external identity provider, you should turn this off.",
"admin.general.allowRatings": "Allow Ratings", "admin.general.allowRatings": "Allow Ratings",

@ -18,12 +18,35 @@ export const approvalMatchModes = ['START', 'EXACT', 'END', 'REGEX', 'TAG', 'TAG
export type ApprovalMatchMode = (typeof approvalMatchModes)[number] export type ApprovalMatchMode = (typeof approvalMatchModes)[number]
/** The part of a page a rule is matched against. */ /** The part of a page a rule is matched against. */
export interface ApprovalPageRef { /** What a rule is matched against: where the page is, and what it is tagged with. */
id: string export interface ApprovalPageMatch {
path: string path: string
tags: string[] tags: string[]
} }
export interface ApprovalPageRef extends ApprovalPageMatch {
id: string
/**
* The page's own switch, from its properties. A page with contributions turned off takes no
* suggestions whatever the rules say which is how a single page is exempted without writing a
* rule around it.
*/
allowContributions: boolean
}
/**
* Who is reviewing, as the rules see them.
*
* `reviewsAll` covers the two ways of being a reviewer without a rule naming your group: the
* `manage:system` permission, which sees everything everywhere, and `review:pages`, which is granted
* to review pages and would be worth nothing if it could not. Neither widens WHICH pages take
* suggestions -- a page still needs a rule -- only who may answer them.
*/
export interface ReviewerScope {
groupIds: string[]
reviewsAll?: boolean
}
/** An edit suggested against a page, as the author's own view of it. */ /** An edit suggested against a page, as the author's own view of it. */
export interface PageEditSubmission { export interface PageEditSubmission {
id: string id: string
@ -184,7 +207,10 @@ class Approvals {
name: patch.name ?? '', name: patch.name ?? '',
isEnabled: patch.isEnabled ?? true, isEnabled: patch.isEnabled ?? true,
match: patch.match ?? 'START', match: patch.match ?? 'START',
path: patch.path ?? '', // -> Trimmed, so a pattern typed with a stray space still matches what it reads as -- and so
// that a `START` path of nothing but spaces is the whole site rather than a rule that
// quietly covers no page at all
path: (patch.path ?? '').trim(),
submitterGroups: patch.submitterGroups ?? [], submitterGroups: patch.submitterGroups ?? [],
reviewerGroups: patch.reviewerGroups ?? [] reviewerGroups: patch.reviewerGroups ?? []
}) })
@ -212,7 +238,8 @@ class Approvals {
'reviewerGroups' 'reviewerGroups'
] as const) { ] as const) {
if (patch[key] !== undefined) { if (patch[key] !== undefined) {
values[key] = patch[key] // -> Trimmed for the same reason it is on create
values[key] = key === 'path' ? String(patch[key]).trim() : patch[key]
} }
} }
@ -232,7 +259,7 @@ class Approvals {
* throwing: the rule is already refused at the API, so this is only reached by one that was valid * throwing: the rule is already refused at the API, so this is only reached by one that was valid
* when it was written and stopped being so. * when it was written and stopped being so.
*/ */
matchesPage(rule: ApprovalRule, page: ApprovalPageRef): boolean { matchesPage(rule: ApprovalRule, page: ApprovalPageMatch): boolean {
const pagePath = page.path.replace(/^\/+/, '') const pagePath = page.path.replace(/^\/+/, '')
const rulePath = rule.path.replace(/^\/+/, '') const rulePath = rule.path.replace(/^\/+/, '')
switch (rule.match) { switch (rule.match) {
@ -277,6 +304,13 @@ class Approvals {
/** /**
* The enabled rule that lets these groups suggest an edit to this page, if there is one. * The enabled rule that lets these groups suggest an edit to this page, if there is one.
* *
* The page's own `allowContributions` is a veto rather than another condition to match: a rule says
* which pages MAY take suggestions, and turning the switch off on one page says that this one does
* not no rule has to be rewritten, narrowed or excluded around it.
*
* Everything asking whether a page takes a suggestion asks this, which is why the check lives here
* rather than at either route.
*
* @returns The first matching rule, or null when the page takes no suggestions from them * @returns The first matching rule, or null when the page takes no suggestions from them
*/ */
async findSubmitRule( async findSubmitRule(
@ -284,7 +318,7 @@ class Approvals {
page: ApprovalPageRef, page: ApprovalPageRef,
groupIds: string[] groupIds: string[]
): Promise<ApprovalRule | null> { ): Promise<ApprovalRule | null> {
if (groupIds.length < 1) { if (groupIds.length < 1 || !page.allowContributions) {
return null return null
} }
const rules = await this.getRules(siteId) const rules = await this.getRules(siteId)
@ -298,6 +332,31 @@ class Approvals {
) )
} }
/**
* Whether this reviewer has any business reviewing this page at all.
*
* What decides whether the page view offers a review button, so it is about the page rather than
* about what happens to be waiting on it: a reviewer of a page with an empty queue is still its
* reviewer. A page no rule covers takes no suggestions, so nobody reviews it -- not even an
* administrator, who would only be offered a button that could never have anything behind it.
*/
async canReviewPage(
siteId: string,
page: ApprovalPageMatch,
{ groupIds, reviewsAll = false }: ReviewerScope
): Promise<boolean> {
if (!reviewsAll && groupIds.length < 1) {
return false
}
const rules = await this.getRules(siteId)
return rules.some(
(rule) =>
rule.isEnabled &&
(reviewsAll || rule.reviewerGroups.some((id) => groupIds.includes(id))) &&
this.matchesPage(rule, page)
)
}
/** /**
* The suggestion this user already has open on this page, if any. * The suggestion this user already has open on this page, if any.
* *
@ -413,14 +472,14 @@ class Approvals {
*/ */
async getReviewableSubmissions( async getReviewableSubmissions(
siteId: string, siteId: string,
{ groupIds, isAdmin = false }: { groupIds: string[]; isAdmin?: boolean } { groupIds, reviewsAll = false, pageId }: ReviewerScope & { pageId?: string }
): Promise<ReviewableSubmission[]> { ): Promise<ReviewableSubmission[]> {
if (!isAdmin && groupIds.length < 1) { if (!reviewsAll && groupIds.length < 1) {
return [] return []
} }
const rules = (await this.getRules(siteId)).filter( const rules = (await this.getRules(siteId)).filter(
(rule) => (rule) =>
rule.isEnabled && (isAdmin || rule.reviewerGroups.some((id) => groupIds.includes(id))) rule.isEnabled && (reviewsAll || rule.reviewerGroups.some((id) => groupIds.includes(id)))
) )
if (rules.length < 1) { if (rules.length < 1) {
return [] return []
@ -447,7 +506,11 @@ class Approvals {
.from(submissionsTable) .from(submissionsTable)
.innerJoin(pagesTable, eq(pagesTable.id, submissionsTable.pageId)) .innerJoin(pagesTable, eq(pagesTable.id, submissionsTable.pageId))
.leftJoin(usersTable, eq(usersTable.id, submissionsTable.authorId)) .leftJoin(usersTable, eq(usersTable.id, submissionsTable.authorId))
.where(eq(submissionsTable.siteId, siteId)) .where(
pageId
? and(eq(submissionsTable.siteId, siteId), eq(submissionsTable.pageId, pageId))
: eq(submissionsTable.siteId, siteId)
)
.orderBy(asc(submissionsTable.createdAt)) .orderBy(asc(submissionsTable.createdAt))
// -> Matched in memory rather than in SQL: a rule can be a regular expression or a set of tags, // -> Matched in memory rather than in SQL: a rule can be a regular expression or a set of tags,
@ -455,7 +518,13 @@ class Approvals {
return rows return rows
.filter((row: any) => .filter((row: any) =>
rules.some((rule) => rules.some((rule) =>
this.matchesPage(rule, { id: row.pageId, path: row.pagePath, tags: row.pageTags ?? [] }) /*
No `allowContributions` here, deliberately: that switch governs whether a suggestion may
be MADE. One already sent stays in its reviewers' queue if the page is later closed to
contributions -- otherwise turning the switch off would silently strand work somebody had
submitted in good faith, with nobody able to accept or decline it.
*/
this.matchesPage(rule, { path: row.pagePath, tags: row.pageTags ?? [] })
) )
) )
.map((row: any) => this.toReviewable(row)) .map((row: any) => this.toReviewable(row))
@ -469,10 +538,10 @@ class Approvals {
async getSubmissionForReview( async getSubmissionForReview(
siteId: string, siteId: string,
submissionId: string, submissionId: string,
{ groupIds, isAdmin = false }: { groupIds: string[]; isAdmin?: boolean } { groupIds, reviewsAll = false }: ReviewerScope
): Promise<ReviewableSubmissionDetail | null> { ): Promise<ReviewableSubmissionDetail | null> {
// -> Reuses the queue rather than re-deriving who may see what: one definition of reviewable // -> Reuses the queue rather than re-deriving who may see what: one definition of reviewable
const reviewable = await this.getReviewableSubmissions(siteId, { groupIds, isAdmin }) const reviewable = await this.getReviewableSubmissions(siteId, { groupIds, reviewsAll })
if (!reviewable.some((s) => s.id === submissionId)) { if (!reviewable.some((s) => s.id === submissionId)) {
return null return null
} }

@ -1,7 +1,9 @@
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { and, count, eq, ilike, or, sql } from 'drizzle-orm' import { and, count, eq, ilike, or, sql } from 'drizzle-orm'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts' import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
import type { SystemIds } from './types.ts' import type { SystemIds } from './types.ts'
import type { FastifyRequest } from 'fastify'
/** How a rule's `path` is compared against the page path. */ /** How a rule's `path` is compared against the page path. */
export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
@ -94,10 +96,95 @@ const groupSelection = {
userCount: count(userGroups.userId) userCount: count(userGroups.userId)
} }
/**
* Who is asking, and what they hold outside the page rules.
*
* `permissions` is the group-wide list `manage:system`, `access:admin` and the rest which is a
* different thing from the page permissions the rules decide.
*/
export interface AccessActor {
groupIds: string[]
permissions: string[]
}
/**
* Every group's rules, by group id.
*
* Cached because a page permission is checked on every page read, and reading three rows out of the
* database to answer it would put a query in front of every request. Reloaded whenever a group
* changes, the same way the site configurations are.
*/
let rulesCache: Record<string, GroupRule[]> = {}
/** /**
* Groups model * Groups model
*/ */
class Groups { class Groups {
/**
* Reload the page rules of every group into memory.
*
* Called at boot and after any change to a group. A group edit therefore takes effect on the next
* request rather than on the next login, which matters: rules are the whole of page access, and a
* revoked permission that waits for a logout is not revoked.
*/
async reloadCache(): Promise<void> {
const rows = await WIKI.db
.select({ id: groupsTable.id, rules: groupsTable.rules })
.from(groupsTable)
rulesCache = {}
for (const row of rows) {
rulesCache[row.id] = (row.rules ?? []) as GroupRule[]
}
WIKI.logger.info(`Loaded page rules for ${rows.length} groups [ OK ]`)
}
/** The pooled rules of a set of groups, which is what a permission is decided against. */
rulesForGroups(groupIds: string[]): GroupRule[] {
return groupIds.flatMap((id) => rulesCache[id] ?? [])
}
/**
* Which groups a request speaks for.
*
* An anonymous request is not group-less: it is the guests group, whose rules are how a wiki says
* what the public may see. Treating it as no groups at all would deny everything, which is a
* different answer from the one the administrator configured.
*/
groupIdsForRequest(req: FastifyRequest): string[] {
if (req.session?.authenticated && req.session.user?.id) {
return req.session.groups ?? []
}
return [WIKI.data.systemIds.guestsGroupId]
}
/** The actor a request speaks for: its groups, and the group-wide permissions it holds. */
actorForRequest(req: FastifyRequest): AccessActor {
return {
groupIds: this.groupIdsForRequest(req),
// -> An API key stands in for a session and carries its own permissions, as it does in the
// route-level check
permissions: req.apiKey?.permissions ?? req.session?.permissions ?? []
}
}
/**
* Whether this caller may do this to this page.
*
* The one place page permissions are decided. Everything page-scoped asks this rather than reading
* the session's permission list, because that list says what a group was granted GLOBALLY and page
* permissions are not granted that way see `helpers/pageRules.ts` for how a rule is chosen.
*
* @param permission A single page permission, e.g. `read:pages` or `read:history`
*/
checkAccess(actor: AccessActor, permission: string, page: RulePageRef): boolean {
// -> Above the rules entirely: an administrator is not something a rule can lock out, and a
// wiki whose only administrator had denied themselves would have nobody left to fix it
if (actor.permissions.includes('manage:system')) {
return true
}
const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page)
return rule ? rule.mode !== 'DENY' : false
}
async init(ids: SystemIds): Promise<void> { async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Inserting default groups...') WIKI.logger.info('Inserting default groups...')
@ -177,6 +264,7 @@ class Groups {
isSystem: false isSystem: false
}) })
.returning({ id: groupsTable.id }) .returning({ id: groupsTable.id })
await this.reloadCache()
return result[0].id return result[0].id
} }
@ -222,6 +310,7 @@ class Groups {
.update(groupsTable) .update(groupsTable)
.set({ ...patch, updatedAt: sql`now()` }) .set({ ...patch, updatedAt: sql`now()` })
.where(eq(groupsTable.id, id)) .where(eq(groupsTable.id, id))
await this.reloadCache()
return (result.rowCount ?? 0) > 0 return (result.rowCount ?? 0) > 0
} }
@ -233,6 +322,7 @@ class Groups {
*/ */
async deleteGroup(id: string): Promise<boolean> { async deleteGroup(id: string): Promise<boolean> {
const result = await WIKI.db.delete(groupsTable).where(eq(groupsTable.id, id)) const result = await WIKI.db.delete(groupsTable).where(eq(groupsTable.id, id))
await this.reloadCache()
return (result.rowCount ?? 0) > 0 return (result.rowCount ?? 0) > 0
} }

@ -1,4 +1,5 @@
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import type { AccessActor } from './groups.ts'
/** /**
* Locale to PostgreSQL text search dictionary, for the languages postgres ships a snowball stemmer * Locale to PostgreSQL text search dictionary, for the languages postgres ships a snowball stemmer
@ -90,6 +91,13 @@ export interface SearchPagesParams {
publicOnly?: boolean publicOnly?: boolean
/** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */ /** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */
includeDrafts?: boolean includeDrafts?: boolean
/**
* Who is searching, so that a result they could not open never reaches them.
*
* Applied to the rows rather than in the query: which pages a rule covers can depend on a regular
* expression or on a page's tags, neither of which a `WHERE` clause here could express.
*/
actor?: AccessActor
/** /**
* Keep a password-protected page's *body* out of the results, for a searcher who would have to enter * Keep a password-protected page's *body* out of the results, for a searcher who would have to enter
* the password to read it. The page itself still appears its title and description are not what * the password to read it. The page itself still appears its title and description are not what
@ -219,7 +227,8 @@ class Search {
limit = 25, limit = 25,
publicOnly = false, publicOnly = false,
includeDrafts = false, includeDrafts = false,
hideProtectedContent = true hideProtectedContent = true,
actor
}: SearchPagesParams): Promise<SearchPagesResult> { }: SearchPagesParams): Promise<SearchPagesResult> {
const terms = query.trim() const terms = query.trim()
const hasQuery = terms.length > 0 const hasQuery = terms.length > 0
@ -227,10 +236,17 @@ class Search {
// -> Only the locales in play need an arm in the dictionary CASE // -> Only the locales in play need an arm in the dictionary CASE
const siteLocales: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? ['en'] const siteLocales: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? ['en']
const searchedLocales = locales.length > 0 ? locales : siteLocales const searchedLocales = locales.length > 0 ? locales : siteLocales
const dict = this.dictionaryExpression( /*
searchedLocales, No terms means no query to parse, and therefore no dictionary to parse it with.
hasQuery ? await this.getAvailableDictionaries() : []
) Both arguments are withheld together on purpose. Passing the locales while claiming nothing is
installed -- which is what an empty `available` says -- made every locale resolve to the
fallback and warn that its dictionary was missing, on a code path that never uses the answer.
That warning was the one in the logs: `english` is installed, nobody had looked.
*/
const dict = hasQuery
? this.dictionaryExpression(searchedLocales, await this.getAvailableDictionaries())
: this.dictionaryExpression([], [])
const tsQuery = sql`websearch_to_tsquery(${dict}, ${terms})` const tsQuery = sql`websearch_to_tsquery(${dict}, ${terms})`
const conditions = [sql`p."siteId" = ${siteId}`, sql`p."isSearchable" = true`] const conditions = [sql`p."siteId" = ${siteId}`, sql`p."isSearchable" = true`]
@ -321,7 +337,22 @@ class Search {
LIMIT ${limit} OFFSET ${offset} LIMIT ${limit} OFFSET ${offset}
`) `)
const result = ((rows.rows ?? rows) as any[]).map((row) => ({ /*
Filtered here rather than in SQL: a page rule can be a regular expression or a set of tags, so
the deciding rule is only knowable per row. Search must not be a way around page permissions
a title and an excerpt are content too.
*/
const visible = actor
? ((rows.rows ?? rows) as any[]).filter((row) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', {
path: row.path as string,
locale: row.locale as string,
tags: (row.tags ?? []) as string[]
})
)
: ((rows.rows ?? rows) as any[])
const result = visible.map((row) => ({
id: row.id as string, id: row.id as string,
path: row.path as string, path: row.path as string,
locale: row.locale as string, locale: row.locale as string,
@ -341,7 +372,18 @@ class Search {
return { return {
results: result, results: result,
totalHits: Number((rows.rows ?? rows)[0]?.totalHits ?? 0) /*
The count postgres reported, less whatever the rules just removed from this page of results.
Not exact when rows are dropped -- the window function counted every match, including ones on
later pages this reader may not see -- but a total that ignored the filtering entirely would
promise results that do not exist.
*/
totalHits: Math.max(
0,
Number((rows.rows ?? rows)[0]?.totalHits ?? 0) -
((rows.rows ?? rows) as any[]).length +
visible.length
)
} }
} }

@ -86,9 +86,8 @@ class Sites {
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: false, comments: false,
contributions: false,
profile: true, profile: true,
reasonForChange: 'required', reasonForChange: 'optional',
search: true search: true
}, },
logoUrl: '', logoUrl: '',
@ -279,9 +278,8 @@ class Sites {
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: false, comments: false,
contributions: false,
profile: true, profile: true,
reasonForChange: 'required', reasonForChange: 'optional',
search: true search: true
}, },
logoText: true, logoText: true,

@ -1,4 +1,5 @@
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import type { AccessActor } from './groups.ts'
export interface Tag { export interface Tag {
tag: string tag: string
@ -21,20 +22,58 @@ class Tags {
* *
* @param siteId Site the pages belong to * @param siteId Site the pages belong to
* @param limit Ceiling on how many distinct tags come back, most used first * @param limit Ceiling on how many distinct tags come back, most used first
* @param actor Who is asking. Given one, the list is built only from the pages they may read
* a tag is the name of something on a page, and the set of tags in use tells a
* reader what a wiki is about. Counted over readable pages too, so the numbers agree
* with what a search for the tag would return.
*/ */
async getTags(siteId: string, { limit = 1000 }: { limit?: number } = {}): Promise<Tag[]> { async getTags(
siteId: string,
{ limit = 1000, actor }: { limit?: number; actor?: AccessActor } = {}
): Promise<Tag[]> {
if (!actor) {
const result = await WIKI.db.execute(sql`
SELECT tag, COUNT(*)::int AS "usageCount"
FROM pages, unnest(tags) AS tag
WHERE "siteId" = ${siteId}
GROUP BY tag
ORDER BY COUNT(*) DESC, tag ASC
LIMIT ${limit}
`)
return ((result.rows ?? result) as any[]).map((row) => ({
tag: row.tag as string,
usageCount: row.usageCount as number
}))
}
/*
Aggregated here rather than in postgres, because which pages count depends on the page rules and
a rule can be a regular expression or a set of tags neither of which a `GROUP BY` could take
into account. Only tagged pages are read, and only their path, locale and tags.
*/
const result = await WIKI.db.execute(sql` const result = await WIKI.db.execute(sql`
SELECT tag, COUNT(*)::int AS "usageCount" SELECT path, locale, tags
FROM pages, unnest(tags) AS tag FROM pages
WHERE "siteId" = ${siteId} WHERE "siteId" = ${siteId} AND array_length(tags, 1) > 0
GROUP BY tag
ORDER BY COUNT(*) DESC, tag ASC
LIMIT ${limit}
`) `)
return ((result.rows ?? result) as any[]).map((row) => ({ const counts = new Map<string, number>()
tag: row.tag as string, for (const row of (result.rows ?? result) as any[]) {
usageCount: row.usageCount as number const page = {
})) path: row.path as string,
locale: row.locale as string,
tags: (row.tags ?? []) as string[]
}
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', page)) {
continue
}
for (const tag of page.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1)
}
}
return [...counts.entries()]
.map(([tag, usageCount]) => ({ tag, usageCount }))
.sort((a, b) => b.usageCount - a.usageCount || a.tag.localeCompare(b.tag))
.slice(0, limit)
} }
} }

@ -63,5 +63,14 @@ declare module 'fastify' {
* The outer array is OR-ed; a nested array is AND-ed. `manage:system` bypasses the check. * The outer array is OR-ed; a nested array is AND-ed. `manage:system` bypasses the check.
*/ */
permissions?: (string | string[])[] permissions?: (string | string[])[]
/**
* Whether this route genuinely serves everybody the same thing.
*
* Only affects the API documentation. A route with no `permissions` is not thereby public: most
* of them answer according to who is asking the caller's session, their groups' page rules, or
* their own account and the docs say so. This marks the few where a guest and an administrator
* really do get the same reply, so that the difference is stated rather than assumed.
*/
publicAccess?: boolean
} }
} }

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
256 icons. 257 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32}, "la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -77,6 +77,7 @@ export const BUNDLED_ICONS = {
"la:icons": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h8v8H7zm10 0h8v8h-8zm-6 2l-3 4h6zm8 0v4h4V9zM7 17h8v8H7zm10 0h8v8h-8zm4 1l-2 3l2 3l2-3zm-10 1a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32}, "la:icons": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h8v8H7zm10 0h8v8h-8zm-6 2l-3 4h6zm8 0v4h4V9zM7 17h8v8H7zm10 0h8v8h-8zm4 1l-2 3l2 3l2-3zm-10 1a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:id-card": {"body":"<path fill=\"currentColor\" d=\"M5 6C3.355 6 2 7.355 2 9v14c0 1.645 1.355 3 3 3h22c1.645 0 3-1.355 3-3V9c0-1.645-1.355-3-3-3zm0 2h22c.566 0 1 .434 1 1v14c0 .566-.434 1-1 1H5c-.566 0-1-.434-1-1V9c0-.566.434-1 1-1m6 2c-2.2 0-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 6 21h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C14.523 16.117 15 15.114 15 14c0-2.2-1.8-4-4-4m7 1v2h8v-2zm-7 1c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m7 3v2h8v-2zm0 4v2h5v-2z\"/>","width":32,"height":32}, "la:id-card": {"body":"<path fill=\"currentColor\" d=\"M5 6C3.355 6 2 7.355 2 9v14c0 1.645 1.355 3 3 3h22c1.645 0 3-1.355 3-3V9c0-1.645-1.355-3-3-3zm0 2h22c.566 0 1 .434 1 1v14c0 .566-.434 1-1 1H5c-.566 0-1-.434-1-1V9c0-.566.434-1 1-1m6 2c-2.2 0-4 1.8-4 4c0 1.113.477 2.117 1.219 2.844A5.04 5.04 0 0 0 6 21h2c0-1.668 1.332-3 3-3s3 1.332 3 3h2a5.04 5.04 0 0 0-2.219-4.156C14.523 16.117 15 15.114 15 14c0-2.2-1.8-4-4-4m7 1v2h8v-2zm-7 1c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m7 3v2h8v-2zm0 4v2h5v-2z\"/>","width":32,"height":32},
"la:image": {"body":"<path fill=\"currentColor\" d=\"M2 5v22h28V5zm2 2h24v13.906l-5.281-5.312l-.719-.719l-4.531 4.531l-5.75-5.812l-.719-.719l-7 7zm20 2a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m-13 6.719L20.188 25H4v-2.281zm11 2l6 6V25h-4.969l-4.156-4.188z\"/>","width":32,"height":32}, "la:image": {"body":"<path fill=\"currentColor\" d=\"M2 5v22h28V5zm2 2h24v13.906l-5.281-5.312l-.719-.719l-4.531 4.531l-5.75-5.812l-.719-.719l-7 7zm20 2a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m-13 6.719L20.188 25H4v-2.281zm11 2l6 6V25h-4.969l-4.156-4.188z\"/>","width":32,"height":32},
"la:inbox": {"body":"<path fill=\"currentColor\" d=\"M7.156 5L7 5.844l-2 13V27h22v-8.156l-2-13L24.844 5zm1.719 2h14.25l1.688 11H18v1c0 1.117-.883 2-2 2s-2-.883-2-2v-1H7.187zM7 20h5.188c.453 1.71 1.964 3 3.812 3s3.36-1.29 3.813-3H25v5H7z\"/>","width":32,"height":32},
"la:infinity": {"body":"<path fill=\"currentColor\" d=\"M9 9c-3.855 0-7 3.145-7 7c0 3.86 3.14 7 7 7c2.93 0 4.719-1.61 6.094-3.594c-.41-.66-.754-1.312-1.094-1.937C12.773 19.496 11.398 21 9 21c-2.758 0-5-2.242-5-5c0-2.773 2.227-5 5-5c1.617 0 2.645.578 3.594 1.563c.949.984 1.75 2.406 2.562 3.906c.813 1.5 1.637 3.078 2.844 4.343S20.871 23 23 23c3.855 0 7-3.145 7-7c0-3.86-3.14-7-7-7c-2.914 0-4.715 1.559-6.094 3.5q.615.973 1.125 1.906C19.25 12.437 20.61 11 23 11c2.758 0 5 2.242 5 5c0 2.773-2.227 5-5 5c-1.59 0-2.59-.578-3.531-1.563c-.942-.984-1.746-2.406-2.563-3.906c-.816-1.5-1.656-3.078-2.875-4.344C12.812 9.922 11.148 9 9 9\"/>","width":32,"height":32}, "la:infinity": {"body":"<path fill=\"currentColor\" d=\"M9 9c-3.855 0-7 3.145-7 7c0 3.86 3.14 7 7 7c2.93 0 4.719-1.61 6.094-3.594c-.41-.66-.754-1.312-1.094-1.937C12.773 19.496 11.398 21 9 21c-2.758 0-5-2.242-5-5c0-2.773 2.227-5 5-5c1.617 0 2.645.578 3.594 1.563c.949.984 1.75 2.406 2.562 3.906c.813 1.5 1.637 3.078 2.844 4.343S20.871 23 23 23c3.855 0 7-3.145 7-7c0-3.86-3.14-7-7-7c-2.914 0-4.715 1.559-6.094 3.5q.615.973 1.125 1.906C19.25 12.437 20.61 11 23 11c2.758 0 5 2.242 5 5c0 2.773-2.227 5-5 5c-1.59 0-2.59-.578-3.531-1.563c-.942-.984-1.746-2.406-2.563-3.906c-.816-1.5-1.656-3.078-2.875-4.344C12.812 9.922 11.148 9 9 9\"/>","width":32,"height":32},
"la:info-circle": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-1 5v2h2v-2zm0 4v8h2v-8z\"/>","width":32,"height":32}, "la:info-circle": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-1 5v2h2v-2zm0 4v8h2v-8z\"/>","width":32,"height":32},
"la:js-square": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm13.244 8c-1.425 0-2.346.912-2.346 2.12c0 1.31.77 1.937 1.928 2.43l.4.173c.733.323 1.169.511 1.169 1.062c0 .465-.427.799-1.092.799c-.788 0-1.236-.418-1.578-.979l-1.31.75c.464.931 1.433 1.645 2.925 1.645c1.52 0 2.66-.788 2.66-2.232c0-1.35-.77-1.949-2.139-2.528l-.398-.172c-.693-.304-.988-.503-.988-.978c0-.39.294-.694.77-.694c.465 0 .758.2 1.034.694l1.256-.807c-.532-.93-1.265-1.283-2.29-1.283zm-5.85.096v5.463c0 .798-.342 1.005-.865 1.005c-.55 0-.788-.379-1.035-.826l-1.31.79c.38.807 1.129 1.472 2.412 1.472C15.02 23 16 22.24 16 20.576v-5.48z\"/>","width":32,"height":32}, "la:js-square": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm13.244 8c-1.425 0-2.346.912-2.346 2.12c0 1.31.77 1.937 1.928 2.43l.4.173c.733.323 1.169.511 1.169 1.062c0 .465-.427.799-1.092.799c-.788 0-1.236-.418-1.578-.979l-1.31.75c.464.931 1.433 1.645 2.925 1.645c1.52 0 2.66-.788 2.66-2.232c0-1.35-.77-1.949-2.139-2.528l-.398-.172c-.693-.304-.988-.503-.988-.978c0-.39.294-.694.77-.694c.465 0 .758.2 1.034.694l1.256-.807c-.532-.93-1.265-1.283-2.29-1.283zm-5.85.096v5.463c0 .798-.342 1.005-.865 1.005c-.55 0-.788-.379-1.035-.826l-1.31.79c.38.807 1.129 1.472 2.412 1.472C15.02 23 16 22.24 16 20.576v-5.48z\"/>","width":32,"height":32},

@ -6,8 +6,14 @@
<span>{{ isEdit ? t('admin.approval.editRule') : t('admin.approval.newRule') }}</span> <span>{{ isEdit ? t('admin.approval.editRule') : t('admin.approval.newRule') }}</span>
</w-card-section> </w-card-section>
<w-form ref="ruleForm" class="py-2" @submit="save"> <w-form ref="ruleForm" class="py-2" @submit="save">
<!--
No `self-start` on these icons. A field's control carries a symmetric `my-2` -- room for the
floated label, matched underneath precisely so the box stays centred on the control -- so
letting both sections centre in the row is what lines the icon up with the field. Pinning
the icon to the top instead put it 6px above the control it belongs to.
-->
<w-item> <w-item>
<blueprint-icon icon="rename" class="self-start" /> <blueprint-icon icon="rename" />
<w-item-section> <w-item-section>
<w-input <w-input
ref="iptName" ref="iptName"
@ -23,7 +29,7 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item> <w-item>
<blueprint-icon icon="filtration" class="self-start" /> <blueprint-icon icon="filtration" />
<w-item-section> <w-item-section>
<w-select <w-select
v-model="state.match" v-model="state.match"
@ -41,7 +47,7 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item> <w-item>
<blueprint-icon :icon="isTagMatch ? `flag-filled` : `link`" class="self-start" /> <blueprint-icon :icon="isTagMatch ? `flag-filled` : `link`" />
<w-item-section> <w-item-section>
<!-- <!--
One field for both kinds of pattern: a tag mode takes a list of tags rather than a path, One field for both kinds of pattern: a tag mode takes a list of tags rather than a path,
@ -56,13 +62,13 @@
:rules="pathValidation" :rules="pathValidation"
hide-bottom-space hide-bottom-space
:label="isTagMatch ? t(`admin.approval.tags`) : t(`admin.approval.path`)" :label="isTagMatch ? t(`admin.approval.tags`) : t(`admin.approval.path`)"
:hint="isTagMatch ? t(`admin.approval.tagsHint`) : t(`admin.approval.pathHint`)" :hint="pathHint"
lazy-rules="ondemand" /> lazy-rules="ondemand" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
<w-item> <w-item>
<blueprint-icon icon="pen" class="self-start" /> <blueprint-icon icon="pen" />
<w-item-section> <w-item-section>
<w-select <w-select
v-model="state.submitterGroups" v-model="state.submitterGroups"
@ -83,7 +89,7 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item> <w-item>
<blueprint-icon icon="validation" class="self-start" /> <blueprint-icon icon="validation" />
<w-item-section> <w-item-section>
<w-select <w-select
v-model="state.reviewerGroups" v-model="state.reviewerGroups"
@ -200,8 +206,19 @@ const matchOptions = computed(() => [
const nameValidation = [(val) => (val ?? '').trim().length > 0 || t('admin.approval.nameRequired')] const nameValidation = [(val) => (val ?? '').trim().length > 0 || t('admin.approval.nameRequired')]
/** What the field is asking for, which is a different thing in each mode -- empty included. */
const pathHint = computed(() => {
if (isTagMatch.value) {
return t('admin.approval.tagsHint')
}
return state.match === 'START' ? t('admin.approval.pathHintStart') : t('admin.approval.pathHint')
})
const pathValidation = [ const pathValidation = [
// -> Empty is a real answer for `START`: every path starts with nothing, so the rule covers the
// whole site. The server agrees, and refuses it for every other mode -- see `validateRule`.
(val) => (val) =>
state.match === 'START' ||
(val ?? '').trim().length > 0 || (val ?? '').trim().length > 0 ||
(isTagMatch.value ? t('admin.approval.tagsRequired') : t('admin.approval.pathRequired')), (isTagMatch.value ? t('admin.approval.tagsRequired') : t('admin.approval.pathRequired')),
// -> Caught here as well as by the server: a pattern that cannot compile is a rule that silently // -> Caught here as well as by the server: a pattern that cannot compile is a rule that silently

@ -456,6 +456,39 @@ const { t } = useI18n()
// DATA // DATA
/**
* Where the view options are remembered. The browser rather than the account, deliberately: how
* densely a list should be drawn is a property of the screen it is being read on, and the same person
* on a laptop and on a large monitor will not want the same answer.
*/
const VIEW_OPTIONS_KEY = 'wiki.fileman.viewOptions'
/**
* The remembered view options, each taken only if it is still a value this component understands.
*
* Field by field rather than wholesale: the entry outlives the code that wrote it, and an option that
* has since changed shape -- or been hand-edited in devtools -- must not be able to put the file list
* into a state it has no way back out of.
*/
function storedViewOptions() {
let stored = null
try {
stored = JSON.parse(globalThis.localStorage?.getItem(VIEW_OPTIONS_KEY) ?? 'null')
} catch {
// -> Unreadable is the same as absent: the defaults below stand
}
if (!stored || typeof stored !== 'object') {
return {}
}
return {
...(['title', 'path'].includes(stored.displayMode) ? { displayMode: stored.displayMode } : {}),
...(typeof stored.isCompact === 'boolean' ? { isCompact: stored.isCompact } : {}),
...(typeof stored.shouldShowFolders === 'boolean'
? { shouldShowFolders: stored.shouldShowFolders }
: {})
}
}
const state = reactive({ const state = reactive({
loading: 0, loading: 0,
isFetching: false, isFetching: false,
@ -476,6 +509,28 @@ const state = reactive({
fileListLoading: false fileListLoading: false
}) })
// -> Over the defaults just above, which is what the view falls back to on a first visit
Object.assign(state, storedViewOptions())
/*
Written on every change rather than when the overlay closes: the file manager is also opened from
the editor's insert flow, which can be dismissed in ways that never reach a teardown here.
*/
watch(
() => [state.displayMode, state.isCompact, state.shouldShowFolders],
([displayMode, isCompact, shouldShowFolders]) => {
try {
globalThis.localStorage?.setItem(
VIEW_OPTIONS_KEY,
JSON.stringify({ displayMode, isCompact, shouldShowFolders })
)
} catch {
// -> Full, or storage denied. Not worth a word to the reader: the options still work, they
// just will not be there next time.
}
}
)
const thumbStyle = { const thumbStyle = {
right: '2px', right: '2px',
borderRadius: '5px', borderRadius: '5px',

@ -15,7 +15,9 @@
:aria-label="t(`common.actions.refresh`)" :aria-label="t(`common.actions.refresh`)"
icon="la:redo-alt" icon="la:redo-alt"
@click="refresh"> @click="refresh">
<w-tooltip anchor="center left" self="center right">{{ t(`common.actions.refresh`) }}</w-tooltip> <w-tooltip anchor="center left" self="center right">{{
t(`common.actions.refresh`)
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
push push
@ -105,7 +107,9 @@
<blueprint-icon icon="chevron-right" /> <blueprint-icon icon="chevron-right" />
<w-item-section> <w-item-section>
<w-item-label>{{ t(`admin.groups.redirectOnFirstLogin`) }}</w-item-label> <w-item-label>{{ t(`admin.groups.redirectOnFirstLogin`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.groups.redirectOnFirstLoginHint`) }}</w-item-label> <w-item-label caption>{{
t(`admin.groups.redirectOnFirstLoginHint`)
}}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-input <w-input
@ -120,7 +124,9 @@
<blueprint-icon icon="exit" /> <blueprint-icon icon="exit" />
<w-item-section> <w-item-section>
<w-item-label>{{ t(`admin.groups.redirectOnLogout`) }}</w-item-label> <w-item-label>{{ t(`admin.groups.redirectOnLogout`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.groups.redirectOnLogoutHint`) }}</w-item-label> <w-item-label caption>{{
t(`admin.groups.redirectOnLogoutHint`)
}}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-input <w-input
@ -139,7 +145,9 @@
<blueprint-icon icon="team" :hue-rotate="-45" /> <blueprint-icon icon="team" :hue-rotate="-45" />
<w-item-section> <w-item-section>
<w-item-label>{{ t(`common.field.id`) }}</w-item-label> <w-item-label>{{ t(`common.field.id`) }}</w-item-label>
<w-item-label><strong>{{state.group.id}}</strong></w-item-label> <w-item-label
><strong>{{ state.group.id }}</strong></w-item-label
>
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
@ -148,7 +156,7 @@
<w-item-section> <w-item-section>
<w-item-label>{{ t(`common.field.createdOn`) }}</w-item-label> <w-item-label>{{ t(`common.field.createdOn`) }}</w-item-label>
<w-item-label> <w-item-label>
<strong>{{humanizeDate(state.group.createdAt)}}</strong> <strong>{{ humanizeDate(state.group.createdAt) }}</strong>
</w-item-label> </w-item-label>
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -158,7 +166,7 @@
<w-item-section> <w-item-section>
<w-item-label>{{ t(`common.field.lastUpdated`) }}</w-item-label> <w-item-label>{{ t(`common.field.lastUpdated`) }}</w-item-label>
<w-item-label> <w-item-label>
<strong>{{humanizeDate(state.group.updatedAt)}}</strong> <strong>{{ humanizeDate(state.group.updatedAt) }}</strong>
</w-item-label> </w-item-label>
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -202,20 +210,26 @@
</w-toolbar> </w-toolbar>
<w-separator /> <w-separator />
<div class="p-4"> <div class="p-4">
<w-banner v-if="!state.group.rules || state.group.rules.length < 1" rounded :class="dark.isActive ? `bg-negative text-white` : `bg-grey-4 text-grey-9`">{{ t('admin.groups.rulesNone') }}</w-banner> <w-banner
v-if="!state.group.rules || state.group.rules.length < 1"
rounded
:class="dark.isActive ? `bg-negative text-white` : `bg-grey-4 text-grey-9`"
>{{ t('admin.groups.rulesNone') }}</w-banner
>
<w-card class="shadow-1 pb-2" v-else> <w-card class="shadow-1 pb-2" v-else>
<w-card-section> <w-card-section>
<div class="admin-groups-rule" v-for="rule of state.group.rules" :key="rule.id"> <div class="admin-groups-rule" v-for="rule of state.group.rules" :key="rule.id">
<div class="admin-groups-rule-icon" :class="getRuleModeColor(rule.mode)"> <div class="admin-groups-rule-icon" :class="getRuleModeColor(rule.mode)">
<w-icon <w-icon
class="cursor-pointer"
:name="getRuleModeIcon(rule.mode)" :name="getRuleModeIcon(rule.mode)"
color="white" color="white"
@click="rule.mode = getNextRuleMode(rule.mode)" /> @click="rule.mode = getNextRuleMode(rule.mode)" />
</div> </div>
<div class="admin-groups-rule-name"> <div class="admin-groups-rule-name">
<div class="admin-groups-rule-name-text"> <div class="admin-groups-rule-name-text">
<strong :class="getRuleModeColor(rule.mode)">{{ getRuleModeName(rule.mode) }}</strong> <strong :class="getRuleModeColor(rule.mode)">{{
getRuleModeName(rule.mode)
}}</strong>
</div> </div>
<w-separator class="ml-2 mr-1" vertical /> <w-separator class="ml-2 mr-1" vertical />
<input type="text" v-model="rule.name" placeholder="Rule Name" /> <input type="text" v-model="rule.name" placeholder="Rule Name" />
@ -270,7 +284,7 @@
<!-- ) {{opt.permission}} --> <!-- ) {{opt.permission}} -->
<w-item-section> <w-item-section>
<w-item-label>{{ opt.title }}</w-item-label> <w-item-label>{{ opt.title }}</w-item-label>
<w-item-label caption>{{opt.hint}}</w-item-label> <w-item-label caption>{{ opt.hint }}</w-item-label>
</w-item-section> </w-item-section>
</w-item> </w-item>
</template> </template>
@ -300,7 +314,11 @@
option-label="title" option-label="title"
multiple multiple
behavior="dialog" behavior="dialog"
:display-value="t(`admin.groups.selectedSites`, rule.sites.length, { count: rule.sites.length })"> :display-value="
t(`admin.groups.selectedSites`, rule.sites.length, {
count: rule.sites.length
})
">
<template #option="{ itemProps, itemEvents, opt, selected, toggleOption }"> <template #option="{ itemProps, itemEvents, opt, selected, toggleOption }">
<w-item v-bind="itemProps" v-on="itemEvents"> <w-item v-bind="itemProps" v-on="itemEvents">
<w-item-section> <w-item-section>
@ -331,7 +349,18 @@
option-label="name" option-label="name"
multiple multiple
behavior="dialog" behavior="dialog"
:display-value="t(`admin.groups.selectedLocales`, { n: rule.locales.length > 0 ? rule.locales[0].toUpperCase() : rule.locales.length }, rule.locales.length)"> :display-value="
t(
`admin.groups.selectedLocales`,
{
n:
rule.locales.length > 0
? rule.locales[0].toUpperCase()
: rule.locales.length
},
rule.locales.length
)
">
<template #option="{ itemProps, opt, selected, toggleOption }"> <template #option="{ itemProps, opt, selected, toggleOption }">
<w-item v-bind="itemProps"> <w-item v-bind="itemProps">
<w-item-section> <w-item-section>
@ -361,13 +390,13 @@
dense dense
:aria-label="t(`admin.groups.ruleMatch`)" :aria-label="t(`admin.groups.ruleMatch`)"
:options="[ :options="[
{ label: t('admin.groups.ruleMatchStart'), value: 'START' }, { label: t('admin.groups.ruleMatchStart'), value: 'START' },
{ label: t('admin.groups.ruleMatchEnd'), value: 'END' }, { label: t('admin.groups.ruleMatchEnd'), value: 'END' },
{ label: t('admin.groups.ruleMatchRegex'), value: 'REGEX' }, { label: t('admin.groups.ruleMatchRegex'), value: 'REGEX' },
{ label: t('admin.groups.ruleMatchTag'), value: 'TAG' }, { label: t('admin.groups.ruleMatchTag'), value: 'TAG' },
{ label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' }, { label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' },
{ label: t('admin.groups.ruleMatchExact'), value: 'EXACT' } { label: t('admin.groups.ruleMatchExact'), value: 'EXACT' }
]" /> ]" />
<w-input <w-input
class="mt-2" class="mt-2"
standout standout
@ -407,7 +436,7 @@
</w-card-header> </w-card-header>
<template v-for="(perm, idx) of permissions" :key="perm.permission"> <template v-for="(perm, idx) of permissions" :key="perm.permission">
<w-item tag="label"> <w-item tag="label">
<w-item-section class="items-center" style="flex: 0 0 40px;"> <w-item-section class="items-center" style="flex: 0 0 40px">
<w-icon name="la:comments" color="primary" size="sm" /> <w-icon name="la:comments" color="primary" size="sm" />
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
@ -470,7 +499,12 @@
</w-toolbar> </w-toolbar>
<w-separator /> <w-separator />
<div class="p-4"> <div class="p-4">
<w-banner v-if="!state.users || state.users.length < 1" rounded :class="dark.isActive ? `bg-negative text-white` : `bg-grey-4 text-grey-9`">{{ t('admin.groups.usersNone') }}</w-banner> <w-banner
v-if="!state.users || state.users.length < 1"
rounded
:class="dark.isActive ? `bg-negative text-white` : `bg-grey-4 text-grey-9`"
>{{ t('admin.groups.usersNone') }}</w-banner
>
<w-card class="shadow-1"> <w-card class="shadow-1">
<w-table <w-table
:rows="state.users" :rows="state.users"
@ -486,26 +520,22 @@
<w-td :props="props"> <w-td :props="props">
<div class="flex items-center"> <div class="flex items-center">
<strong>{{ props.value }}</strong> <strong>{{ props.value }}</strong>
<w-icon <w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" />
class="ml-2" <w-icon class="ml-2" v-if="!props.row.isActive" name="la:ban" color="pink" />
v-if="props.row.isSystem"
name="la:lock"
color="pink" />
<w-icon
class="ml-2"
v-if="!props.row.isActive"
name="la:ban"
color="pink" />
</div> </div>
</w-td> </w-td>
</template> </template>
<template #body-cell-email="props"> <template #body-cell-email="props">
<w-td :props="props"><em>{{ props.value }}</em></w-td> <w-td :props="props"
><em>{{ props.value }}</em></w-td
>
</template> </template>
<template #body-cell-date="props"> <template #body-cell-date="props">
<w-td :props="props"> <w-td :props="props">
<i18n-t class="text-caption" keypath="admin.users.createdAt" tag="div"> <i18n-t class="text-caption" keypath="admin.users.createdAt" tag="div">
<template #date><strong>{{ humanizeDate(props.value) }}</strong></template> <template #date
><strong>{{ humanizeDate(props.value) }}</strong></template
>
</i18n-t> </i18n-t>
<i18n-t <i18n-t
class="text-caption" class="text-caption"
@ -539,7 +569,9 @@
color="accent" color="accent"
:aria-label="t(`admin.groups.unassignUser`)" :aria-label="t(`admin.groups.unassignUser`)"
@click="unassignUser(props.row)"> @click="unassignUser(props.row)">
<w-tooltip anchor="center left" self="center right">{{ t('admin.groups.unassignUser') }}</w-tooltip> <w-tooltip anchor="center left" self="center right">{{
t('admin.groups.unassignUser')
}}</w-tooltip>
</w-btn> </w-btn>
</w-td> </w-td>
</template> </template>
@ -1225,18 +1257,24 @@ onMounted(() => {
display: block; display: block;
} }
/*
Sized and placed to the disc `::before` draws, with the glyph inset by the padding: an inline
<svg> scales its viewBox to whatever box it is given, so the old `width: 100%; height: 38px`
-- metrics for the icon FONT this replaced, where `font-size` did the sizing -- stretched the
mark across the whole circle.
The box stays the full 31px even though the glyph is 15px, so the click target is the disc a
reader is aiming at rather than the mark inside it.
*/
.w-icon { .w-icon {
position: absolute; position: absolute;
top: 0; top: 4px;
left: 0; left: 0;
right: 0; box-sizing: border-box;
font-size: 16px; width: 31px;
height: 38px; height: 31px;
line-height: 38px; padding: 8px;
width: 100%; cursor: pointer;
align-items: center;
justify-content: center;
display: flex;
} }
} }
@ -1244,6 +1282,13 @@ onMounted(() => {
line-height: 12px; line-height: 12px;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
/*
On the text baseline, not stretched. An <input> stretched to the row's height centres its text
inside that height, while the mode name beside it sits at the top of its own box -- so the two
read as a few pixels apart even though both are 12px type. The separator between them is
unaffected: it carries its own `self-stretch`, which outranks this.
*/
align-items: baseline;
padding-top: 4px; padding-top: 4px;
&-text { &-text {

@ -22,6 +22,77 @@
@click="togglePageProperties"> @click="togglePageProperties">
<w-tooltip anchor="center left" self="center right">Page Properties</w-tooltip> <w-tooltip anchor="center left" self="center right">Page Properties</w-tooltip>
</w-btn> </w-btn>
</template>
<!--
Between the two halves of the authoring group on purpose: it sits under the Edit button for
somebody who has one, and at the top of the rail for a reviewer who may not edit at all -- which
is why it is outside that group rather than in it.
Only for whoever reviews this page: the server answers `canReview` from the approval rules and
the reviewer's own permissions, so nothing here has to know how that is decided.
-->
<w-btn
class="h-12"
v-if="state.canReview"
flat
:color="editorStore.isActive ? `white` : `deep-orange-9`"
aria-label="Pending Edit Suggestions">
<!--
The badge is a sibling of the icon, not a child of it: WIcon renders a bare `<svg>` and no
slot, so anything written inside it is dropped -- and an HTML badge could not live inside an
SVG in any case. It floats against the button, which is the positioned box here.
-->
<w-icon name="la:inbox" />
<!--
The same expression as the button's own colour, so the badge cannot drift from the icon it
sits on: `deep-orange-9` on the resting rail, and inverted in the editor, where the rail is
already that orange and an orange badge would disappear into it.
-->
<w-badge
v-if="pendingCount > 0"
:color="editorStore.isActive ? `white` : `deep-orange-9`"
:text-color="editorStore.isActive ? `deep-orange-9` : `white`"
rounded
floating>
<strong>{{ pendingCount }}</strong>
</w-badge>
<w-tooltip anchor="center left" self="center right">
{{ t('inbox.pendingReview') }}
</w-tooltip>
<w-menu
class="translucent-menu"
anchor="top left"
self="top right"
auto-close
transition-show="jump-left">
<w-list padding style="min-width: 320px">
<w-item v-if="pendingCount < 1">
<w-item-section>
<w-item-label caption>{{ t('inbox.reviewNone') }}</w-item-label>
</w-item-section>
</w-item>
<w-item
v-for="submission of state.submissions"
:key="submission.id"
clickable
@click="reviewSubmission(submission)">
<w-item-section class="items-center" avatar>
<w-icon class="text-deep-orange-9" name="la:file-alt" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>
{{ submission.author.name || t('inbox.reviewUnknownAuthor') }}
</w-item-label>
<w-item-label caption>{{ humanizeDate(submission.createdAt) }}</w-item-label>
</w-item-section>
<w-item-section side v-if="submission.isStale">
<w-badge color="warning" rounded>{{ t('inbox.reviewStale') }}</w-badge>
</w-item-section>
</w-item>
</w-list>
</w-menu>
</w-btn>
<template v-if="userStore.can(`edit:pages`)">
<w-btn <w-btn
class="h-12" class="h-12"
v-if="flagsStore.experimental" v-if="flagsStore.experimental"
@ -40,17 +111,17 @@
color="white" color="white"
:text-color="hasPendingAssets ? `white` : `deep-orange-3`" :text-color="hasPendingAssets ? `white` : `deep-orange-3`"
aria-label="Pending Asset Uploads"> aria-label="Pending Asset Uploads">
<w-icon name="mdi:image-sync-outline"> <!-- Outside the icon for the same reason as the review badge above -->
<w-badge <w-icon name="mdi:image-sync-outline" />
class="page-actions-pending-badge" <w-badge
v-if="hasPendingAssets" class="page-actions-pending-badge"
color="white" v-if="hasPendingAssets"
text-color="orange-9" color="white"
rounded text-color="orange-9"
floating> rounded
<strong>{{ editorStore.pendingAssets.length * 1 }}</strong> floating>
</w-badge> <strong>{{ editorStore.pendingAssets.length * 1 }}</strong>
</w-icon> </w-badge>
<w-tooltip anchor="center left" self="center right">Pending Asset Uploads</w-tooltip> <w-tooltip anchor="center left" self="center right">Pending Asset Uploads</w-tooltip>
<w-menu ref="menuPendingAssets" anchor="top left" self="top right" :offset="[10, 0]"> <w-menu ref="menuPendingAssets" anchor="top left" self="top right" :offset="[10, 0]">
<w-card style="width: 450px"> <w-card style="width: 450px">
@ -88,8 +159,13 @@
</w-btn> </w-btn>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
</template> </template>
<!--
`read:history` is the permission that exists to say who may see what a page used to contain, so
the button follows it rather than page read access. The API asks the same question.
-->
<w-btn <w-btn
class="h-12" class="h-12"
v-if="userStore.can(`read:history`)"
flat flat
icon="la:history" icon="la:history"
:color="editorStore.isActive ? `white` : `grey`" :color="editorStore.isActive ? `white` : `grey`"
@ -115,6 +191,11 @@
:color="editorStore.isActive ? `deep-orange-2` : `grey`" :color="editorStore.isActive ? `deep-orange-2` : `grey`"
aria-label="Page Actions"> aria-label="Page Actions">
<w-tooltip anchor="center left" self="center right">Page Actions</w-tooltip> <w-tooltip anchor="center left" self="center right">Page Actions</w-tooltip>
<!--
Literal colour classes, not WIcon's `color` prop: that builds `text-<name>` at runtime and
Tailwind only emits a utility it can see spelled out, so these three icons had been drawing
in the inherited text colour rather than the rail's orange.
-->
<w-menu <w-menu
class="translucent-menu" class="translucent-menu"
anchor="top left" anchor="top left"
@ -124,19 +205,19 @@
<w-list padding style="min-width: 225px"> <w-list padding style="min-width: 225px">
<w-item clickable disabled v-if="userStore.can(`manage:pages`)"> <w-item clickable disabled v-if="userStore.can(`manage:pages`)">
<w-item-section class="items-center" avatar> <w-item-section class="items-center" avatar>
<w-icon color="deep-orange-9" name="la:atom" size="sm" /> <w-icon class="text-deep-orange-9" name="la:atom" size="sm" />
</w-item-section> </w-item-section>
<w-item-section><w-item-label>Convert Page</w-item-label></w-item-section> <w-item-section><w-item-label>Convert Page</w-item-label></w-item-section>
</w-item> </w-item>
<w-item clickable v-if="userStore.can(`edit:pages`)" @click="rerenderPage"> <w-item clickable v-if="userStore.can(`edit:pages`)" @click="rerenderPage">
<w-item-section class="items-center" avatar> <w-item-section class="items-center" avatar>
<w-icon color="deep-orange-9" name="la:magic" size="sm" /> <w-icon class="text-deep-orange-9" name="la:magic" size="sm" />
</w-item-section> </w-item-section>
<w-item-section><w-item-label>Rerender Page</w-item-label></w-item-section> <w-item-section><w-item-label>Rerender Page</w-item-label></w-item-section>
</w-item> </w-item>
<w-item clickable disabled> <w-item clickable disabled>
<w-item-section class="items-center" avatar> <w-item-section class="items-center" avatar>
<w-icon color="deep-orange-9" name="la:sun" size="sm" /> <w-icon class="text-deep-orange-9" name="la:sun" size="sm" />
</w-item-section> </w-item-section>
<w-item-section><w-item-label>View Backlinks</w-item-label></w-item-section> <w-item-section><w-item-label>View Backlinks</w-item-label></w-item-section>
</w-item> </w-item>
@ -226,12 +307,72 @@ const { t } = useI18n()
const menuPendingAssets = ref(null) const menuPendingAssets = ref(null)
// DATA
const state = reactive({
/** Whether this user reviews this page at all, which is what shows the button. */
canReview: false,
/** What is waiting on it, oldest first. */
submissions: []
})
// COMPUTED // COMPUTED
const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0) const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0)
const pendingCount = computed(() => state.submissions.length)
// WATCHERS
// -> Per page, so navigating between pages asks again rather than carrying the last one's answer
watch(() => pageStore.id, loadSubmissions)
// MOUNTED
onMounted(loadSubmissions)
// METHODS // METHODS
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
/**
* What is waiting on this page, if this user is one of its reviewers.
*
* Quietly on failure: the rail is not where a reader finds out that a request went wrong, and a
* button that does not appear is the same outcome as not being a reviewer.
*/
async function loadSubmissions() {
state.canReview = false
state.submissions = []
if (!pageStore.id || !userStore.authenticated) {
return
}
try {
const resp = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${pageStore.id}/submissions`
).json()
state.canReview = resp?.canReview === true
state.submissions = resp?.submissions ?? []
} catch (err) {
console.warn(err)
}
}
/**
* Open one for review, remembering where it was opened from.
*
* `from=page` is what sends the reviewer back here when they are done rather than to the inbox queue
* they never came through.
*/
function reviewSubmission(submission) {
router.push({ path: `/_inbox/review/${submission.id}`, query: { from: 'page' } })
}
function togglePageProperties() { function togglePageProperties() {
siteStore.$patch({ siteStore.$patch({
sideDialogComponent: 'PagePropertiesDialog', sideDialogComponent: 'PagePropertiesDialog',

@ -11,7 +11,7 @@
<span class="page-history-page">{{ pageStore.title }}</span> <span class="page-history-page">{{ pageStore.title }}</span>
<w-space /> <w-space />
<transition name="syncing"> <transition name="syncing">
<w-spinner class="mr-2" v-show="state.loading > 0" color="accent" size="24px" /> <w-spinner class="mr-4" v-show="state.loading > 0" color="accent" size="20px" />
</transition> </transition>
<!-- <!--
How the two versions are laid against each other. Up here rather than over the diff, so the How the two versions are laid against each other. Up here rather than over the diff, so the

@ -5,7 +5,7 @@
<span>{{ t('pageSource.title') }}</span> <span>{{ t('pageSource.title') }}</span>
<w-space /> <w-space />
<transition name="syncing"> <transition name="syncing">
<w-spinner class="mr-2" v-show="state.loading > 0" color="accent" size="24px" /> <w-spinner class="mr-4" v-show="state.loading > 0" color="accent" size="20px" />
</transition> </transition>
<w-btn <w-btn
class="mr-4" class="mr-4"

@ -1,6 +1,10 @@
<template> <template>
<div class="gap-1"> <div>
<template v-if="pageStore.tags && pageStore.tags.length > 0"> <!--
The gap was on the outer element, which is a plain block and has nothing to space: the chips ran
into each other. Its own wrapping flex row, so the field below still starts on a line of its own.
-->
<div class="flex flex-wrap items-center gap-1" v-if="pageStore.tags?.length > 0">
<w-chip <w-chip
square square
color="secondary" color="secondary"
@ -8,13 +12,14 @@
dense dense
:clickable="!props.edit" :clickable="!props.edit"
:removable="props.edit" :removable="props.edit"
@click="searchTag(tag)"
@remove="removeTag(tag)" @remove="removeTag(tag)"
v-for="tag of pageStore.tags" v-for="tag of pageStore.tags"
:key="`tag-` + tag"> :key="`tag-` + tag">
<w-icon class="mr-1" name="la:hashtag" size="14px" /> <w-icon class="mr-1" name="la:hashtag" size="14px" />
<span class="text-caption">{{tag}}</span> <span class="text-caption">{{ tag }}</span>
</w-chip> </w-chip>
</template> </div>
<!-- <!--
Entry only: no `use-chips`, because the selection is already shown as the chips above and having Entry only: no `use-chips`, because the selection is already shown as the chips above and having
it in the field as well said the same thing twice. `create` is what lets a tag that does not it in the field as well said the same thing twice. `create` is what lets a tag that does not
@ -42,6 +47,7 @@
<script setup> <script setup>
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
@ -58,6 +64,9 @@ const props = defineProps({
} }
}) })
// ROUTER
const router = useRouter()
// STORES // STORES
@ -145,6 +154,20 @@ function createTag(val) {
pageStore.tags = nextSelection pageStore.tags = nextSelection
} }
/**
* Search the site for everything carrying this tag.
*
* As a `#tag` token in the query rather than as a parameter of its own, because that is how the search
* page states a tag filter: it reads them back out of `q` to fill its own tag selector, so arriving
* this way leaves the reader on a search they can widen or narrow from there.
*
* Only reachable in view mode -- WChip emits `click` only while `clickable`, which the editing chips
* are not, their control being the remove button instead.
*/
function searchTag(tag) {
router.push({ path: '/_search', query: { q: `#${tag}` } })
}
function removeTag(tag) { function removeTag(tag) {
pageStore.tags = pageStore.tags.filter((t) => t !== tag) pageStore.tags = pageStore.tags.filter((t) => t !== tag)
} }

@ -9,8 +9,13 @@
<div class="text-body2">{{ t('common.page.suggestIdentifyHint') }}</div> <div class="text-body2">{{ t('common.page.suggestIdentifyHint') }}</div>
</w-card-section> </w-card-section>
<w-form ref="guestForm" class="py-2" @submit="submit"> <w-form ref="guestForm" class="py-2" @submit="submit">
<!--
No `self-start` on these icons. A field's control carries a symmetric `my-2` -- room for the
floated label, matched underneath precisely so the box stays centred on the control -- so
letting both sections centre in the row is what lines the icon up with the field.
-->
<w-item> <w-item>
<blueprint-icon icon="contact" class="self-start" /> <blueprint-icon icon="contact" />
<w-item-section> <w-item-section>
<w-input <w-input
ref="iptName" ref="iptName"
@ -26,7 +31,7 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item> <w-item>
<blueprint-icon icon="envelope" class="self-start" /> <blueprint-icon icon="envelope" />
<w-item-section> <w-item-section>
<w-input <w-input
v-model="state.email" v-model="state.email"

@ -82,7 +82,7 @@
The selection as chips rather than a comma-joined string. Each carries its own remove The selection as chips rather than a comma-joined string. Each carries its own remove
affordance, so a value can be dropped without reopening the list. affordance, so a value can be dropped without reopening the list.
--> -->
<span v-if="useChips && hasSelection" class="flex min-w-0 flex-wrap items-center gap-1"> <span v-if="showsChips" class="flex min-w-0 flex-wrap items-center gap-1">
<w-chip <w-chip
v-for="(v, i) of selectedValues" v-for="(v, i) of selectedValues"
:key="i" :key="i"
@ -115,7 +115,7 @@
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined" :aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:disabled="isDisabled" :disabled="isDisabled"
:readonly="readonly" :readonly="readonly"
:placeholder="useChips && hasSelection ? '' : placeholder" :placeholder="showsChips ? '' : placeholder"
class="w-unstyled min-w-8 flex-1 bg-transparent pt-0.5 outline-none placeholder:text-black/40 dark:placeholder:text-white/40" class="w-unstyled min-w-8 flex-1 bg-transparent pt-0.5 outline-none placeholder:text-black/40 dark:placeholder:text-white/40"
@focus="readonly || open(0)" @focus="readonly || open(0)"
@keydown="onKeydown" /> @keydown="onKeydown" />
@ -126,8 +126,12 @@
<!-- <!--
`selected` lets a caller summarise the selection instead of listing it -- e.g. "3 groups `selected` lets a caller summarise the selection instead of listing it -- e.g. "3 groups
selected" rather than three comma-joined names. selected" rather than three comma-joined names.
Empty once the chips above are drawing the selection: the comma-joined text is what chips
REPLACE, and rendering both said the same thing twice, side by side. The element stays for
the layout -- it is what holds the row open and pushes the dropdown arrow to the end.
--> -->
<slot name="selected">{{ displayText }}</slot> <slot name="selected">{{ showsChips ? '' : displayText }}</slot>
</span> </span>
<w-spinner v-if="loading" size="1em" class="shrink-0" /> <w-spinner v-if="loading" size="1em" class="shrink-0" />
<w-icon <w-icon
@ -474,6 +478,9 @@ const selectedValues = computed(() => {
const hasSelection = computed(() => selectedValues.value.length > 0) const hasSelection = computed(() => selectedValues.value.length > 0)
/** Whether the selection is being drawn as chips, which is a different thing from being able to. */
const showsChips = computed(() => props.useChips && hasSelection.value)
const displayText = computed(() => { const displayText = computed(() => {
if (props.displayValue !== null) { if (props.displayValue !== null) {
return props.displayValue return props.displayValue

@ -183,19 +183,6 @@
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="pen" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowContributions`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowContributionsHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.contributions"
:aria-label="t(`admin.general.allowContributions`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label"> <w-item tag="label">
<blueprint-icon icon="administrator-male" /> <blueprint-icon icon="administrator-male" />
<w-item-section> <w-item-section>
@ -588,7 +575,6 @@ function defaultConfig() {
ratings: false, ratings: false,
ratingsMode: 'off', ratingsMode: 'off',
comments: false, comments: false,
contributions: false,
reasonForChange: 'required', reasonForChange: 'required',
profile: false profile: false
}, },
@ -708,7 +694,6 @@ async function save() {
browse: state.config.features?.browse ?? false, browse: state.config.features?.browse ?? false,
comments: state.config.features?.comments ?? false, comments: state.config.features?.comments ?? false,
ratingsMode: state.config.features?.ratingsMode ?? 'off', ratingsMode: state.config.features?.ratingsMode ?? 'off',
contributions: state.config.features?.contributions ?? false,
profile: state.config.features?.profile ?? false, profile: state.config.features?.profile ?? false,
reasonForChange: state.config.features?.reasonForChange ?? 'required', reasonForChange: state.config.features?.reasonForChange ?? 'required',
search: state.config.features?.search ?? false search: state.config.features?.search ?? false

@ -2,9 +2,9 @@
<div class="errorpage"> <div class="errorpage">
<div class="errorpage-bg" /> <div class="errorpage-bg" />
<div class="errorpage-content"> <div class="errorpage-content">
<div class="errorpage-code">{{error.code}}</div> <div class="errorpage-code">{{ error.code }}</div>
<div class="errorpage-title">{{error.title}}</div> <div class="errorpage-title">{{ error.title }}</div>
<div class="errorpage-hint">{{error.hint}}</div> <div class="errorpage-hint">{{ error.hint }}</div>
<div class="errorpage-actions"> <div class="errorpage-actions">
<w-btn <w-btn
v-if="error.showHomeBtn" v-if="error.showHomeBtn"
@ -28,11 +28,14 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
const actions = { const actions = {
unauthorized: { unauthorized: {
code: 403, code: 403,
@ -53,6 +56,12 @@ const actions = {
// ROUTER // ROUTER
const route = useRoute() const route = useRoute()
const router = useRouter()
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N // I18N
@ -64,6 +73,26 @@ useMeta({
title: t('common.error.title') title: t('common.error.title')
}) })
// MOUNTED
/*
A site can choose to skip this screen entirely for a visitor who is not logged in: with
`bypassUnauthorized` on, being refused a page sends them to sign in rather than to a page whose only
purpose is to offer them a login button.
Only when nobody is logged in. Somebody who IS signed in and still refused has nothing to gain from
the login screen, and sending them there would bounce them straight back.
*/
onMounted(() => {
if (
route.params.action === 'unauthorized' &&
siteStore.auth.bypassUnauthorized &&
!userStore.authenticated
) {
router.replace('/login')
}
})
// COMPUTED // COMPUTED
const error = computed(() => { const error = computed(() => {

@ -143,6 +143,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as monaco from 'monaco-editor' import * as monaco from 'monaco-editor'
@ -160,6 +161,11 @@ import { useSiteStore } from '@/stores/site'
const dark = useDark() const dark = useDark()
// ROUTER
const route = useRoute()
const router = useRouter()
// STORES // STORES
const editorStore = useEditorStore() const editorStore = useEditorStore()
@ -177,6 +183,9 @@ useMeta({
// DATA // DATA
/** The queue's own address, which is also this screen with nothing open. */
const REVIEW_PATH = '/_inbox/review'
const state = reactive({ const state = reactive({
loading: 0, loading: 0,
submissions: [], submissions: [],
@ -198,6 +207,10 @@ let modifiedModel = null
// WATCHERS // WATCHERS
// -> The URL says which submission is open, so everything follows from it -- including arriving on
// one directly, which is what a link in a notification will do
watch(() => route.params.submissionId, loadSubmission)
// -> The container only exists once a submission is open, so the editor is built after that render // -> The container only exists once a submission is open, so the editor is built after that render
watch( watch(
() => state.selected?.id, () => state.selected?.id,
@ -250,11 +263,21 @@ async function load() {
state.loading-- state.loading--
} }
async function openSubmission(submission) { /**
* The submission the URL names, or none.
*
* Driven by the route rather than by the click that got here, so that a link straight to a review
* behaves exactly like picking it off the queue -- and so the back button walks out of one.
*/
async function loadSubmission(id) {
if (!id) {
state.selected = null
return
}
state.loading++ state.loading++
try { try {
state.selected = await API_CLIENT.get( state.selected = await API_CLIENT.get(
`sites/${siteStore.id}/approvals/submissions/${submission.id}` `sites/${siteStore.id}/approvals/submissions/${id}`
).json() ).json()
} catch (err) { } catch (err) {
notify({ notify({
@ -262,12 +285,36 @@ async function openSubmission(submission) {
message: t('inbox.reviewLoadFailed'), message: t('inbox.reviewLoadFailed'),
caption: await apiMessage(err) caption: await apiMessage(err)
}) })
/*
Reviewed by somebody else already, or never this reviewer's to see. Back to the queue, and with
`replace` so the dead address does not sit in the history for the back button to return to.
*/
state.selected = null
router.replace(REVIEW_PATH)
} }
state.loading-- state.loading--
} }
function openSubmission(submission) {
router.push(`${REVIEW_PATH}/${submission.id}`)
}
/**
* Where leaving a review goes back to.
*
* The queue, unless the reviewer never came through it: `from=page` is set by the review button on a
* page view, and returning them to an inbox they did not open would strand them a section away from
* what they were reading.
*/
function backTarget() {
if (route.query.from === 'page' && state.selected?.page?.path !== undefined) {
return `/${state.selected.page.path}`
}
return REVIEW_PATH
}
function closeSubmission() { function closeSubmission() {
state.selected = null router.push(backTarget())
} }
/** /**
@ -363,8 +410,11 @@ function approveSubmission() {
type: 'positive', type: 'positive',
message: t('inbox.reviewApproveSuccess') message: t('inbox.reviewApproveSuccess')
}) })
closeSubmission() const target = backTarget()
// -> Refreshed before leaving, so the queue behind is right whether or not that is where this
// goes; on the way to a page the reload is what the page's own review button will read
await load() await load()
router.push(target)
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
@ -396,8 +446,9 @@ function rejectSubmission() {
type: 'positive', type: 'positive',
message: t('inbox.reviewDeclineSuccess') message: t('inbox.reviewDeclineSuccess')
}) })
closeSubmission() const target = backTarget()
await load() await load()
router.push(target)
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
@ -411,7 +462,11 @@ function rejectSubmission() {
// MOUNTED // MOUNTED
onMounted(load) onMounted(() => {
load()
// -> Whatever the address arrived pointing at, which is nothing at all for the queue itself
loadSubmission(route.params.submissionId)
})
onBeforeUnmount(disposeEditor) onBeforeUnmount(disposeEditor)
</script> </script>

@ -514,6 +514,10 @@ watch(
message: 'This page does not exist (yet)!' message: 'This page does not exist (yet)!'
}) })
} }
} else if (err.message === 'ERR_PAGE_UNAUTHORIZED') {
// -> `replace`, so the back button leaves the wiki the way it came rather than bouncing off
// the same refusal again
router.replace('/_error/unauthorized')
} else { } else {
notify({ notify({
type: 'negative', type: 'negative',

@ -172,14 +172,21 @@
<w-item-section> <w-item-section>
<w-item-label>{{ item.title }}</w-item-label> <w-item-label>{{ item.title }}</w-item-label>
<w-item-label v-if="item.description" caption>{{ item.description }}</w-item-label> <w-item-label v-if="item.description" caption>{{ item.description }}</w-item-label>
<w-item-label class="text-grey" caption>/{{ item.path }}</w-item-label>
<w-item-label class="text-highlight" v-if="item.highlight" caption> <w-item-label class="text-highlight" v-if="item.highlight" caption>
<span v-html="item.highlight" /> <span v-html="item.highlight" />
</w-item-label> </w-item-label>
</w-item-section> </w-item-section>
<w-item-section side> <w-item-section side>
<div class="flex layout-search-itemtags"> <div class="text-caption text-right">{{ humanizeDate(item.updatedAt) }}</div>
<!--
`layout-search-itemtags` was a class nothing defines -- a leftover the layout
migration left behind -- so the row had no gap and the chips ran together.
-->
<div class="mt-1 flex flex-wrap items-center justify-end gap-1">
<w-chip <w-chip
v-for="tag of item.tags" v-for="tag of item.tags"
:key="`tag-` + tag"
square square
color="secondary" color="secondary"
text-color="white" text-color="white"
@ -188,10 +195,6 @@
>{{ tag }}</w-chip >{{ tag }}</w-chip
> >
</div> </div>
<div class="flex">
<div class="text-caption mr-2 text-grey">/{{ item.path }}</div>
<div class="text-caption">{{ humanizeDate(item.updatedAt) }}</div>
</div>
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-list> </w-list>

@ -37,7 +37,12 @@ const routes = [
{ path: '', redirect: '/_inbox/messages' }, { path: '', redirect: '/_inbox/messages' },
{ path: 'messages', component: () => import('@/pages/InboxMessages.vue') }, { path: 'messages', component: () => import('@/pages/InboxMessages.vue') },
{ path: 'watching', component: () => import('@/pages/InboxWatching.vue') }, { path: 'watching', component: () => import('@/pages/InboxWatching.vue') },
{ path: 'review', component: () => import('@/pages/InboxReview.vue') } /*
The submission being reviewed is in the URL, so a review can be linked to -- which is what a
notification about one will have to do. Optional, since the same screen without it is the
queue.
*/
{ path: 'review/:submissionId?', component: () => import('@/pages/InboxReview.vue') }
] ]
}, },
{ {

@ -165,6 +165,14 @@ export const usePageStore = defineStore('page', {
if (err.response?.status === 404) { if (err.response?.status === 404) {
throw new Error('ERR_PAGE_NOT_FOUND') throw new Error('ERR_PAGE_NOT_FOUND')
} }
/*
Nor is a page the reader may not open: the group rules say so deliberately, and the reader
is owed the unauthorized screen -- which offers signing in as somebody else -- rather than
an error banner over an empty page view.
*/
if (err.response?.status === 403) {
throw new Error('ERR_PAGE_UNAUTHORIZED')
}
console.warn(err) console.warn(err)
throw err throw err
} }

@ -67,6 +67,15 @@ export const useSiteStore = defineStore('site', {
reasonForChange: 'required', reasonForChange: 'required',
search: false search: false
}, },
/** How this site handles signing in. Set in the admin area's Login section. */
auth: {
/**
* Send a visitor who is not logged in straight to the login screen instead of showing them
* the unauthorized page. For a wiki that is closed to the public, that screen is a dead end
* with a login button on it, and this skips the step.
*/
bypassUnauthorized: false
},
editors: { editors: {
asciidoc: false, asciidoc: false,
markdown: false, markdown: false,
@ -165,6 +174,10 @@ export const useSiteStore = defineStore('site', {
...this.features, ...this.features,
...siteInfo.features ...siteInfo.features
}, },
auth: {
...this.auth,
...siteInfo.auth
},
editors: { editors: {
asciidoc: siteInfo.editors.asciidoc.isActive, asciidoc: siteInfo.editors.asciidoc.isActive,
markdown: siteInfo.editors.markdown.isActive, markdown: siteInfo.editors.markdown.isActive,

Loading…
Cancel
Save