fix: page rules with tag matching + various fixes

scarlett
NGPixel 2 days ago
parent 007e8e9745
commit 70331ecd63
No known key found for this signature in database

@ -54,7 +54,10 @@ async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId:
* guests group among its reviewers, or a page rule granting them `review:pages`, would otherwise hand * guests group among its reviewers, or a page rule granting them `review:pages`, would otherwise hand
* the queue to the public. An empty scope reviews nothing, whatever the rules say. * the queue to the public. An empty scope reviews nothing, whatever the rules say.
*/ */
function reviewerFor(req: FastifyRequest, page?: { path: string; tags?: string[] }): ReviewerScope { function reviewerFor(
req: FastifyRequest<{ Params: { siteId: string } }>,
page?: { path: string; tags?: string[] }
): ReviewerScope {
if (!isReviewerSession(req)) { if (!isReviewerSession(req)) {
return { groupIds: [], reviewsAll: false } return { groupIds: [], reviewsAll: false }
} }
@ -63,7 +66,13 @@ function reviewerFor(req: FastifyRequest, page?: { path: string; tags?: string[]
groupIds: WIKI.models.approvals.getActorGroupIds(req), groupIds: WIKI.models.approvals.getActorGroupIds(req),
reviewsAll: reviewsAll:
actor.permissions.includes('manage:system') || actor.permissions.includes('manage:system') ||
WIKI.models.groups.checkAccess(actor, 'review:pages', page ?? { path: '' }) // -> The site root stands in for "the queue spanning every page", as above. The site itself is
// never stood in for: a rule limited to other sites has nothing to say about this queue
WIKI.models.groups.checkAccess(actor, 'review:pages', {
siteId: req.params.siteId,
path: page?.path ?? '',
tags: page?.tags
})
} }
} }

@ -34,14 +34,18 @@ const assetIdParam = {
* one no locale restriction applies to so an omitted locale silently widens every such rule. Every * one no locale restriction applies to so an omitted locale silently widens every such rule. Every
* asset the API hands around carries one, and the two places that build a destination by hand say * asset the API hands around carries one, and the two places that build a destination by hand say
* which locale they mean. * which locale they mean.
*
* The SITE comes off the request instead, since every route in this file addresses one in its path
* and an asset row does not have to be asked which site it is in to answer that.
*/ */
function mayOnAsset( function mayOnAsset(
req: FastifyRequest, req: FastifyRequest<{ Params: { siteId: string } }>,
permission: string, permission: string,
asset: { folderPath?: string | null; fileName: string; locale: string } asset: { folderPath?: string | null; fileName: string; locale: string }
): boolean { ): boolean {
const folder = asset.folderPath ?? '' const folder = asset.folderPath ?? ''
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
siteId: req.params.siteId,
path: folder ? `${folder}/${asset.fileName}` : asset.fileName, path: folder ? `${folder}/${asset.fileName}` : asset.fileName,
locale: asset.locale locale: asset.locale
}) })

