diff --git a/backend/api/approvals.ts b/backend/api/approvals.ts index c69bcd106..144f473b5 100644 --- a/backend/api/approvals.ts +++ b/backend/api/approvals.ts @@ -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 * 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)) { return { groupIds: [], reviewsAll: false } } @@ -63,7 +66,13 @@ function reviewerFor(req: FastifyRequest, page?: { path: string; tags?: string[] groupIds: WIKI.models.approvals.getActorGroupIds(req), reviewsAll: 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 + }) } } diff --git a/backend/api/assets.ts b/backend/api/assets.ts index 88dfdaf60..2c65ab627 100644 --- a/backend/api/assets.ts +++ b/backend/api/assets.ts @@ -34,14 +34,18 @@ const assetIdParam = { * 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 * 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( - req: FastifyRequest, + req: FastifyRequest<{ Params: { siteId: string } }>, permission: string, asset: { folderPath?: string | null; fileName: string; locale: string } ): boolean { const folder = asset.folderPath ?? '' return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { + siteId: req.params.siteId, path: folder ? `${folder}/${asset.fileName}` : asset.fileName, locale: asset.locale }) diff --git a/backend/api/comments.ts b/backend/api/comments.ts index d0b79aab6..01669ffe5 100644 --- a/backend/api/comments.ts +++ b/backend/api/comments.ts @@ -4,6 +4,7 @@ import { mayOnPage } from './pages.ts' import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import type { CommentsProviderInput } from '../models/comments.ts' +import type { RulePageRef } from '../helpers/pageRules.ts' const siteIdParam = { type: 'object', @@ -562,7 +563,9 @@ async function requireBuiltInPage( reply.notFound('This page does not exist.') 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.') 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)) { return comment } @@ -615,7 +623,7 @@ async function requireWritableComment( */ async function consumeCooldown( req: FastifyRequest<{ Params: { siteId: string; pageId: string } }>, - page: { path: string; locale: string; tags: string[] } + page: RulePageRef ): Promise { const seconds = WIKI.models.comments.cooldownFor(req.params.siteId) if (seconds < 1 || mayOnPage(req, 'manage:comments', page)) { diff --git a/backend/api/pages.ts b/backend/api/pages.ts index bc1a6b2fa..5b334f312 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -1,6 +1,7 @@ import { validate as uuidValidate } from 'uuid' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { PageActor, PageInput } from '../models/pages.ts' +import type { RulePageRef } from '../helpers/pageRules.ts' import { SEARCH_ORDER_BY, 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 * 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 { +export function mayOnPage(req: FastifyRequest, permission: string, page: RulePageRef): boolean { 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'] -export function mayReadSource( - req: FastifyRequest, - page: { path: string; locale?: string; tags?: string[] } -): boolean { +export function mayReadSource(req: FastifyRequest, page: RulePageRef): boolean { 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 * deliberately opened to everyone. */ -export function pagePermissionsFor( - req: FastifyRequest, - page: { path: string; locale?: string; tags?: string[] } -): string[] { +export function pagePermissionsFor(req: FastifyRequest, page: RulePageRef): string[] { const actor = WIKI.models.groups.actorForRequest(req) /* 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) { 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 { @@ -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 page they have no access to is not one to name at them — even to explain a refusal. */ - relations: group.relations.filter((rel) => - mayOnPage(req, 'read:pages', { path: rel.path, locale: rel.locale }) - ) + relations: group.relations.filter((rel) => mayOnPage(req, 'read:pages', { ...rel, siteId })) } } ) @@ -671,7 +661,7 @@ async function routes(app: FastifyInstance) { have in hand yet. */ withContent: req.query.withContent - ? (target: { path: string; locale: string; tags: string[] }) => mayReadSource(req, target) + ? (target: RulePageRef) => mayReadSource(req, target) : false, publicOnly: !actor, // -> 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) { 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.') } const { page, versionId } = await WIKI.models.pages.createPage( @@ -930,6 +932,23 @@ async function routes(app: FastifyInstance) { if (!mayOnPage(req, 'write:pages', target)) { 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( req.params.siteId, 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. */ const destination = { + siteId: req.params.siteId, path: req.body.path.replace(/^\/+/, ''), locale: req.body.locale || target.locale, 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 // 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.') } + // -> `id` and `path` are all the response schema keeps; the locale and tags were for the check return target } ) @@ -1443,7 +1464,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get page user permissions', 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'], params: siteIdParam, body: { @@ -1479,10 +1500,24 @@ async function routes(app: FastifyInstance) { } }, async (req) => { - return pagePermissionsFor(req, { - path: req.body.path.replace(/^\/+/, ''), + const 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 }) + return pagePermissionsFor(req, { + siteId: req.params.siteId, + path, + locale: req.body.locale, + tags + }) } ) } diff --git a/backend/api/schemas/group.ts b/backend/api/schemas/group.ts index a5c5aa7da..ea5e4d946 100644 --- a/backend/api/schemas/group.ts +++ b/backend/api/schemas/group.ts @@ -27,7 +27,8 @@ export async function registerSchemas(app: FastifyInstance): Promise { }, match: { 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'] }, mode: { @@ -40,6 +41,15 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'string', 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: { type: 'array', description: 'Locale codes this rule is limited to. Empty means all locales.', diff --git a/backend/api/tags.ts b/backend/api/tags.ts index 8a3c28bb5..a4c871b43 100644 --- a/backend/api/tags.ts +++ b/backend/api/tags.ts @@ -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 diff --git a/backend/api/tree.ts b/backend/api/tree.ts index 263587bb7..4f21250f4 100644 --- a/backend/api/tree.ts +++ b/backend/api/tree.ts @@ -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 * 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. * @@ -92,7 +101,7 @@ const folderIdParam = { * folder on every listing, which is not worth what it costs. */ function visibleTreeItems( - req: FastifyRequest, + req: SiteRequest, items: T[] ): T[] { const actor = WIKI.models.groups.actorForRequest(req) @@ -100,6 +109,7 @@ function visibleTreeItems - 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) return pages.filter((page) => WIKI.models.groups.checkAccess(actor, 'read:pages', { + siteId: req.params.siteId, path: page.path, - locale: req.query.locale ?? defaultLocale(req.params.siteId) + locale: req.query.locale ?? defaultLocale(req.params.siteId), + tags: page.tags }) ) } diff --git a/backend/controllers/files.ts b/backend/controllers/files.ts index eff62e601..7ff361084 100644 --- a/backend/controllers/files.ts +++ b/backend/controllers/files.ts @@ -52,6 +52,7 @@ async function routes(app: FastifyInstance) { if ( !asset || !WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), 'read:assets', { + siteId: site.id, path: asset.folderPath ? `${asset.folderPath}/${asset.fileName}` : asset.fileName, locale: asset.locale }) diff --git a/backend/helpers/pageRules.ts b/backend/helpers/pageRules.ts index 7a26aac84..539e220b7 100644 --- a/backend/helpers/pageRules.ts +++ b/backend/helpers/pageRules.ts @@ -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 - * 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. + * permissions (`roles`), a way of addressing pages (`match`, with either `path` or `tags`), 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. + * + * 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 * 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 * 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. + * 1. MATCH TYPE, as bands. From weakest to strongest: + * + * Path Starts With < Path Ends With < Path Matches Regex < + * Has Any Tag < Has All Tags < Path Is Exactly + * + * 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. + * + * Three BANDS, because path length below only settles a contest inside one of them: the three + * 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. * - * 2. MATCH TYPE, when two rules are equally specific. From weakest to strongest: + * A tag rule has no path, so without the bands it would score zero at step 2 and lose to every + * rule that named one, which is every rule a group starts with. * - * Has Any Tag < Has All Tags < Path Starts With < Path Ends With < - * Path Matches Regex < Path Is Exactly + * 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. * - * 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. 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. * - * 3. MODE, when two rules are equally specific and of the same kind: + * 4. 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 + * 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` — - * 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`. */ -/** 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 { + siteId: string 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. + * Match kinds from weakest to strongest. 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'] +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 = { + 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. */ 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[] { - return rule.path - .split(',') - .map((tag) => tag.trim().toLowerCase()) - .filter(Boolean) + return (rule.tags ?? []).map((tag) => tag.trim().toLowerCase()).filter(Boolean) } /** 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. * - * 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. + * The length of the path it addresses. A tag rule addresses no path, so every one of them scores + * zero — which says nothing about it, since this is only ever read against another rule of the same + * band and both tag kinds are in the same one. */ function specificityOf(rule: GroupRule): number { - if (rule.match === 'TAG' || rule.match === 'TAGALL') { + if (TAG_MATCHES.includes(rule.match)) { return 0 } 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. */ 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)) { return false } @@ -142,24 +232,14 @@ export function resolvePageRule( page: RulePageRef ): GroupRule | null { let winner: GroupRule | null = null - let winnerRank: [number, number, number] = [-1, -1, -1] + let winnerRank: number[] | null = null 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]))) - ) { + const rank = rankOf(rule) + if (!winnerRank || outranks(rank, winnerRank)) { winner = rule winnerRank = rank } diff --git a/backend/locales/en.json b/backend/locales/en.json index 418bf7c25..b21289b21 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -615,6 +615,9 @@ "admin.groups.ruleMatchTagAll": "Has All Tags...", "admin.groups.rulePath": "Path", "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.rules": "Rules", "admin.groups.rulesNone": "This group doesn't have any rules yet.", diff --git a/backend/models/approvals.ts b/backend/models/approvals.ts index 62817c206..d845daac8 100644 --- a/backend/models/approvals.ts +++ b/backend/models/approvals.ts @@ -445,6 +445,7 @@ class Approvals { WIKI.models.groups.actorForRequest(req), 'review:pages', { + siteId, path: page.path, tags: page.tags } diff --git a/backend/models/groups.ts b/backend/models/groups.ts index b472739db..d1b09d348 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -10,7 +10,7 @@ import type { FastifyRequest } from 'fastify' /** The permission that bypasses every check, and the one the guards below exist to protect. */ 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' /** Whether a matching rule grants, denies, or unconditionally grants its roles. */ @@ -24,6 +24,12 @@ export interface GroupRule { match: GroupRuleMatch mode: GroupRuleMode 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[] sites: string[] } @@ -263,6 +269,7 @@ class Groups { match: 'START', mode: 'ALLOW', path: '', + tags: [], locales: [], sites: [] } @@ -287,6 +294,7 @@ class Groups { match: 'START', mode: 'DENY', path: '', + tags: [], locales: [], sites: [] } @@ -356,6 +364,7 @@ class Groups { match: 'START', mode: 'ALLOW', path: '', + tags: [], locales: [], sites: [] } @@ -408,7 +417,7 @@ class Groups { const result = await WIKI.db .update(groupsTable) .set({ - ...this.clampGuestPatch(id, patch), + ...this.clampGuestPatch(id, this.normalizeRulePatch(patch)), ...(patch.name !== undefined ? { name: patch.name.trim() } : {}), updatedAt: sql`now()` }) @@ -417,6 +426,31 @@ class Groups { 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. * diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 701468cd1..4c919afe9 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -8,6 +8,7 @@ import { } from '../helpers/common.ts' import { invalidateAppShellCache } from '../helpers/appShell.ts' import type { AccessActor } from './groups.ts' +import type { RulePageRef } from '../helpers/pageRules.ts' import type { FastifyRequest } from 'fastify' import type { RenderPermissions, TocNode } from './rendering.ts' import type { DeletedEntry } from './tree.ts' @@ -120,6 +121,12 @@ export interface PageLocaleRelation { locale: string path: 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. */ export interface Page { 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 hash: string alias: string | null @@ -444,6 +457,7 @@ class Pages { const scripts = row.scripts ?? {} return { id: row.id, + siteId: row.siteId, path: row.path, hash: row.hash, alias: row.alias, @@ -519,7 +533,12 @@ class Pages { conditions.push(eq(pagesTable.publishState, 'published')) } 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) .where(and(...conditions)) .orderBy(pagesTable.locale) @@ -550,7 +569,13 @@ class Pages { return null } 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, { localeGroupId: page.localeGroupId, 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 { + 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. */ private async findByPath( siteId: string, @@ -568,6 +624,7 @@ class Pages { locale: string path: string title: string + tags: string[] localeGroupId: string | null } | null> { const rows = await WIKI.db @@ -576,6 +633,7 @@ class Pages { locale: pagesTable.locale, path: pagesTable.path, title: pagesTable.title, + tags: pagesTable.tags, localeGroupId: pagesTable.localeGroupId }) .from(pagesTable) @@ -967,7 +1025,7 @@ class Pages { const guests = WIKI.models.groups.actorForPublic() 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 }) => ({ locale, path, @@ -1064,7 +1122,7 @@ class Pages { if (!row) { return null } - if (!WIKI.models.groups.checkAccess(actor, 'read:pages', row)) { + if (!WIKI.models.groups.checkAccess(actor, 'read:pages', { ...row, siteId })) { return null } @@ -1115,7 +1173,9 @@ class Pages { ) ) .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 // nothing for an annotation to point at 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 * 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. */ publicOnly?: boolean unlocked?: boolean | ((pageId: string) => boolean) @@ -1203,6 +1263,7 @@ class Pages { ? withContent({ path: row.page.path, locale: row.page.locale, + siteId, tags: row.page.tags ?? [] }) : withContent @@ -2283,9 +2344,16 @@ class Pages { async getPathFromAlias( siteId: 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 - .select({ id: pagesTable.id, path: pagesTable.path }) + .select({ + id: pagesTable.id, + path: pagesTable.path, + locale: pagesTable.locale, + tags: pagesTable.tags + }) .from(pagesTable) .where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.alias, alias))) .limit(1) diff --git a/backend/models/search.ts b/backend/models/search.ts index 1bdf97374..581bc818f 100644 --- a/backend/models/search.ts +++ b/backend/models/search.ts @@ -398,6 +398,7 @@ class Search { const visible = actor ? ((rows.rows ?? rows) as any[]).filter((row) => WIKI.models.groups.checkAccess(actor, 'read:pages', { + siteId, path: row.path as string, locale: row.locale as string, tags: (row.tags ?? []) as string[] diff --git a/backend/models/sites.ts b/backend/models/sites.ts index cf5876520..7eaa2a1f1 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -99,141 +99,149 @@ class Sites { } async createSite(hostname: string, config: Record = {}) { - const result = await WIKI.db - .insert(sitesTable) - .values({ - hostname, - isEnabled: true, - config: toMerged( - { - title: 'My Wiki Site', - description: '', - company: '', - contentLicense: '', - footerExtra: '', - banner: { - isEnabled: false, - title: '', - content: '' - }, - pageExtensions: ['md', 'html', 'txt'], - discoverable: false, - defaults: { - tocDepth: { - min: 1, - max: 2 - } - }, - features: { - browse: true, - collaborativeEditing: true, - ratings: false, - ratingsMode: 'off', - // -> On, because what decides whether a site has comments is whether a provider has - // been picked. This is the switch that turns them all off without losing that - // choice, which is only useful to somebody who has already made it. - comments: true, - reasonForChange: 'optional', - search: true - }, - /* + /* + The whole configuration the site is created with, defaults and caller's together. Read back + below rather than reading `config` again: the caller supplies the handful of fields the create + form asks for, so anything else looked up there is undefined — which is what made creating a + site through the API fail on `locales.primary` every time. + */ + const siteConfig = toMerged( + { + title: 'My Wiki Site', + description: '', + company: '', + contentLicense: '', + footerExtra: '', + banner: { + isEnabled: false, + title: '', + content: '' + }, + pageExtensions: ['md', 'html', 'txt'], + discoverable: false, + defaults: { + tocDepth: { + min: 1, + max: 2 + } + }, + features: { + browse: true, + collaborativeEditing: true, + ratings: false, + ratingsMode: 'off', + // -> On, because what decides whether a site has comments is whether a provider has + // been picked. This is the switch that turns them all off without losing that + // choice, which is only useful to somebody who has already made it. + comments: true, + reasonForChange: 'optional', + search: true + }, + /* The wiki's own provider, so that a site with comments turned on has somewhere for them to go without an administrator having to choose first. Every alternative is somebody else's service with an account to open; this one needs nothing set up. Whether there are comments at all is `features.comments` above -- see `models/comments.ts`. */ - comments: { - provider: 'default', - providers: {} - }, - logoUrl: '', - logoText: true, - sitemap: true, - robots: { - index: true, - follow: true - }, - // -> Local authentication is the only strategy guaranteed to exist at this point - authStrategies: [{ id: WIKI.data.systemIds.localAuthId, order: 0, isVisible: true }], - auth: { - autoLogin: false, - bypassUnauthorized: false, - hideLocal: false, - loginRedirect: '/', - welcomeRedirect: '/', - logoutRedirect: '/' - }, - locales: { - primary: 'en', - active: ['en'], - forcePrefix: false, - showMenu: true - }, - assets: { - logo: false, - favicon: false, - loginBg: false - }, - theme: { - dark: false, - codeBlocksTheme: 'github-dark', - colorPrimary: '#1976D2', - colorSecondary: '#02C39A', - colorAccent: '#FF9800', - colorHeader: '#000000', - colorSidebar: '#1976D2', - injectCSS: '', - injectHead: '', - injectBody: '', - contentWidth: 'full', - sidebarPosition: 'left', - tocPosition: 'right', - showPrintBtn: true, - baseFont: 'roboto', - contentFont: 'roboto' - }, - editors: { - asciidoc: { - isActive: true, - config: {} - }, - markdown: { - isActive: true, - config: { - allowHTML: true, - lineBreaks: true, - linkify: true, - multimdTable: true, - quotes: 'english', - tabWidth: 2, - typographer: false, - underline: true - } - }, - wysiwyg: { - isActive: true, - config: {} - } - }, - uploads: { - conflictBehavior: 'overwrite', - pastedDestination: '' - }, - storage: { - largeThreshold: '25MB', - sitePrefix: false, - localePrefix: true, - syncInterval: '5m', - directAccessFallback: 'stream' - }, - // -> Keyed by the directory name under `modules/analytics`. Empty until an administrator - // turns a provider on; the model completes each one from the module's declared props. - analytics: { - providers: {} + comments: { + provider: 'default', + providers: {} + }, + logoUrl: '', + logoText: true, + sitemap: true, + robots: { + index: true, + follow: true + }, + // -> Local authentication is the only strategy guaranteed to exist at this point + authStrategies: [{ id: WIKI.data.systemIds.localAuthId, order: 0, isVisible: true }], + auth: { + autoLogin: false, + bypassUnauthorized: false, + hideLocal: false, + loginRedirect: '/', + welcomeRedirect: '/', + logoutRedirect: '/' + }, + locales: { + primary: 'en', + active: ['en'], + forcePrefix: false, + showMenu: true + }, + assets: { + logo: false, + favicon: false, + loginBg: false + }, + theme: { + dark: false, + codeBlocksTheme: 'github-dark', + colorPrimary: '#1976D2', + colorSecondary: '#02C39A', + colorAccent: '#FF9800', + colorHeader: '#000000', + colorSidebar: '#1976D2', + injectCSS: '', + injectHead: '', + injectBody: '', + contentWidth: 'full', + sidebarPosition: 'left', + tocPosition: 'right', + showPrintBtn: true, + baseFont: 'roboto', + contentFont: 'roboto' + }, + editors: { + asciidoc: { + isActive: true, + config: {} + }, + markdown: { + isActive: true, + config: { + allowHTML: true, + lineBreaks: true, + linkify: true, + multimdTable: true, + quotes: 'english', + tabWidth: 2, + typographer: false, + underline: true } }, - config - ) + wysiwyg: { + isActive: true, + config: {} + } + }, + uploads: { + conflictBehavior: 'overwrite', + pastedDestination: '' + }, + storage: { + largeThreshold: '25MB', + sitePrefix: false, + localePrefix: true, + syncInterval: '5m', + directAccessFallback: 'stream' + }, + // -> Keyed by the directory name under `modules/analytics`. Empty until an administrator + // turns a provider on; the model completes each one from the module's declared props. + analytics: { + providers: {} + } + }, + config + ) + + const result = await WIKI.db + .insert(sitesTable) + .values({ + hostname, + isEnabled: true, + config: siteConfig }) .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 // one the first time a page is written in them 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 await WIKI.models.sites.reloadCache() diff --git a/backend/models/tags.ts b/backend/models/tags.ts index a6b189b05..81bd63dde 100644 --- a/backend/models/tags.ts +++ b/backend/models/tags.ts @@ -59,6 +59,7 @@ class Tags { const counts = new Map() for (const row of (result.rows ?? result) as any[]) { const page = { + siteId, path: row.path as string, locale: row.locale 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)) .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 { + 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() diff --git a/backend/models/tree.ts b/backend/models/tree.ts index 6a3b40eda..4a9259191 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -78,6 +78,12 @@ export interface BrowseItem { icon: string | null isPage: 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. */ @@ -100,6 +106,12 @@ export interface ListedPage { description: string /** The page's icon, as an Iconify reference. Empty when it has none. */ 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, fileName: treeTable.fileName, title: treeTable.title, + tags: treeTable.tags, description: pagesTable.description, icon: pagesTable.icon }) @@ -427,7 +440,8 @@ class Tree { path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName, title: row.title, description: row.description ?? '', - icon: row.icon ?? '' + icon: row.icon ?? '', + tags: row.tags ?? [] } }) } @@ -522,6 +536,7 @@ class Tree { type: treeTable.type, fileName: treeTable.fileName, title: treeTable.title, + tags: treeTable.tags, icon: pagesTable.icon, holdsVisiblePages: sql`${holdsVisiblePages}`.mapWith(Boolean) }) @@ -552,15 +567,18 @@ class Tree { title: row.title, icon: null, isPage: false, - isFolder: false + isFolder: false, + tags: [] } if (row.type === 'folder') { entry.isFolder = true } else { 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.icon = row.icon + entry.tags = row.tags ?? [] } merged.set(row.fileName, entry) } diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index 49f67fb75..bad2fdd15 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -408,7 +408,33 @@ { label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' }, { label: t('admin.groups.ruleMatchExact'), value: 'EXACT' } ]" /> + + + + 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 watch(() => route.params.section, checkRoute) @@ -1073,7 +1111,12 @@ async function fetchGroup() { if (!resp?.id) { 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 } catch (err) { notify({ @@ -1116,6 +1159,45 @@ async function save() { 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() { state.group.rules.push({ id: uuid(), @@ -1124,6 +1206,7 @@ function newRule() { match: 'START', roles: [], path: '', + tags: [], locales: [], sites: [] }) @@ -1187,6 +1270,7 @@ async function importRules() { : 'START', roles: r.roles || [], 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)), sites: r.sites.filter((s) => adminStore.sites.some((site) => site.id === s)) })) @@ -1301,6 +1385,7 @@ async function unassignUser(user) { onMounted(() => { checkRoute() fetchGroup() + fetchTags() })