@ -4,6 +4,7 @@ import { mayOnPage } from './pages.ts'
import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts' import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { CommentsProviderInput } from '../models/comments.ts' import type { CommentsProviderInput } from '../models/comments.ts'
import type { RulePageRef } from '../helpers/pageRules.ts'
const siteIdParam = { const siteIdParam = {
type: 'object', type: 'object',
@ -562,7 +563,9 @@ async function requireBuiltInPage(
reply.notFound('This page does not exist.') reply.notFound('This page does not exist.')
return null return null
} }
return page // -> Carrying the site, since everything below asks a page rule about this page and a rule may be
// limited to particular sites
return { ...page, siteId: req.params.siteId }
} }
/** /**
@ -587,7 +590,12 @@ async function requireWritableComment(
reply.notFound('This comment does not exist.') reply.notFound('This comment does not exist.')
return null return null
} }
const page = { path: comment.path, locale: comment.locale, tags: comment.tags ?? [] } const page = {
siteId: req.params.siteId,
path: comment.path,
locale: comment.locale,
tags: comment.tags ?? []
}
if (mayOnPage(req, 'manage:comments', page)) { if (mayOnPage(req, 'manage:comments', page)) {
return comment return comment
} }
@ -615,7 +623,7 @@ async function requireWritableComment(
*/ */
async function consumeCooldown( async function consumeCooldown(
req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>, req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>,
page: { path: string; locale: string; tags: string[] } page: RulePageRef
): Promise<number> { ): Promise<number> {
const seconds = WIKI.models.comments.cooldownFor(req.params.siteId) const seconds = WIKI.models.comments.cooldownFor(req.params.siteId)
if (seconds < 1 || mayOnPage(req, 'manage:comments', page)) { if (seconds < 1 || mayOnPage(req, 'manage:comments', page)) {

@ -1,6 +1,7 @@
import { validate as uuidValidate } from 'uuid' import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts' import type { PageActor, PageInput } from '../models/pages.ts'
import type { RulePageRef } from '../helpers/pageRules.ts'
import { import {
SEARCH_ORDER_BY, SEARCH_ORDER_BY,
SEARCH_TAGS_MATCH, SEARCH_TAGS_MATCH,
@ -119,11 +120,7 @@ export function unlockedFor(req: FastifyRequest, pageId: string): boolean {
* a different question from the one the route-level `config.permissions` hook answers and the only * 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. * correct one for anything page-scoped. `helpers/pageRules.ts` sets out how a rule is chosen.
*/ */
export function mayOnPage( export function mayOnPage(req: FastifyRequest, permission: string, page: RulePageRef): boolean {
req: FastifyRequest,
permission: string,
page: { path: string; locale?: string; tags?: string[] }
): boolean {
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page) return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page)
} }
@ -137,10 +134,7 @@ export function mayOnPage(
*/ */
const SOURCE_PERMISSIONS = ['read:source', 'write:pages', 'manage:pages'] const SOURCE_PERMISSIONS = ['read:source', 'write:pages', 'manage:pages']
export function mayReadSource( export function mayReadSource(req: FastifyRequest, page: RulePageRef): boolean {
req: FastifyRequest,
page: { path: string; locale?: string; tags?: string[] }
): boolean {
return SOURCE_PERMISSIONS.some((permission) => mayOnPage(req, permission, page)) return SOURCE_PERMISSIONS.some((permission) => mayOnPage(req, permission, page))
} }
@ -155,10 +149,7 @@ export function mayReadSource(
* what they say. Answering an empty list for a reader without a session would hide controls a wiki had * what they say. Answering an empty list for a reader without a session would hide controls a wiki had
* deliberately opened to everyone. * deliberately opened to everyone.
*/ */
export function pagePermissionsFor( export function pagePermissionsFor(req: FastifyRequest, page: RulePageRef): string[] {
req: FastifyRequest,
page: { path: string; locale?: string; tags?: string[] }
): string[] {
const actor = WIKI.models.groups.actorForRequest(req) const actor = WIKI.models.groups.actorForRequest(req)
/* /*
An administrator holds all of them, and holds them here too. Deriving the list from their An administrator holds all of them, and holds them here too. Deriving the list from their
@ -593,7 +584,8 @@ async function routes(app: FastifyInstance) {
if (!group) { if (!group) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
if (!mayOnPage(req, 'read:pages', { path: group.page.path, locale: group.page.locale })) { const siteId = req.params.siteId
if (!mayOnPage(req, 'read:pages', { ...group.page, siteId })) {
return reply.forbidden('You are not allowed to read this page.') return reply.forbidden('You are not allowed to read this page.')
} }
return { return {
@ -602,9 +594,7 @@ async function routes(app: FastifyInstance) {
Filtered by what the asker may read, one page at a time: the set is a list of pages, and a Filtered by what the asker may read, one page at a time: the set is a list of pages, and a
page they have no access to is not one to name at them even to explain a refusal. page they have no access to is not one to name at them even to explain a refusal.
*/ */
relations: group.relations.filter((rel) => relations: group.relations.filter((rel) => mayOnPage(req, 'read:pages', { ...rel, siteId }))
mayOnPage(req, 'read:pages', { path: rel.path, locale: rel.locale })
)
} }
} }
) )
@ -671,7 +661,7 @@ async function routes(app: FastifyInstance) {
have in hand yet. have in hand yet.
*/ */
withContent: req.query.withContent withContent: req.query.withContent
? (target: { path: string; locale: string; tags: string[] }) => mayReadSource(req, target) ? (target: RulePageRef) => mayReadSource(req, target)
: false, : false,
publicOnly: !actor, publicOnly: !actor,
// -> Answered once the page is known, since a hash does not say which page it is yet // -> Answered once the page is known, since a hash does not say which page it is yet
@ -857,8 +847,20 @@ 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 })) { Against the page as it is about to be, since there is no page to ask about yet: the path it
is going to and the tags it is arriving with. A tag rule has nothing else to read here the
tags a new page carries are the ones in this request and leaving them out would make a
rule addressing tags silently miss every page the moment it was created.
*/
if (
!mayOnPage(req, 'write:pages', {
siteId: req.params.siteId,
path: req.body.path,
locale: req.body.locale,
tags: req.body.tags ?? []
})
) {
return reply.forbidden('You are not allowed to create a page here.') return reply.forbidden('You are not allowed to create a page here.')
} }
const { page, versionId } = await WIKI.models.pages.createPage( const { page, versionId } = await WIKI.models.pages.createPage(
@ -930,6 +932,23 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'write:pages', target)) { if (!mayOnPage(req, 'write:pages', target)) {
return reply.forbidden('You are not allowed to edit this page.') return reply.forbidden('You are not allowed to edit this page.')
} }
/*
And against the tags the edit gives it, when it changes them. Retagging a page is what a move
is to a path: a rule may address pages by tag, so writing a page INTO a set of tags the writer
has no say over is the same hole as moving one into a branch they could not have created a
page in and the check below is the tag half of the one the move route makes.
*/
if (
req.body.tags !== undefined &&
!mayOnPage(req, 'write:pages', {
siteId: req.params.siteId,
path: target.path,
locale: target.locale,
tags: req.body.tags
})
) {
return reply.forbidden('You are not allowed to give this page those tags.')
}
const change = await WIKI.models.pages.updatePage( const change = await WIKI.models.pages.updatePage(
req.params.siteId, req.params.siteId,
req.params.pageId, req.params.pageId,
@ -1044,6 +1063,7 @@ async function routes(app: FastifyInstance) {
is a way to put a page somewhere they could not have created one. is a way to put a page somewhere they could not have created one.
*/ */
const destination = { const destination = {
siteId: req.params.siteId,
path: req.body.path.replace(/^\/+/, ''), path: req.body.path.replace(/^\/+/, ''),
locale: req.body.locale || target.locale, locale: req.body.locale || target.locale,
tags: target.tags tags: target.tags
@ -1427,9 +1447,10 @@ async function routes(app: FastifyInstance) {
} }
// -> Resolving an alias tells the caller a page exists and where it is, which is only theirs // -> Resolving an alias tells the caller a page exists and where it is, which is only theirs
// to know if they may read it // to know if they may read it
if (!mayOnPage(req, 'read:pages', { path: target.path })) { if (!mayOnPage(req, 'read:pages', { ...target, siteId: req.params.siteId })) {
return reply.notFound('No page uses this alias.') return reply.notFound('No page uses this alias.')
} }
// -> `id` and `path` are all the response schema keeps; the locale and tags were for the check
return target return target
} }
) )
@ -1443,7 +1464,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Get page user permissions', summary: 'Get page user permissions',
description: description:
"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.", "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\nA rule may address pages by tag, so the tags of the page actually sitting at the path are part of the answer — they are read here rather than sent, and a path with no page on it has none.\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: {
@ -1479,10 +1500,24 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req) => { async (req) => {
return pagePermissionsFor(req, { const path = req.body.path.replace(/^\/+/, '')
path: req.body.path.replace(/^\/+/, ''), /*
The page's own tags, looked up rather than taken from the request: a rule may address pages
by tag, so the answer is only the same one the endpoints give if it is asked of the page that
is actually there. A path with no page answers with none, which is right there is nothing
for a tag rule to have matched, and what may be done at an empty path is a question about the
path alone.
*/
const tags = await WIKI.models.pages.tagsAt(req.params.siteId, {
path,
locale: req.body.locale locale: req.body.locale
}) })
return pagePermissionsFor(req, {
siteId: req.params.siteId,
path,
locale: req.body.locale,
tags
})
} }
) )
} }

@ -27,7 +27,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}, },
match: { match: {
type: 'string', type: 'string',
description: 'How `path` is compared against the page path.', description:
'How the rule addresses pages. `TAG` (any of them) and `TAGALL` (all of them) match on `tags` and ignore `path`; every other kind compares `path` against the page path and ignores `tags`.',
enum: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'] enum: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT']
}, },
mode: { mode: {
@ -40,6 +41,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string', type: 'string',
maxLength: 255 maxLength: 255
}, },
tags: {
type: 'array',
description:
'Tags a `TAG` / `TAGALL` rule matches on, lowercased and de-duplicated when stored. A tag no page carries is accepted: a rule may be written before the content it is about.',
items: {
type: 'string',
maxLength: 255
}
},
locales: { locales: {
type: 'array', type: 'array',
description: 'Locale codes this rule is limited to. Empty means all locales.', description: 'Locale codes this rule is limited to. Empty means all locales.',

@ -70,6 +70,62 @@ async function routes(app: FastifyInstance) {
}) })
} }
) )
/**
* LIST TAGS ACROSS EVERY SITE
*/
app.get<{ Querystring: { limit?: number } }>(
'/tags',
{
config: {
/*
A page rule is not a site's: one may name several sites, or none and mean all of them, so
the group editor's tag field cannot be filled from one site's list. Gated on reading
groups for that reason it exists to be that field's options, and whoever may read a
group already sees the tags its rules name.
*/
permissions: ['read:groups', 'manage:groups']
},
schema: {
summary: 'List the tags in use across every site',
description:
"Every tag carried by at least one page on the instance, most used first. This is what the group editor's page-rule tag field offers, since a rule is not limited to one site.\n\nUnlike the per-site listing this is not narrowed to the pages the caller may read: a rule acts on a tag whether or not the person writing it can see the pages carrying it, so a filtered list would hide tags the rule still matches.",
tags: ['Pages'],
querystring: {
type: 'object',
properties: {
limit: {
type: 'integer',
minimum: 1,
maximum: 5000,
default: 1000
}
}
},
response: {
200: {
description: 'Tags in use, most used first',
type: 'array',
items: {
type: 'object',
properties: {
tag: {
type: 'string'
},
usageCount: {
type: 'integer',
description: 'How many pages carry the tag, counted across every site.'
}
}
}
}
}
}
},
async (req) => {
return WIKI.models.tags.getAllTags({ limit: req.query.limit })
}
)
} }
export default routes export default routes

@ -79,6 +79,15 @@ 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.
*/ */
/**
* A request to any route in this file, every one of which addresses a site in its path.
*
* The two helpers below take the site from it rather than from a parameter of their own: a page rule
* may be limited to particular sites, so every check needs one, and there are a dozen call sites
* that would each have to remember to pass the same value.
*/
type SiteRequest = FastifyRequest<{ Params: { siteId: string } }>
/** /**
* The entries of a tree listing this caller may see, and the folders leading to them. * The entries of a tree listing this caller may see, and the folders leading to them.
* *
@ -92,7 +101,7 @@ const folderIdParam = {
* folder on every listing, which is not worth what it costs. * folder on every listing, which is not worth what it costs.
*/ */
function visibleTreeItems<T extends { type?: string; folderPath?: string; fileName?: string }>( function visibleTreeItems<T extends { type?: string; folderPath?: string; fileName?: string }>(
req: FastifyRequest, req: SiteRequest,
items: T[] items: T[]
): T[] { ): T[] {
const actor = WIKI.models.groups.actorForRequest(req) const actor = WIKI.models.groups.actorForRequest(req)
@ -100,6 +109,7 @@ function visibleTreeItems<T extends { type?: string; folderPath?: string; fileNa
const path = item.folderPath ? `${item.folderPath}/${item.fileName}` : (item.fileName ?? '') const path = item.folderPath ? `${item.folderPath}/${item.fileName}` : (item.fileName ?? '')
const permission = item.type === 'asset' ? 'read:assets' : 'read:pages' const permission = item.type === 'asset' ? 'read:assets' : 'read:pages'
return WIKI.models.groups.checkAccess(actor, permission, { return WIKI.models.groups.checkAccess(actor, permission, {
siteId: req.params.siteId,
path, path,
tags: (item as any).tags ?? [] tags: (item as any).tags ?? []
}) })
@ -137,13 +147,9 @@ function folderPathOf(folder: { folderPath?: string | null; fileName: string }):
* branch it opens: a rule denying `read:pages` under `geography` hides the folder as well as 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. * pages in it, and only somebody who may reorganise pages there may rename or remove it.
*/ */
function mayOnFolder( function mayOnFolder(req: SiteRequest, permission: string, path: string, locale: string): boolean {
req: FastifyRequest,
permission: string,
path: string,
locale: string
): boolean {
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, {
siteId: req.params.siteId,
path, path,
locale locale
}) })
@ -337,12 +343,20 @@ async function routes(app: FastifyInstance) {
A browse row carries a whole path rather than a folder/name pair, and stands for a page, a 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 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. a folder it is the branch, which is what a rule over the branch is talking about.
With the locale being listed and the row's own tags, both of which a rule may be written
against a row that is only a folder carries no tags, so a tag rule never hides one.
*/ */
const actor = WIKI.models.groups.actorForRequest(req) const actor = WIKI.models.groups.actorForRequest(req)
return { return {
...level, ...level,
items: level.items.filter((item) => items: level.items.filter((item) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', { path: item.path }) WIKI.models.groups.checkAccess(actor, 'read:pages', {
siteId: req.params.siteId,
path: item.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId),
tags: item.tags
})
) )
} }
} }
@ -442,8 +456,10 @@ async function routes(app: FastifyInstance) {
const actor = WIKI.models.groups.actorForRequest(req) const actor = WIKI.models.groups.actorForRequest(req)
return pages.filter((page) => return pages.filter((page) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', { WIKI.models.groups.checkAccess(actor, 'read:pages', {
siteId: req.params.siteId,
path: page.path, path: page.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId) locale: req.query.locale ?? defaultLocale(req.params.siteId),
tags: page.tags
}) })
) )
} }

@ -52,6 +52,7 @@ async function routes(app: FastifyInstance) {
if ( if (
!asset || !asset ||
!WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), 'read:assets', { !WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), 'read:assets', {
siteId: site.id,
path: asset.folderPath ? `${asset.folderPath}/${asset.fileName}` : asset.fileName, path: asset.folderPath ? `${asset.folderPath}/${asset.fileName}` : asset.fileName,
locale: asset.locale locale: asset.locale
}) })

@ -8,41 +8,64 @@ import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups.
* --------------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------------
* *
* A group grants page permissions through rules, never as a blanket. Every rule names a set of * 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 * permissions (`roles`), a way of addressing pages (`match`, with either `path` or `tags`), and what
* (`mode`). A user's rules are all of their groups' rules pooled together belonging to a second * it does with them (`mode`). A user's rules are all of their groups' rules pooled together
* group can therefore both widen and narrow what the first one said. * belonging to a second group can therefore both widen and narrow what the first one said.
*
* The two tag kinds read `tags` and ignore `path`; every other kind is the other way round. They are
* separate fields rather than one reused for both so that a rule keeps whichever it is not currently
* matching on changing a rule's kind and changing it back is not a way to lose what it said.
* *
* **Nothing is granted by default.** A permission nobody wrote a rule for is denied: no rules at all * **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. * is the same as one DENY rule covering the whole site. This is why an empty group can read nothing.
* *
* A rule only has a say over a page it is SCOPED to. `sites` and `locales` each name what the rule
* is limited to, and an empty list means every one of them so a rule left alone speaks for the
* whole instance, and one naming a site says nothing at all about the others.
*
* When more than one rule names the permission being asked about and matches the page, exactly one * 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. * 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 * 1. MATCH TYPE, as bands. From weakest to strongest:
* `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 * Path Starts With < Path Ends With < Path Matches Regex <
* the whole site (empty path) is the least specific thing there is. Tag rules address no path * Has Any Tag < Has All Tags < Path Is Exactly
* 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: * The order runs from the vaguest way of naming pages to the most precise: a prefix is a whole
* branch of the tree, a tag is something somebody put ON the page to say what it is, and an
* exact path is one page and nothing else.
* *
* Has Any Tag < Has All Tags < Path Starts With < Path Ends With < * Three BANDS, because path length below only settles a contest inside one of them: the three
* Path Matches Regex < Path Is Exactly * path-shaped kinds, then the two tag kinds, then Path Is Exactly. A tag rule therefore beats a
* prefix rule however deep that prefix is `confidential` is denied under `docs` as surely as
* anywhere else, and a guests group denying the whole site can still be opened on the pages
* tagged `public` while naming a page outright still beats saying what it is tagged.
* *
* The order runs from the vaguest way of naming pages to the most precise: a tag is a property * A tag rule has no path, so without the bands it would score zero at step 2 and lose to every
* a page happens to have, a prefix is a whole branch of the tree, and an exact path is one page * rule that named one, which is every rule a group starts with.
* and nothing else.
* *
* 3. MODE, when two rules are equally specific and of the same kind: * 2. SPECIFICITY, highest first, within a band. 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, so they all score zero and this settles nothing between them.
*
* 3. MATCH TYPE AGAIN, to separate two kinds sharing a band at the same specificity: Has All Tags
* beats Has Any Tag, since every tag in a list is a stronger claim than any one of them, and a
* regex beats a suffix beats a prefix.
*
* 4. MODE, when two rules are equally specific and of the same kind:
* *
* ALLOW < DENY < FORCE ALLOW * ALLOW < DENY < FORCE ALLOW
* *
* An ALLOW grants the permission. A DENY overrides any ALLOW. A FORCE ALLOW overrides any DENY, * 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. * 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 * The four 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` * 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. * the deeper rule was more specific and had already won. And a path rule cannot make a hole in a tag
* rule unless it names the page exactly; otherwise it takes another tag rule a FORCE ALLOW on the
* tag, or taking the tag off the page.
* *
* --------------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------------
* *
@ -50,28 +73,59 @@ import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups.
* read. See `models/groups.ts`. * read. See `models/groups.ts`.
*/ */
/** A page as a rule sees it. `locale` and `path` place it; `tags` are what tag rules match on. */ /**
* A page as a rule sees it.
*
* `siteId` and `locale` scope it, `path` places it, and `tags` are what a tag rule matches on.
*
* `siteId` is REQUIRED, and deliberately so: a rule limited to particular sites has to be able to
* tell whether this page is in one of them, and a reference that could leave it out would apply
* every such rule to every site the moment a caller forgot. There is no page anywhere in this
* codebase that does not belong to a site, so nothing is being asked for that is not in hand.
*/
export interface RulePageRef { export interface RulePageRef {
siteId: string
path: string path: string
locale?: string locale?: string
tags?: string[] tags?: string[]
} }
/** /**
* Match kinds from weakest to strongest, used to break a tie between equally specific rules. The * Match kinds from weakest to strongest. The index IS the priority, so the order of this array is
* index IS the priority, so the order of this array is the order documented above. * the order documented above.
*/ */
const MATCH_PRIORITY: GroupRuleMatch[] = ['TAG', 'TAGALL', 'START', 'END', 'REGEX', 'EXACT'] const MATCH_PRIORITY: GroupRuleMatch[] = ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT']
/**
* Which band of the ordering each kind sits in, weakest first step 1 above.
*
* Path length settles a contest only INSIDE a band, which is the whole point of having them: a tag
* rule names no path, so measuring it against one would put it below every rule that did.
*/
const MATCH_BAND: Record<GroupRuleMatch, number> = {
START: 0,
END: 0,
REGEX: 0,
TAG: 1,
TAGALL: 1,
EXACT: 2
}
/** Modes from weakest to strongest, used to break a tie between rules of the same kind. */ /** Modes from weakest to strongest, used to break a tie between rules of the same kind. */
const MODE_PRIORITY: GroupRuleMode[] = ['ALLOW', 'DENY', 'FORCEALLOW'] 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. */ /** The kinds that address pages by tag rather than by path. */
const TAG_MATCHES: GroupRuleMatch[] = ['TAG', 'TAGALL']
/**
* The tags a rule addresses, as they are compared.
*
* Lowercased here rather than on the way in, because the page's own tags are stored as they were
* typed: a page tagged `Meeting` and a rule naming `meeting` are the same tag, and the wiki has no
* canonical case to hold either of them to.
*/
function ruleTags(rule: GroupRule): string[] { function ruleTags(rule: GroupRule): string[] {
return rule.path return (rule.tags ?? []).map((tag) => tag.trim().toLowerCase()).filter(Boolean)
.split(',')
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean)
} }
/** Compared without leading slashes on either side, since neither is stored with one. */ /** Compared without leading slashes on either side, since neither is stored with one. */
@ -82,20 +136,56 @@ function normalizePath(value: string): string {
/** /**
* How much of the site a rule is talking about, as a number where higher is narrower. * 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 * The length of the path it addresses. A tag rule addresses no path, so every one of them scores
* out-specify a rule that names one matching the ordering above, where tags are the vaguest way of * zero which says nothing about it, since this is only ever read against another rule of the same
* naming a page. * band and both tag kinds are in the same one.
*/ */
function specificityOf(rule: GroupRule): number { function specificityOf(rule: GroupRule): number {
if (rule.match === 'TAG' || rule.match === 'TAGALL') { if (TAG_MATCHES.includes(rule.match)) {
return 0 return 0
} }
return normalizePath(rule.path).length return normalizePath(rule.path).length
} }
/**
* A rule as a sortable key: the four steps documented above, strongest first in each position.
*
* Compared lexicographically by `outranks`, so adding a step is adding an entry here rather than
* another branch in the comparison.
*/
function rankOf(rule: GroupRule): number[] {
return [
MATCH_BAND[rule.match],
specificityOf(rule),
MATCH_PRIORITY.indexOf(rule.match),
MODE_PRIORITY.indexOf(rule.mode)
]
}
/**
* Whether `a` beats `b`, comparing the ranks position by position.
*
* Strictly greater, so two identical ranks leave the incumbent in place: the first rule of an
* otherwise identical pair wins and the outcome does not depend on the order they arrived in.
*/
function outranks(a: number[], b: number[]): boolean {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return a[i] > b[i]
}
}
return false
}
/** Whether a rule addresses this page at all, ignoring what it then says about it. */ /** Whether a rule addresses this page at all, ignoring what it then says about it. */
export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean { export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean {
// -> A rule may be limited to particular locales; an empty list means every one of them // -> A rule may be limited to particular sites; an empty list means every one of them
if (rule.sites?.length > 0 && !rule.sites.includes(page.siteId)) {
return false
}
// -> And to particular locales, the same way. Unlike the site, a reference may leave the locale
// out — an asset addressed by path alone — and such a page is not one a locale rule excludes
if (rule.locales?.length > 0 && page.locale && !rule.locales.includes(page.locale)) { if (rule.locales?.length > 0 && page.locale && !rule.locales.includes(page.locale)) {
return false return false
} }
@ -142,24 +232,14 @@ export function resolvePageRule(
page: RulePageRef page: RulePageRef
): GroupRule | null { ): GroupRule | null {
let winner: GroupRule | null = null let winner: GroupRule | null = null
let winnerRank: [number, number, number] = [-1, -1, -1] let winnerRank: number[] | null = null
for (const rule of rules) { for (const rule of rules) {
if (!rule.roles?.includes(permission) || !ruleMatchesPage(rule, page)) { if (!rule.roles?.includes(permission) || !ruleMatchesPage(rule, page)) {
continue continue
} }
const rank: [number, number, number] = [ const rank = rankOf(rule)
specificityOf(rule), if (!winnerRank || outranks(rank, winnerRank)) {
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 winner = rule
winnerRank = rank winnerRank = rank
} }

@ -615,6 +615,9 @@
"admin.groups.ruleMatchTagAll": "Has All Tags...", "admin.groups.ruleMatchTagAll": "Has All Tags...",
"admin.groups.rulePath": "Path", "admin.groups.rulePath": "Path",
"admin.groups.ruleSites": "Site(s)", "admin.groups.ruleSites": "Site(s)",
"admin.groups.ruleTags": "Tags",
"admin.groups.ruleTagsFailed": "Failed to load the list of existing tags.",
"admin.groups.ruleTagsHint": "Type to search existing tags, or enter a new one.",
"admin.groups.ruleUntitled": "Untitled Rule", "admin.groups.ruleUntitled": "Untitled Rule",
"admin.groups.rules": "Rules", "admin.groups.rules": "Rules",
"admin.groups.rulesNone": "This group doesn't have any rules yet.", "admin.groups.rulesNone": "This group doesn't have any rules yet.",

@ -445,6 +445,7 @@ class Approvals {
WIKI.models.groups.actorForRequest(req), WIKI.models.groups.actorForRequest(req),
'review:pages', 'review:pages',
{ {
siteId,
path: page.path, path: page.path,
tags: page.tags tags: page.tags
} }

@ -10,7 +10,7 @@ import type { FastifyRequest } from 'fastify'
/** The permission that bypasses every check, and the one the guards below exist to protect. */ /** The permission that bypasses every check, and the one the guards below exist to protect. */
export const SYSTEM_PERMISSION = 'manage:system' export const SYSTEM_PERMISSION = 'manage:system'
/** How a rule's `path` is compared against the page path. */ /** How a rule addresses pages: `TAG` and `TAGALL` read `tags`, everything else reads `path`. */
export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
/** Whether a matching rule grants, denies, or unconditionally grants its roles. */ /** Whether a matching rule grants, denies, or unconditionally grants its roles. */
@ -24,6 +24,12 @@ export interface GroupRule {
match: GroupRuleMatch match: GroupRuleMatch
mode: GroupRuleMode mode: GroupRuleMode
path: string path: string
/**
* The tags a `TAG` / `TAGALL` rule addresses. Ignored by every other kind, and kept rather than
* cleared when one is chosen, so that a rule switched to a path kind and back still says what it
* said. A tag named here need not be on any page: a rule may be written ahead of the content.
*/
tags: string[]
locales: string[] locales: string[]
sites: string[] sites: string[]
} }
@ -263,6 +269,7 @@ class Groups {
match: 'START', match: 'START',
mode: 'ALLOW', mode: 'ALLOW',
path: '', path: '',
tags: [],
locales: [], locales: [],
sites: [] sites: []
} }
@ -287,6 +294,7 @@ class Groups {
match: 'START', match: 'START',
mode: 'DENY', mode: 'DENY',
path: '', path: '',
tags: [],
locales: [], locales: [],
sites: [] sites: []
} }
@ -356,6 +364,7 @@ class Groups {
match: 'START', match: 'START',
mode: 'ALLOW', mode: 'ALLOW',
path: '', path: '',
tags: [],
locales: [], locales: [],
sites: [] sites: []
} }
@ -408,7 +417,7 @@ class Groups {
const result = await WIKI.db const result = await WIKI.db
.update(groupsTable) .update(groupsTable)
.set({ .set({
...this.clampGuestPatch(id, patch), ...this.clampGuestPatch(id, this.normalizeRulePatch(patch)),
...(patch.name !== undefined ? { name: patch.name.trim() } : {}), ...(patch.name !== undefined ? { name: patch.name.trim() } : {}),
updatedAt: sql`now()` updatedAt: sql`now()`
}) })
@ -417,6 +426,31 @@ class Groups {
return (result.rowCount ?? 0) > 0 return (result.rowCount ?? 0) > 0
} }
/**
* Tidy the tags on the rules being saved: trimmed, lowercased, de-duplicated, blanks dropped.
*
* Done on the way in rather than at match time so that the stored rule says exactly what it
* matches. `ruleTags` lowercases anyway a rule written through the API keeps working either
* way but a rule listing `Meeting` and `meeting` as two tags is a `TAGALL` rule that reads as
* though it wanted two things and a rule the admin screen would show twice.
*
* Every rule is tidied, not only the tag kinds: what a path rule carries in `tags` is what it
* would match on if it were switched back, and there is no moment at which holding a stray blank
* is worth anything.
*/
private normalizeRulePatch(patch: GroupPatch): GroupPatch {
if (!patch.rules) {
return patch
}
return {
...patch,
rules: patch.rules.map((rule) => ({
...rule,
tags: [...new Set((rule.tags ?? []).map((tag) => tag.trim().toLowerCase()).filter(Boolean))]
}))
}
}
/** /**
* Hold the guests group to what the public may be given. * Hold the guests group to what the public may be given.
* *

@ -8,6 +8,7 @@ import {
} from '../helpers/common.ts' } from '../helpers/common.ts'
import { invalidateAppShellCache } from '../helpers/appShell.ts' import { invalidateAppShellCache } from '../helpers/appShell.ts'
import type { AccessActor } from './groups.ts' import type { AccessActor } from './groups.ts'
import type { RulePageRef } from '../helpers/pageRules.ts'
import type { FastifyRequest } from 'fastify' import type { FastifyRequest } from 'fastify'
import type { RenderPermissions, TocNode } from './rendering.ts' import type { RenderPermissions, TocNode } from './rendering.ts'
import type { DeletedEntry } from './tree.ts' import type { DeletedEntry } from './tree.ts'
@ -120,6 +121,12 @@ export interface PageLocaleRelation {
locale: string locale: string
path: string path: string
title: string title: string
/**
* The counterpart's own tags. Not something a client is shown the response schemas drop it
* but a counterpart has to be judged against `read:pages` before it is named at anybody, and a
* rule that addresses pages by tag cannot answer without them.
*/
tags: string[]
} }
/** /**
@ -137,6 +144,12 @@ export interface PageLocaleRelationInput {
/** A page as the API exposes it: the columns and both blobs, flattened into one object. */ /** A page as the API exposes it: the columns and both blobs, flattened into one object. */
export interface Page { export interface Page {
id: string id: string
/**
* The site the page belongs to. Carried so that a page in hand is enough to ask a page rule about
* it rules may be limited to particular sites, and `RulePageRef` requires one for that reason.
* Dropped by the response schemas: the caller addressed the page through its site to begin with.
*/
siteId: string
path: string path: string
hash: string hash: string
alias: string | null alias: string | null
@ -444,6 +457,7 @@ class Pages {
const scripts = row.scripts ?? {} const scripts = row.scripts ?? {}
return { return {
id: row.id, id: row.id,
siteId: row.siteId,
path: row.path, path: row.path,
hash: row.hash, hash: row.hash,
alias: row.alias, alias: row.alias,
@ -519,7 +533,12 @@ class Pages {
conditions.push(eq(pagesTable.publishState, 'published')) conditions.push(eq(pagesTable.publishState, 'published'))
} }
return await WIKI.db return await WIKI.db
.select({ locale: pagesTable.locale, path: pagesTable.path, title: pagesTable.title }) .select({
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
tags: pagesTable.tags
})
.from(pagesTable) .from(pagesTable)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(pagesTable.locale) .orderBy(pagesTable.locale)
@ -550,7 +569,13 @@ class Pages {
return null return null
} }
return { return {
page: { id: page.id, locale: page.locale, path: page.path, title: page.title }, page: {
id: page.id,
locale: page.locale,
path: page.path,
title: page.title,
tags: page.tags
},
relations: await this.localeRelationsFor(siteId, { relations: await this.localeRelationsFor(siteId, {
localeGroupId: page.localeGroupId, localeGroupId: page.localeGroupId,
id: page.id id: page.id
@ -558,6 +583,37 @@ class Pages {
} }
} }
/**
* The tags carried by the page at a path, or an empty list when there is no page there.
*
* For the one caller that has a path and no page: the endpoint the interface asks what it may do
* at a path it is about to show. A rule can address pages by tag, so answering that from the path
* alone would report a permission a tag rule had granted or taken away as though the rule did not
* exist and the interface would then draw controls the endpoint behind them refuses, or hide
* ones it would have allowed.
*
* Read from the database rather than taken from the caller, for the obvious reason: what a page is
* tagged decides what may be done to it, and a client that could name the tags could name the ones
* that suit it.
*/
async tagsAt(
siteId: string,
{ locale, path }: { locale?: string; path: string }
): Promise<string[]> {
const rows = await WIKI.db
.select({ tags: pagesTable.tags })
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale || this.defaultLocale(siteId)),
eq(pagesTable.hash, generatePathHash(path || 'home'))
)
)
.limit(1)
return rows[0]?.tags ?? []
}
/** One page of a site, by the locale and path that address it. */ /** One page of a site, by the locale and path that address it. */
private async findByPath( private async findByPath(
siteId: string, siteId: string,
@ -568,6 +624,7 @@ class Pages {
locale: string locale: string
path: string path: string
title: string title: string
tags: string[]
localeGroupId: string | null localeGroupId: string | null
} | null> { } | null> {
const rows = await WIKI.db const rows = await WIKI.db
@ -576,6 +633,7 @@ class Pages {
locale: pagesTable.locale, locale: pagesTable.locale,
path: pagesTable.path, path: pagesTable.path,
title: pagesTable.title, title: pagesTable.title,
tags: pagesTable.tags,
localeGroupId: pagesTable.localeGroupId localeGroupId: pagesTable.localeGroupId
}) })
.from(pagesTable) .from(pagesTable)
@ -967,7 +1025,7 @@ class Pages {
const guests = WIKI.models.groups.actorForPublic() const guests = WIKI.models.groups.actorForPublic()
const pages = rows const pages = rows
.filter((row) => WIKI.models.groups.checkAccess(guests, 'read:pages', row)) .filter((row) => WIKI.models.groups.checkAccess(guests, 'read:pages', { ...row, siteId }))
.map(({ locale, path, updatedAt, localeGroupId }) => ({ .map(({ locale, path, updatedAt, localeGroupId }) => ({
locale, locale,
path, path,
@ -1064,7 +1122,7 @@ class Pages {
if (!row) { if (!row) {
return null return null
} }
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', row)) { if (!WIKI.models.groups.checkAccess(actor, 'read:pages', { ...row, siteId })) {
return null return null
} }
@ -1115,7 +1173,9 @@ class Pages {
) )
) )
.orderBy(pagesTable.locale) .orderBy(pagesTable.locale)
const readable = rows.filter((row) => WIKI.models.groups.checkAccess(actor, 'read:pages', row)) const readable = rows.filter((row) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', { ...row, siteId })
)
// -> One document is not a set of alternates: a page whose only readable version is itself has // -> One document is not a set of alternates: a page whose only readable version is itself has
// nothing for an annotation to point at // nothing for an annotation to point at
return readable.length > 1 ? readable.map(({ locale, path }) => ({ locale, path })) : [] return readable.length > 1 ? readable.map(({ locale, path }) => ({ locale, path })) : []
@ -1157,7 +1217,7 @@ class Pages {
* is granted by a page rule, and a rule is chosen by path, locale and tags, none of which a * is granted by a page rule, and a rule is chosen by path, locale and tags, none of which a
* request addressing a page by hash has in hand before the row is read. * request addressing a page by hash has in hand before the row is read.
*/ */
withContent?: boolean | ((page: { path: string; locale: string; tags: string[] }) => boolean) withContent?: boolean | ((page: RulePageRef) => boolean)
/** Restrict to what a reader with no session may see: published pages. */ /** Restrict to what a reader with no session may see: published pages. */
publicOnly?: boolean publicOnly?: boolean
unlocked?: boolean | ((pageId: string) => boolean) unlocked?: boolean | ((pageId: string) => boolean)
@ -1203,6 +1263,7 @@ class Pages {
? withContent({ ? withContent({
path: row.page.path, path: row.page.path,
locale: row.page.locale, locale: row.page.locale,
siteId,
tags: row.page.tags ?? [] tags: row.page.tags ?? []
}) })
: withContent : withContent
@ -2283,9 +2344,16 @@ class Pages {
async getPathFromAlias( async getPathFromAlias(
siteId: string, siteId: string,
alias: string alias: string
): Promise<{ id: string; path: string } | null> { ): Promise<{ id: string; path: string; locale: string; tags: string[] } | null> {
// -> The locale and the tags come back alongside the path because the caller has to decide
// whether this reader may know the page exists, and a rule reads all three
const results = await WIKI.db const results = await WIKI.db
.select({ id: pagesTable.id, path: pagesTable.path }) .select({
id: pagesTable.id,
path: pagesTable.path,
locale: pagesTable.locale,
tags: pagesTable.tags
})
.from(pagesTable) .from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.alias, alias))) .where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.alias, alias)))
.limit(1) .limit(1)

@ -398,6 +398,7 @@ class Search {
const visible = actor const visible = actor
? ((rows.rows ?? rows) as any[]).filter((row) => ? ((rows.rows ?? rows) as any[]).filter((row) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', { WIKI.models.groups.checkAccess(actor, 'read:pages', {
siteId,
path: row.path as string, path: row.path as string,
locale: row.locale as string, locale: row.locale as string,
tags: (row.tags ?? []) as string[] tags: (row.tags ?? []) as string[]

@ -99,12 +99,13 @@ class Sites {
} }
async createSite(hostname: string, config: Record<string, any> = {}) { async createSite(hostname: string, config: Record<string, any> = {}) {
const result = await WIKI.db /*
.insert(sitesTable) The whole configuration the site is created with, defaults and caller's together. Read back
.values({ below rather than reading `config` again: the caller supplies the handful of fields the create
hostname, form asks for, so anything else looked up there is undefined which is what made creating a
isEnabled: true, site through the API fail on `locales.primary` every time.
config: toMerged( */
const siteConfig = toMerged(
{ {
title: 'My Wiki Site', title: 'My Wiki Site',
description: '', description: '',
@ -234,6 +235,13 @@ class Sites {
}, },
config config
) )
const result = await WIKI.db
.insert(sitesTable)
.values({
hostname,
isEnabled: true,
config: siteConfig
}) })
.returning({ id: sitesTable.id }) .returning({ id: sitesTable.id })
@ -243,7 +251,7 @@ class Sites {
// exist before a page can point at it, and a site starts with its primary locale — the rest get // exist before a page can point at it, and a site starts with its primary locale — the rest get
// one the first time a page is written in them // one the first time a page is written in them
WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`) WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`)
await WIKI.models.navigation.siteNavId(newSite.id, config.locales.primary) await WIKI.models.navigation.siteNavId(newSite.id, siteConfig.locales.primary)
// -> Site lookups by id / hostname are served from cache, which must know about the new site // -> Site lookups by id / hostname are served from cache, which must know about the new site
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()

@ -59,6 +59,7 @@ class Tags {
const counts = new Map<string, number>() const counts = new Map<string, number>()
for (const row of (result.rows ?? result) as any[]) { for (const row of (result.rows ?? result) as any[]) {
const page = { const page = {
siteId,
path: row.path as string, path: row.path as string,
locale: row.locale as string, locale: row.locale as string,
tags: (row.tags ?? []) as string[] tags: (row.tags ?? []) as string[]
@ -75,6 +76,35 @@ class Tags {
.sort((a, b) => b.usageCount - a.usageCount || a.tag.localeCompare(b.tag)) .sort((a, b) => b.usageCount - a.usageCount || a.tag.localeCompare(b.tag))
.slice(0, limit) .slice(0, limit)
} }
/**
* Every tag in use anywhere on the instance, most used first.
*
* For the group editor, whose page rules are not a site's: one rule may name several sites, or
* none at all and mean every one of them, so the tags it offers cannot come from a single site's
* list. Counted across sites, so a tag used on two of them counts the pages of both.
*
* Deliberately NOT filtered by who is asking, unlike `getTags`. Whoever writes page rules is
* deciding what everyone else may read, and a list narrowed to the pages they happen to have
* access to would quietly leave tags out of the field that their rules still act on a rule
* written against a tag it did not offer works exactly the same as one written against a tag it
* did. The route it answers is gated on the permission to read groups instead.
*
* @param limit Ceiling on how many distinct tags come back, most used first
*/
async getAllTags({ limit = 1000 }: { limit?: number } = {}): Promise<Tag[]> {
const result = await WIKI.db.execute(sql`
SELECT tag, COUNT(*)::int AS "usageCount"
FROM pages, unnest(tags) AS tag
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
}))
}
} }
export const tags = new Tags() export const tags = new Tags()

@ -78,6 +78,12 @@ export interface BrowseItem {
icon: string | null icon: string | null
isPage: boolean isPage: boolean
isFolder: boolean isFolder: boolean
/**
* The page's own tags, empty for an entry that is only a folder. Not shown the response schema
* drops it but a rule may address pages by tag and the route filters this listing by
* `read:pages`, which cannot be decided without them.
*/
tags: string[]
} }
/** One level of a browse listing: what a folder holds, plus what the folder itself is called. */ /** One level of a browse listing: what a folder holds, plus what the folder itself is called. */
@ -100,6 +106,12 @@ export interface ListedPage {
description: string description: string
/** The page's icon, as an Iconify reference. Empty when it has none. */ /** The page's icon, as an Iconify reference. Empty when it has none. */
icon: string icon: string
/**
* The page's own tags. Carried for the same reason as on a browse item: the caller filters the
* list by `read:pages`, and a tag rule is not answerable without them. Dropped by the response
* schema.
*/
tags: string[]
} }
/** /**
@ -402,6 +414,7 @@ class Tree {
folderPath: treeTable.folderPath, folderPath: treeTable.folderPath,
fileName: treeTable.fileName, fileName: treeTable.fileName,
title: treeTable.title, title: treeTable.title,
tags: treeTable.tags,
description: pagesTable.description, description: pagesTable.description,
icon: pagesTable.icon icon: pagesTable.icon
}) })
@ -427,7 +440,8 @@ class Tree {
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName, path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
title: row.title, title: row.title,
description: row.description ?? '', description: row.description ?? '',
icon: row.icon ?? '' icon: row.icon ?? '',
tags: row.tags ?? []
} }
}) })
} }
@ -522,6 +536,7 @@ class Tree {
type: treeTable.type, type: treeTable.type,
fileName: treeTable.fileName, fileName: treeTable.fileName,
title: treeTable.title, title: treeTable.title,
tags: treeTable.tags,
icon: pagesTable.icon, icon: pagesTable.icon,
holdsVisiblePages: sql<boolean>`${holdsVisiblePages}`.mapWith(Boolean) holdsVisiblePages: sql<boolean>`${holdsVisiblePages}`.mapWith(Boolean)
}) })
@ -552,15 +567,18 @@ class Tree {
title: row.title, title: row.title,
icon: null, icon: null,
isPage: false, isPage: false,
isFolder: false isFolder: false,
tags: []
} }
if (row.type === 'folder') { if (row.type === 'folder') {
entry.isFolder = true entry.isFolder = true
} else { } else {
entry.isPage = true entry.isPage = true
// -> The page is the thing a reader clicks, so it names the row when both exist // -> The page is the thing a reader clicks, so it names the row when both exist -- and its
// tags are the entry's, a folder having none of its own
entry.title = row.title entry.title = row.title
entry.icon = row.icon entry.icon = row.icon
entry.tags = row.tags ?? []
} }
merged.set(row.fileName, entry) merged.set(row.fileName, entry)
} }

@ -408,7 +408,33 @@
{ 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' }
]" /> ]" />
<!--
A tag rule matches on tags and a path rule on a path, so the field under the
kind is whichever one that kind reads. They are separate properties of the
rule rather than one field doing double duty: changing the kind and changing
it back leaves both intact.
-->
<w-select
v-if="isTagMatch(rule.match)"
class="mt-2"
standout
v-model="rule.tags"
:options="sortedTags"
dense
options-dense
use-input
use-chips
create
multiple
hide-dropdown-icon
:placeholder="t(`admin.groups.ruleTagsHint`)"
:aria-label="t(`admin.groups.ruleTags`)"
:loading="state.isLoadingTags"
@create="(val) => addRuleTags(rule, val)">
<template #prepend><w-icon name="la:hashtag" size="xs" /></template>
</w-select>
<w-input <w-input
v-else
class="mt-2" class="mt-2"
standout standout
v-model="rule.path" v-model="rule.path"
@ -648,6 +674,9 @@ const state = reactive({
rules: [] rules: []
}, },
isLoading: false, isLoading: false,
/** Every tag in use on the instance, as suggestions for the tag rules. */
tags: [],
isLoadingTags: false,
users: [], users: [],
isLoadingUsers: false, isLoadingUsers: false,
usersFilter: '', usersFilter: '',
@ -976,6 +1005,15 @@ const ruleOptions = computed(() =>
isGuestGroup.value ? rules.filter((rule) => GUEST_ROLES.includes(rule.permission)) : rules isGuestGroup.value ? rules.filter((rule) => GUEST_ROLES.includes(rule.permission)) : rules
) )
/**
* The tag suggestions in the order they are offered: alphabetical.
*
* `GET /tags` answers most-used first, which is the right order for a limit and the wrong one for a
* list to pick from -- WSelect narrows it as you type but never reorders it. `localeCompare`, since
* tags are page-authored words in whatever language the wiki is written in.
*/
const sortedTags = computed(() => [...state.tags].sort((a, b) => a.localeCompare(b)))
// WATCHERS // WATCHERS
watch(() => route.params.section, checkRoute) watch(() => route.params.section, checkRoute)
@ -1073,7 +1111,12 @@ async function fetchGroup() {
if (!resp?.id) { if (!resp?.id) {
throw new Error('An unexpected error occured while fetching group details.') throw new Error('An unexpected error occured while fetching group details.')
} }
state.group = resp // -> `tags` is optional in the API's rule schema, so a rule written through it may arrive without
// one; the field below binds to an array either way
state.group = {
...resp,
rules: (resp.rules ?? []).map((r) => ({ ...r, tags: r.tags ?? [] }))
}
state.usersTotal = state.group.userCount ?? 0 state.usersTotal = state.group.userCount ?? 0
} catch (err) { } catch (err) {
notify({ notify({
@ -1116,6 +1159,45 @@ async function save() {
state.isLoading = false state.isLoading = false
} }
/** Whether a rule kind addresses pages by tag, which is what decides the field offered under it. */
function isTagMatch(match) {
return match === 'TAG' || match === 'TAGALL'
}
/**
* Add whatever was typed to a rule, as one tag or as several.
*
* A comma or a semicolon separates them, so a list can be pasted in one go -- the same as the page
* properties panel. A tag that is on no page is perfectly valid here: a rule may be written ahead of
* the content it is about, and one that names a tag nobody has used yet simply matches nothing until
* somebody does. It does NOT join the suggestions, which are the tags actually in use: a tag exists
* because a page carries it, and offering one invented in this field would say otherwise.
*/
function addRuleTags(rule, val) {
const tags = val
.split(/[,;]+/)
.map((v) => v.trim().toLowerCase())
.filter(Boolean)
rule.tags = [...new Set([...(rule.tags ?? []), ...tags])]
}
async function fetchTags() {
state.isLoadingTags = true
try {
const resp = await API_CLIENT.get('tags').json()
state.tags = (resp ?? []).map((tg) => tg.tag)
} catch (err) {
// -> Suggestions are a convenience: a tag can still be typed in without them, so this is a
// warning rather than a failure
notify({
type: 'warning',
message: t('admin.groups.ruleTagsFailed'),
caption: apiErrorMessage(err)
})
}
state.isLoadingTags = false
}
function newRule() { function newRule() {
state.group.rules.push({ state.group.rules.push({
id: uuid(), id: uuid(),
@ -1124,6 +1206,7 @@ function newRule() {
match: 'START', match: 'START',
roles: [], roles: [],
path: '', path: '',
tags: [],
locales: [], locales: [],
sites: [] sites: []
}) })
@ -1187,6 +1270,7 @@ async function importRules() {
: 'START', : 'START',
roles: r.roles || [], roles: r.roles || [],
path: r.path || '', path: r.path || '',
tags: Array.isArray(r.tags) ? r.tags.map((tg) => `${tg}`.trim().toLowerCase()) : [],
locales: r.locales.filter((l) => adminStore.locales.some((loc) => loc.code === l)), locales: r.locales.filter((l) => adminStore.locales.some((loc) => loc.code === l)),
sites: r.sites.filter((s) => adminStore.sites.some((site) => site.id === s)) sites: r.sites.filter((s) => adminStore.sites.some((site) => site.id === s))
})) }))
@ -1301,6 +1385,7 @@ async function unassignUser(user) {
onMounted(() => { onMounted(() => {
checkRoute() checkRoute()
fetchGroup() fetchGroup()
fetchTags()
}) })
</script> </script>

Loading…
Cancel
Save