feat: glossary (wip) + improved permission picker + new library menu

This commit is contained in:
NGPixel
2026-10-06 02:19:10 -04:00
parent 26ba03716a
commit f4c60a2e1a
50 changed files with 14036 additions and 443 deletions
+3 -1
View File
@@ -114,7 +114,7 @@ jobs:
ghcr.io/requarks/wiki
tags: |
type=semver,pattern={{version}},value=${{ env.REL_VERSION }}
type=raw,value=3.0.0-beta
type=raw,value=3.0.0-beta,enable=${{ github.event_name == 'workflow_dispatch' }}
labels: |
org.opencontainers.image.title=Wiki.js
org.opencontainers.image.description=Wiki.js - Next Generation Open Source Wiki
@@ -155,9 +155,11 @@ jobs:
# BUILD HELM/OCI CHARTS
# ---------------------------------------------------
- name: Set up Helm
if: github.event_name == 'workflow_dispatch'
uses: azure/setup-helm@v5
- name: Package and push Helm chart
if: github.event_name == 'workflow_dispatch'
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+41 -3
View File
@@ -572,7 +572,7 @@ that is already on it needs no permission from anybody.
**Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`,
`review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`,
`read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`,
`manage:comments`, `manage:navigation` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**:
`manage:comments`, `manage:navigation`, `read:glossary`, `manage:glossary` (`PAGE_PERMISSIONS` in `models/groups.ts`). A group grants them through **rules**:
each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and
what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when
several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering.
@@ -603,6 +603,22 @@ Consequences worth knowing:
it rewrite the menu handed down to it. `api/navigation.ts` has the pair of checks
(`mayManageNavAt`, `mayEditNavItems`), and the `inherited` route answers `canEditItems` so the
editor knows which of its two halves to offer.
- **`read:glossary` and `manage:glossary` ignore the path.** They are the page permissions about a
site and a locale rather than a page (`LOCALE_PERMISSIONS` in `models/groups.ts`): every rule naming
one that is scoped to the site and locale applies, whatever it matches, and mode alone decides —
ALLOW < DENY < FORCE ALLOW. So a DENY written for one folder closes the whole glossary. Ask
`groups.checkLocaleAccess(actor, permission, siteId, locale)`; `checkAccess` routes them there too.
`manage:glossary` in a locale implies `read:glossary` there. Neither is in the Guests group's seeded
DENY rule, where it would outrank any ALLOW an administrator wrote for guests later.
- **A page that is not live is its editors' alone.** Live is `published`, or `scheduled` inside its
window (`publishStartDate` inclusive, `publishEndDate` exclusive); the dates gate nothing on a
`published` page, where `publishStartDate` is what a blog post is dated by. Any other page is seen —
render, source, history, comments, a listing or search result — only by a signed-in session holding
`write:pages` or `manage:pages` on it; `read:pages` and `read:source` are not enough, and a request
without a session (an API key included) never sees one. `helpers/publishing.ts` is the rule:
`liveCondition` for SQL, `maySeeUnpublished` per page, and `unpublishedFor(req)` in `api/pages.ts`
is what a reader-facing `getPage` passes as `unpublished`. That option defaults to open because most
callers are the wiki acting on its own pages, so a new route answering a reader has to pass it.
- **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
required (`reviewerFor` in `api/approvals.ts` is the worked example).
@@ -1074,8 +1090,8 @@ document is whether the client will run the app, and each has its own function:
— and therefore the only half that is cached and handed on.
- **`fragmentsForBrowser`** — for a browser that will. The head alone, describing the page as THAT
requester may see it (`pages.describePageForRequest`), which makes exactly the cut the page route
makes: `read:pages` per path, and any signed-in session sees an unpublished page while an API key is
answered as the public is. Never cached, because the answer is one reader's. It carries no body (the
makes: `read:pages` per path, and a page that is not live only for a session that may edit it (see
[Permissions](#permissions)). Never cached, because the answer is one reader's. It carries no body (the
app is about to draw the page properly, so a copy is bytes on every hard navigation for content the
browser discards), never 404s (whether a path this reader may create is empty is the app's own flow
to present) and adds no page-specific `noindex`.
@@ -1190,6 +1206,28 @@ instead. A `num` placeholder that is not a number drops its whole snippet for th
a prop that had to be kept out of a browser could not be used by a provider in the first place. This
is why `api/analytics.ts` is the one module-prop surface with no `maskSensitiveProps` on the way out.
### Glossary
Terms per site and per locale, read and edited in an overlay opened from the header's **Library**
menu. **`dev/specs/glossary.md` is the design**; `models/glossary.ts`, `api/glossary.ts`,
`components/GlossaryOverlay.vue`.
- **A name means one term.** A term's name and its aliases share one namespace per site and locale,
compared case-insensitively. The unique index covers term against term; aliases are checked by the
model, which answers 409 naming the holder. `caseSensitive` is about matching in text, never about
uniqueness.
- **Related terms are two-way**: one row per pair in `glossaryTermRelations`, smaller id first, read
from both sides. Saving a term's list replaces every pair touching it.
- **A save names the `updatedAt` it was opened with** (`expectedUpdatedAt`), compared at millisecond
precision inside the UPDATE; a mismatch is 409 with the term as it now stands. Dates are written
from JS rather than `now()` so that what is stored is what the client was given.
- **The definition is basic markdown, rendered in the browser** by `renderers/glossary.js` — the
comment renderer's feature set without `nofollow ugc` or mentions. Never HTML.
- **The frontend learns access from `GET /sites/:siteId/glossary/access`** (`stores/glossary.js`),
once per site and session — the locales the session may read in and may edit in. Neither permission
changes from page to page, and the per-page permissions are empty on every route that is not a page.
- **`autoLink` is stored and not yet acted on**; the spec's §9 is the design it is waiting for.
### Comments
Two things wearing one name, and `models/comments.ts` is the seam between them. **Only one provider
+4 -5
View File
@@ -1,25 +1,24 @@
import { audit } from '../helpers/audit.ts'
import { CustomError } from '../helpers/common.ts'
import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor } from './pages.ts'
import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
import type { ApprovalPageRef, ApprovalRulePatch, ReviewerScope } from '../models/approvals.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/**
* The page a suggestion is about, with the source it would be edited from.
*
* Loaded the way the public page route loads it — an anonymous reader sees published pages only, and a
* password still has to have been entered — so eligibility to suggest an edit never becomes a way to
* Loaded the way the public page route loads it — a page that is not live is there only for whoever
* may edit it, and a password still has to have been entered — so eligibility to suggest an edit never becomes a way to
* read something that was not readable. The source itself is fetched regardless of who is asking,
* because the caller has to be able to edit what they are looking at; the routes below only hand it
* over once a rule says this actor may suggest edits to this page.
*/
async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId: string) {
const actor = actorFrom(req)
const page = await WIKI.models.pages.getPage({
siteId,
id: pageId,
withContent: true,
publicOnly: !actor,
unpublished: unpublishedFor(req),
unlocked: (id: string) => unlockedFor(req, id),
withPassword: mayBypassPassword(req)
})
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { mayOnPage } from './pages.ts'
import { mayOnPage, unpublishedFor } from './pages.ts'
import { normalizePagePath } from '../helpers/common.ts'
const siteIdParam = {
@@ -186,7 +186,7 @@ async function routes(app: FastifyInstance) {
siteId: req.params.siteId,
blog,
actor: WIKI.models.groups.actorForRequest(req),
publicOnly: !req.session?.authenticated,
unpublished: unpublishedFor(req),
filter: {
tag: req.query.tag,
year: req.query.year,
+11 -4
View File
@@ -1,6 +1,6 @@
import { audit } from '../helpers/audit.ts'
import { maskSensitiveProps } from '../helpers/common.ts'
import { mayOnPage } from './pages.ts'
import { mayOnPage, unpublishedFor } from './pages.ts'
import {
ANNOTATION_NOTE_MAX_LENGTH,
ANNOTATIONS_MAX,
@@ -988,14 +988,21 @@ async function requireBuiltInPage(
reply.notFound('This site does not use the built-in comments provider.')
return null
}
const page = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId)
if (!page) {
const row = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId)
if (!row) {
reply.notFound('This page does not exist.')
return null
}
// -> 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 }
const { isLive, ...ref } = { ...row, siteId: req.params.siteId }
// -> A page that is not live is not there for its discussion either, on the page view's own terms
const unpublished = unpublishedFor(req)
if (!isLive && !(unpublished && unpublished(ref))) {
reply.notFound('This page does not exist.')
return null
}
return ref
}
/**
+519
View File
@@ -0,0 +1,519 @@
import { audit } from '../helpers/audit.ts'
import { GLOSSARY_INPUT_PROPERTIES } from './schemas/glossary.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { GlossaryTermInput, GlossaryWriteResult } from '../models/glossary.ts'
const siteIdParam = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' }
},
required: ['siteId']
}
const termIdParam = {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
termId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'termId']
}
const localeQuery = {
type: 'object',
properties: {
locale: { type: 'string', minLength: 1, maxLength: 255 }
},
required: ['locale']
}
const conflictResponse = {
description:
'Another term already holds one of the names (`nameTaken`), or somebody saved this term since it was opened (`stale`, with the term as it now stands).',
$ref: 'GlossaryConflict#'
}
/** Whether the caller may read this site's glossary in this locale — `read:glossary`, path ignored. */
function mayRead(req: FastifyRequest, siteId: string, locale: string): boolean {
return WIKI.models.groups.checkLocaleAccess(
WIKI.models.groups.actorForRequest(req),
'read:glossary',
siteId,
locale
)
}
/** Whether the caller may edit this site's glossary in this locale — `manage:glossary`, path ignored. */
function mayManage(req: FastifyRequest, siteId: string, locale: string): boolean {
return WIKI.models.groups.checkLocaleAccess(
WIKI.models.groups.actorForRequest(req),
'manage:glossary',
siteId,
locale
)
}
/**
* The checks every write to an existing term makes: the site and its switch, the term, then
* `manage:glossary` in the term's own locale. A term the caller may not even read answers 404, as it
* does on the read route.
*
* @returns The term's locale when the handler may go on; when not, the reply has been sent
*/
async function guardWrite(
req: FastifyRequest,
reply: FastifyReply,
siteId: string,
termId: string
): Promise<string | null> {
if (!WIKI.sites[siteId] || !WIKI.models.glossary.isEnabled(siteId)) {
reply.notFound('This site has no glossary.')
return null
}
const locale = await WIKI.models.glossary.localeOf(siteId, termId)
if (!locale || !mayRead(req, siteId, locale)) {
reply.notFound('This term does not exist.')
return null
}
if (!mayManage(req, siteId, locale)) {
reply.forbidden('You may not edit the glossary in this locale.')
return null
}
return locale
}
/**
* The checks every locale-scoped read makes, in the order that leaks least: the site, then the
* permission, then whether the locale is one the site offers.
*
* @returns Whether the handler may go on; when not, the reply has been sent
*/
function guardRead(req: FastifyRequest, reply: FastifyReply, siteId: string, locale: string) {
if (!WIKI.sites[siteId]) {
reply.notFound('Site does not exist.')
return false
}
if (!mayRead(req, siteId, locale)) {
reply.forbidden('You may not read the glossary in this locale.')
return false
}
if (!WIKI.models.glossary.isActiveLocale(siteId, locale)) {
reply.badRequest('This locale is not active on this site.')
return false
}
return true
}
/** Send a refused write as the status it stands for. */
function sendRefusal(
reply: FastifyReply,
result: Exclude<GlossaryWriteResult, { ok: true }>
): FastifyReply {
switch (result.reason) {
case 'notFound':
return reply.notFound('This term does not exist.')
case 'invalidRelated':
return reply.badRequest(
'A related term does not exist, is in another locale, or is the term itself.'
)
case 'nameTaken':
return reply.code(409).send({
ok: false,
statusCode: 409,
error: 'Conflict',
message: `"${result.name}" is already used by the term "${result.holder.term}".`,
reason: 'nameTaken',
field: result.field,
name: result.name,
holder: result.holder
})
case 'stale':
return reply.code(409).send({
ok: false,
statusCode: 409,
error: 'Conflict',
message: 'This term was changed by somebody else since it was opened.',
reason: 'stale',
current: result.current
})
}
}
/**
* Glossary API Routes
*
* A site's glossary, one per locale. See `dev/specs/glossary.md`.
*
* **Reading** is `read:glossary` and **writing** `manage:glossary`, both PAGE permissions whose rule's
* path is ignored: they are asked per site and locale. So no route here declares route-level
* `permissions` (the hook reads the group-wide list only), and every one asks
* `groups.checkLocaleAccess` in the handler — a write against the locale of the term it touches.
*
* With `features.glossary` off the reads answer as though there were no terms and the writes 404, so
* that the switch hides the glossary without deleting it.
*/
async function routes(app: FastifyInstance) {
/**
* GLOSSARY ACCESS
*/
// -> No route-level permissions: what it reports IS the permission, per locale
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/glossary/access',
{
schema: {
summary: 'What the caller may do with the glossary',
description:
"Whether the site has a glossary, and the active locales the caller may read it in and edit it in. This is what decides whether the interface offers the glossary at all; it is asked once per site and session rather than per page, since neither permission depends on the page.\n\n`read:glossary` and `manage:glossary` are page permissions whose rule's path is ignored: every rule naming one that is scoped to the site and the locale applies, ALLOW < DENY < FORCE ALLOW. `manage:glossary` in a locale implies `read:glossary` there.",
tags: ['Glossary'],
params: siteIdParam,
response: {
200: {
description: 'Glossary access of the caller',
type: 'object',
properties: {
enabled: {
type: 'boolean',
description: 'The site’s `features.glossary` switch.'
},
readableLocales: {
type: 'array',
items: { type: 'string' },
description: 'The active locales the caller may read the glossary in.'
},
manageableLocales: {
type: 'array',
items: { type: 'string' },
description: 'The active locales the caller may edit the glossary in.'
}
}
}
}
}
},
async (req, reply) => {
const site = WIKI.sites[req.params.siteId]
if (!site) {
return reply.notFound('Site does not exist.')
}
const active: string[] = site.config?.locales?.active ?? []
return {
enabled: WIKI.models.glossary.isEnabled(req.params.siteId),
readableLocales: active.filter((locale) => mayRead(req, req.params.siteId, locale)),
manageableLocales: active.filter((locale) => mayManage(req, req.params.siteId, locale))
}
}
)
/**
* LIST TERMS
*/
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>(
'/sites/:siteId/glossary',
{
schema: {
summary: 'List the glossary terms of a locale',
description:
'Every term in the locale, A–Z, with what the glossary’s list shows. The definition and the rest come from the single-term route.\n\nNeeds `read:glossary` in the locale. Empty while the site’s glossary is switched off.',
tags: ['Glossary'],
params: siteIdParam,
querystring: localeQuery,
response: {
200: {
description: 'The terms of the locale, A–Z',
type: 'array',
items: { $ref: 'GlossaryTermSummary#' }
}
}
}
},
async (req, reply) => {
if (!guardRead(req, reply, req.params.siteId, req.query.locale)) {
return reply
}
if (!WIKI.models.glossary.isEnabled(req.params.siteId)) {
return []
}
return WIKI.models.glossary.list(req.params.siteId, req.query.locale)
}
)
/**
* LIST CATEGORIES
*/
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>(
'/sites/:siteId/glossary/categories',
{
schema: {
summary: 'List the glossary categories of a locale',
description:
'Every category a term in the locale carries, A–Z — what the form suggests.\n\nNeeds `read:glossary` in the locale. Empty while the site’s glossary is switched off.',
tags: ['Glossary'],
params: siteIdParam,
querystring: localeQuery,
response: {
200: {
description: 'Categories in use, A–Z',
type: 'array',
items: { type: 'string' }
}
}
}
},
async (req, reply) => {
if (!guardRead(req, reply, req.params.siteId, req.query.locale)) {
return reply
}
if (!WIKI.models.glossary.isEnabled(req.params.siteId)) {
return []
}
return WIKI.models.glossary.categories(req.params.siteId, req.query.locale)
}
)
/**
* LOOK UP A TERM BY NAME
*/
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
app.get<{ Params: { siteId: string }; Querystring: { locale: string; name: string } }>(
'/sites/:siteId/glossary/lookup',
{
schema: {
summary: 'Find a glossary term by name',
description:
'The term a name belongs to, as its own name or as one of its aliases, compared without regard to case. What a link to a term by name resolves through.\n\nNeeds `read:glossary` in the locale.',
tags: ['Glossary'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
locale: { type: 'string', minLength: 1, maxLength: 255 },
name: { type: 'string', minLength: 1, maxLength: 255 }
},
required: ['locale', 'name']
},
response: {
200: {
description: 'The term the name belongs to',
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' }
}
}
}
}
},
async (req, reply) => {
if (!guardRead(req, reply, req.params.siteId, req.query.locale)) {
return reply
}
const id = WIKI.models.glossary.isEnabled(req.params.siteId)
? await WIKI.models.glossary.lookup(req.params.siteId, req.query.locale, req.query.name)
: null
if (!id) {
return reply.notFound('No term goes by this name.')
}
return { id }
}
)
/**
* GET A TERM
*/
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
app.get<{ Params: { siteId: string; termId: string } }>(
'/sites/:siteId/glossary/:termId',
{
schema: {
summary: 'Get a glossary term',
description:
'A term in full: its definition, related terms (from both sides), documentation page as it now resolves, and references.\n\nNeeds `read:glossary` in the term’s locale.',
tags: ['Glossary'],
params: termIdParam,
response: {
200: {
description: 'The term',
$ref: 'GlossaryTerm#'
}
}
}
},
async (req, reply) => {
const term = WIKI.models.glossary.isEnabled(req.params.siteId)
? await WIKI.models.glossary.get(req.params.siteId, req.params.termId)
: null
// -> One answer for a term that is not there and one the caller may not read: the difference
// is not something to tell whoever is asking
if (!term || !mayRead(req, req.params.siteId, term.locale)) {
return reply.notFound('This term does not exist.')
}
return term
}
)
/**
* CREATE A TERM
*/
// -> No route-level permissions: `manage:glossary` is a page rule. See the note above.
app.post<{ Params: { siteId: string }; Body: GlossaryTermInput & { locale: string } }>(
'/sites/:siteId/glossary',
{
schema: {
summary: 'Create a glossary term',
description:
'Needs `manage:glossary` in the locale.\n\nThe term’s name and its aliases must not be held by any other term of the locale, as a name or an alias, compared without regard to case; a clash answers 409 naming the term that holds it.\n\nThe locale is fixed here for good: a term in another locale is a different term.',
tags: ['Glossary'],
params: siteIdParam,
body: {
type: 'object',
required: ['locale', 'term'],
properties: {
locale: { type: 'string', minLength: 1, maxLength: 255 },
...GLOSSARY_INPUT_PROPERTIES
}
},
response: {
200: {
description: 'The term as created',
$ref: 'GlossaryTerm#'
},
409: conflictResponse
}
}
},
async (req, reply) => {
const { siteId } = req.params
if (!WIKI.sites[siteId] || !WIKI.models.glossary.isEnabled(siteId)) {
return reply.notFound('This site has no glossary.')
}
if (!mayManage(req, siteId, req.body.locale)) {
return reply.forbidden('You may not edit the glossary in this locale.')
}
if (!WIKI.models.glossary.isActiveLocale(siteId, req.body.locale)) {
return reply.badRequest('This locale is not active on this site.')
}
const result = await WIKI.models.glossary.create(
siteId,
req.body.locale,
req.body,
req.session?.user?.id ?? null
)
if (!result.ok) {
return sendRefusal(reply, result)
}
await audit(req, 'glossary', 'createGlossaryTerm', {
siteId,
locale: result.term.locale,
termId: result.term.id,
term: result.term.term
})
return result.term
}
)
/**
* UPDATE A TERM
*/
// -> No route-level permissions: `manage:glossary` is a page rule. See the note above.
app.put<{
Params: { siteId: string; termId: string }
Body: GlossaryTermInput & { expectedUpdatedAt: string }
}>(
'/sites/:siteId/glossary/:termId',
{
schema: {
summary: 'Update a glossary term',
description:
'Needs `manage:glossary` in the term’s locale.\n\nReplaces the term, related terms included: a term taken out of `relatedTerms` loses this one from its own list too.\n\n`expectedUpdatedAt` is the `updatedAt` the term was opened with. When somebody has saved it since, nothing is written and the answer is 409 with the term as it now stands; re-sending with that `updatedAt` saves over it.',
tags: ['Glossary'],
params: termIdParam,
body: {
type: 'object',
required: ['term', 'expectedUpdatedAt'],
properties: {
...GLOSSARY_INPUT_PROPERTIES,
expectedUpdatedAt: { type: 'string', format: 'date-time' }
}
},
response: {
200: {
description: 'The term as saved',
$ref: 'GlossaryTerm#'
},
409: conflictResponse
}
}
},
async (req, reply) => {
const { siteId, termId } = req.params
if (!(await guardWrite(req, reply, siteId, termId))) {
return reply
}
const result = await WIKI.models.glossary.update(
siteId,
termId,
req.body,
req.body.expectedUpdatedAt,
req.session?.user?.id ?? null
)
if (!result.ok) {
return sendRefusal(reply, result)
}
await audit(req, 'glossary', 'updateGlossaryTerm', {
siteId,
locale: result.term.locale,
termId,
term: result.term.term,
changedFields: result.changedFields
})
return result.term
}
)
/**
* DELETE A TERM
*/
// -> No route-level permissions: `manage:glossary` is a page rule. See the note above.
app.delete<{ Params: { siteId: string; termId: string } }>(
'/sites/:siteId/glossary/:termId',
{
schema: {
summary: 'Delete a glossary term',
description:
'Needs `manage:glossary` in the term’s locale. The term leaves every other term’s related list with it.',
tags: ['Glossary'],
params: termIdParam,
response: {
200: {
description: 'Term deleted',
type: 'object',
properties: {
ok: { type: 'boolean' }
}
}
}
}
},
async (req, reply) => {
const { siteId, termId } = req.params
if (!(await guardWrite(req, reply, siteId, termId))) {
return reply
}
const deleted = await WIKI.models.glossary.remove(siteId, termId)
if (!deleted) {
return reply.notFound('This term does not exist.')
}
await audit(req, 'glossary', 'deleteGlossaryTerm', {
siteId,
locale: deleted.locale,
termId,
term: deleted.term
})
return { ok: true }
}
)
}
export default routes
+2
View File
@@ -15,6 +15,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/comments.ts').then((m) => m.registerSchemas(app))
await import('./schemas/extension.ts').then((m) => m.registerSchemas(app))
await import('./schemas/flags.ts').then((m) => m.registerSchemas(app))
await import('./schemas/glossary.ts').then((m) => m.registerSchemas(app))
await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
await import('./schemas/hook.ts').then((m) => m.registerSchemas(app))
await import('./schemas/icon.ts').then((m) => m.registerSchemas(app))
@@ -43,6 +44,7 @@ async function routes(app: FastifyInstance) {
app.register(import('./blogs.ts'))
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })
app.register(import('./comments.ts'))
app.register(import('./glossary.ts'))
app.register(import('./groups.ts'), { prefix: '/groups' })
app.register(import('./hooks.ts'), { prefix: '/hooks' })
app.register(import('./icons.ts'), { prefix: '/icons' })
+34 -18
View File
@@ -10,6 +10,7 @@ import {
type SearchTagsMatch
} from '../models/search.ts'
import { audit } from '../helpers/audit.ts'
import { maySeeUnpublished, maySeeUnpublishedAnywhere } from '../helpers/publishing.ts'
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
@@ -152,6 +153,26 @@ export function mayReadSource(req: FastifyRequest, page: RulePageRef): boolean {
return SOURCE_PERMISSIONS.some((permission) => mayOnPage(req, permission, page))
}
/**
* What `getPage`'s `unpublished` is for this requester: whether a page that is not live — a draft, or
* a scheduled page outside its window — may be handed to them.
*
* Only to a signed-in session holding `write:pages` or `manage:pages` on the page itself, which are
* the people working on it. Reading it, its source included, is not enough. A request without a
* session is held to live pages outright, API keys included — see `helpers/publishing.ts`.
*/
export function unpublishedFor(req: FastifyRequest): false | ((page: RulePageRef) => boolean) {
if (!actorFrom(req)) {
return false
}
const actor = WIKI.models.groups.actorForRequest(req)
// -> Settled in SQL for the reader who may edit nothing, which is most of them
if (!maySeeUnpublishedAnywhere(actor)) {
return false
}
return (page) => maySeeUnpublished(actor, page)
}
/**
* Every page permission this requester holds at a path.
*
@@ -181,16 +202,15 @@ export function pagePermissionsFor(req: FastifyRequest, page: RulePageRef): stri
/**
* A page, as this requester is allowed to see it — or null when they are not allowed to see it at all.
*
* The gate for anything that hangs off a page but is not the page itself. An anonymous requester only
* ever reaches a published page, and a password-protected one comes back with `isLocked` set until the
* The gate for anything that hangs off a page but is not the page itself. A page that is not live is
* only reached by whoever may edit it (`unpublishedFor`), and a password-protected one comes back with `isLocked` set until the
* session has satisfied the unlock, which the caller is expected to refuse on.
*/
async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: string) {
const actor = actorFrom(req)
const page = await WIKI.models.pages.getPage({
siteId,
id: pageId,
publicOnly: !actor,
unpublished: unpublishedFor(req),
unlocked: (id: string) => unlockedFor(req, id)
})
// -> Not readable is indistinguishable from not there, for anything hanging off the page
@@ -447,8 +467,6 @@ async function routes(app: FastifyInstance) {
}
},
async (req) => {
const actor = actorFrom(req)
const permissions = actor?.permissions ?? []
return WIKI.models.search.searchPages({
siteId: req.params.siteId,
query: req.query.query,
@@ -464,13 +482,11 @@ async function routes(app: FastifyInstance) {
orderByDirection: req.query.orderByDirection,
offset: req.query.offset,
limit: req.query.limit,
publicOnly: !actor,
// -> So that a page the caller could not open never shows up as a result
actor: WIKI.models.groups.actorForRequest(req),
// -> An unpublished page is only of interest to someone who could have written it
includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) =>
permissions.includes(p)
),
// -> The page view's rule, asked per result: a page that is not live is only listed for
// whoever may edit it, and never without a session
unpublished: Boolean(actorFrom(req)),
// -> Same rule as the page view: a protected page's text is for whoever holds the password, and
// a search excerpt is that text. Its title and description are not covered, so the page is
// still listed — see `hideProtectedContent`.
@@ -488,7 +504,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Get a page for inclusion',
description:
"What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — an anonymous request only ever sees published pages, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.",
"What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — a draft, or a scheduled page outside its publishing window, is only there for a signed-in requester holding `write:pages` or `manage:pages` on it, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.",
tags: ['Pages'],
params: siteIdParam,
querystring: {
@@ -513,7 +529,6 @@ async function routes(app: FastifyInstance) {
}
},
async (req, reply) => {
const actor = actorFrom(req)
// -> The stored form of whatever the including page wrote, since that is what it is looked up
// by. The site root is the `home` page.
const path = normalizePagePath(req.query.path)
@@ -521,7 +536,7 @@ async function routes(app: FastifyInstance) {
siteId: req.params.siteId,
hash: generatePathHash(path || 'home'),
locale: req.query.locale,
publicOnly: !actor,
unpublished: unpublishedFor(req),
unlocked: (pageId) => unlockedFor(req, pageId),
withPassword: false
})
@@ -625,7 +640,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Get a single page',
description:
"Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.",
"Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in. A page that is not live — a draft, or a scheduled page outside its publishing window — answers 404 to everybody but a signed-in requester holding `write:pages` or `manage:pages` on it, its source included. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.",
tags: ['Pages'],
params: {
type: 'object',
@@ -677,7 +692,9 @@ async function routes(app: FastifyInstance) {
withContent: req.query.withContent
? (target: RulePageRef) => mayReadSource(req, target)
: false,
publicOnly: !actor,
// -> A draft, or a scheduled page outside its window, is not there for anybody who may not
// edit it — which is what keeps its render and its source alike from a reader
unpublished: unpublishedFor(req),
// -> Answered once the page is known, since a hash does not say which page it is yet
unlocked: (pageId) => unlockedFor(req, pageId),
withPassword: mayBypassPassword(req)
@@ -819,13 +836,12 @@ async function routes(app: FastifyInstance) {
},
async (req, reply) => {
const isId = uuidValidate(req.params.pageIdOrHash)
const actor = actorFrom(req)
const page = await WIKI.models.pages.unlockPage({
siteId: req.params.siteId,
...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }),
locale: req.query.locale,
password: req.body.password,
publicOnly: !actor
unpublished: unpublishedFor(req)
})
if (!page) {
return reply.unauthorized('Incorrect password.')
+2 -1
View File
@@ -1,5 +1,5 @@
import { audit } from '../helpers/audit.ts'
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
import { actorFrom, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import type { RatingMode } from '../models/pageRatings.ts'
import type { Page } from '../models/pages.ts'
@@ -51,6 +51,7 @@ async function loadRateable(
const page = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
id: req.params.pageId,
unpublished: unpublishedFor(req),
unlocked: (id: string) => unlockedFor(req, id)
})
if (!page || !mayOnPage(req, 'read:pages', page)) {
+204
View File
@@ -0,0 +1,204 @@
import {
GLOSSARY_ALIASES_MAX,
GLOSSARY_DEFINITION_MAX_LENGTH,
GLOSSARY_NAME_MAX_LENGTH,
GLOSSARY_REFERENCES_MAX,
GLOSSARY_RELATED_MAX
} from '../../models/glossary.ts'
import type { FastifyInstance } from 'fastify'
/** The properties of a term the sidebar lists, shared by the summary and the full term. */
const summaryProperties = {
id: { type: 'string', format: 'uuid' },
term: { type: 'string' },
expansion: {
type: ['string', 'null'],
description: 'The full form, when the term is an abbreviation.'
},
aliases: {
type: 'array',
items: { type: 'string' },
description:
'Alternate forms and inflections. Unique across every term of the locale, like the term itself.'
},
category: { type: ['string', 'null'] },
caseSensitive: {
type: 'boolean',
description:
'Whether the term matches text only with its exact case. About matching in text, never about uniqueness: `REST` and `rest` cannot be two terms either way.'
},
autoLink: {
type: 'boolean',
description:
'Whether the term is linked automatically where it appears in page text. Stored, and not yet acted on.'
}
}
/**
* What a create or a save carries, as properties to spread into a route's body schema: the create adds
* `locale`, the save `expectedUpdatedAt`. Properties rather than a registered schema, because a body
* built from a `$ref` plus a property of its own needs `allOf`, and the coercion and defaults Fastify
* applies are less predictable through one.
*/
export const GLOSSARY_INPUT_PROPERTIES = {
term: { type: 'string', minLength: 1, maxLength: GLOSSARY_NAME_MAX_LENGTH },
expansion: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH },
definition: { type: 'string', maxLength: GLOSSARY_DEFINITION_MAX_LENGTH },
aliases: {
type: 'array',
maxItems: GLOSSARY_ALIASES_MAX,
items: { type: 'string', maxLength: GLOSSARY_NAME_MAX_LENGTH },
description:
'Repeats, and copies of the term itself, are dropped rather than refused. A name another term already holds, as its term or an alias, is refused with 409.'
},
relatedTerms: {
type: 'array',
maxItems: GLOSSARY_RELATED_MAX,
items: { type: 'string', format: 'uuid' },
description:
'IDs of terms in the same site and locale. Replaces the whole list, from both sides: a term taken out of this list loses this one from its own.'
},
documentationPath: {
type: ['string', 'null'],
maxLength: GLOSSARY_NAME_MAX_LENGTH,
description: 'A page path in the term’s own locale, without a locale prefix.'
},
documentationLabel: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH },
references: {
type: 'array',
maxItems: GLOSSARY_REFERENCES_MAX,
items: {
type: 'object',
required: ['url'],
properties: {
url: {
type: 'string',
maxLength: 2048,
pattern: '^https?://',
description: '`http:` or `https:` only.'
},
label: { type: 'string', maxLength: GLOSSARY_NAME_MAX_LENGTH }
}
}
},
caseSensitive: { type: 'boolean', default: false },
autoLink: { type: 'boolean', default: true },
category: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH }
}
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* GLOSSARY TERM SUMMARY - A term as the glossary's list shows it
*/
app.addSchema({
$id: 'GlossaryTermSummary',
type: 'object',
properties: summaryProperties
})
/**
* GLOSSARY TERM - A term in full
*/
app.addSchema({
$id: 'GlossaryTerm',
type: 'object',
properties: {
...summaryProperties,
siteId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
definition: {
type: 'string',
description: 'Basic markdown, never HTML. Rendered by the client with raw HTML disabled.'
},
references: {
type: 'array',
items: {
type: 'object',
properties: {
url: { type: 'string' },
label: { type: 'string', description: 'Empty when the URL stands in for one.' }
}
}
},
relatedTerms: {
type: 'array',
description:
'Related terms, which is two-way: a term lists every term that lists it. A–Z by name.',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
term: { type: 'string' }
}
}
},
documentation: {
type: ['object', 'null'],
description:
'The page in the same locale that documents the term, looked up as it is read. Null when none is named; `exists` false when the named path has no page.',
properties: {
path: { type: 'string' },
label: {
type: ['string', 'null'],
description: 'The link text. Null for the default, which the client supplies.'
},
title: { type: ['string', 'null'] },
exists: { type: 'boolean' }
}
},
author: {
type: ['object', 'null'],
description: 'Who saved it last. Null for an API key, or once the account is gone.',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' }
}
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: {
type: 'string',
format: 'date-time',
description:
'At millisecond precision. A save names the value it was opened with as `expectedUpdatedAt`.'
}
}
})
/**
* GLOSSARY CONFLICT - Why a write answered 409
*/
app.addSchema({
$id: 'GlossaryConflict',
type: 'object',
properties: {
ok: { type: 'boolean' },
statusCode: { type: 'integer' },
error: { type: 'string' },
message: { type: 'string' },
reason: {
type: 'string',
enum: ['nameTaken', 'stale'],
description:
'`nameTaken`: another term already holds one of the names. `stale`: somebody saved the term after the caller opened it.'
},
field: {
type: 'string',
enum: ['term', 'aliases'],
description: 'For `nameTaken`, which of the caller’s fields holds the name.'
},
name: { type: 'string', description: 'For `nameTaken`, the name that is held.' },
holder: {
type: 'object',
description: 'For `nameTaken`, the term holding it.',
properties: {
id: { type: 'string', format: 'uuid' },
term: { type: 'string' }
}
},
current: {
$ref: 'GlossaryTerm#',
description: 'For `stale`, the term as it now stands.'
}
}
})
}
+5
View File
@@ -107,6 +107,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description:
'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.'
},
glossary: {
type: 'boolean',
description:
'Whether this site has a glossary. On by default. Off hides it everywhere — the Library menu has no Glossary row and the glossary routes answer as though there were no terms — and keeps every term for when it is turned back on. Who may read it is the `read:glossary` page permission, and who may edit it `manage:glossary`, both granted per locale.'
},
lastEditedBy: {
type: 'boolean',
description:
+9 -11
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts'
import { audit } from '../helpers/audit.ts'
import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
import { actorFrom } from './pages.ts'
import { actorFrom, unpublishedFor } from './pages.ts'
interface TreeQuery {
parentId?: string
@@ -280,7 +280,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Get the page graph',
description:
"Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not published is included only for a caller who may also edit it (`write:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.",
"Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not live — a draft, or a scheduled page outside its publishing window — is included only for a signed-in caller who may also edit it (`write:pages` or `manage:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.",
tags: ['Tree'],
params: siteIdParam,
querystring: {
@@ -301,9 +301,9 @@ async function routes(app: FastifyInstance) {
async (req) => {
const siteId = req.params.siteId
const actor = WIKI.models.groups.actorForRequest(req)
// -> A draft belongs to the people working on it, and an API key is answered as the public is
// -- the same line the page route draws, via `actorFrom`
const isSession = actorFrom(req) !== null
// -> A page that is not live belongs to the people working on it, and an API key is answered as
// the public is -- the same line the page route draws
const unpublished = unpublishedFor(req)
return WIKI.models.pageGraph.graphFor({
siteId,
locale: req.query.locale || defaultLocale(siteId),
@@ -312,12 +312,10 @@ async function routes(app: FastifyInstance) {
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', ref)) {
return null
}
if (page.publishState === 'published') {
if (page.isLive) {
return 'published'
}
return isSession && WIKI.models.groups.checkAccess(actor, 'write:pages', ref)
? 'draft'
: null
return unpublished && unpublished(ref) ? 'draft' : null
}
})
}
@@ -398,7 +396,7 @@ async function routes(app: FastifyInstance) {
siteId: req.params.siteId,
path: req.query.path,
locale: req.query.locale ?? defaultLocale(req.params.siteId),
publicOnly: !req.session?.authenticated
unpublished: unpublishedFor(req)
})
if (!level) {
return reply.notFound('This folder does not exist.')
@@ -513,7 +511,7 @@ async function routes(app: FastifyInstance) {
orderBy: req.query.orderBy,
orderByDirection: req.query.orderByDirection,
depth: req.query.depth,
publicOnly: !req.session?.authenticated
unpublished: unpublishedFor(req)
})
// -> An index block is drawn inside a page, but it lists other pages: each one still has to be
// the reader's to see
+4 -3
View File
@@ -1,19 +1,20 @@
import { audit } from '../helpers/audit.ts'
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
import { actorFrom, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
/**
* The page being watched, as this requester is allowed to see it.
*
* Watching a page is a thing done TO a page, so it goes through the same gate as reading one: an
* anonymous requester never gets here at all, and a page somebody may not read is answered as though
* it were not there. A password is not part of it — the watcher is asking to be told when the page
* anonymous requester never gets here at all, and a page somebody may not read — or may not see
* before it is live — is answered as though it were not there. A password is not part of it — the watcher is asking to be told when the page
* changes, not to read what it says.
*/
async function loadWatchablePage(req: FastifyRequest, siteId: string, pageId: string) {
const page = await WIKI.models.pages.getPage({
siteId,
id: pageId,
unpublished: unpublishedFor(req),
unlocked: (id: string) => unlockedFor(req, id)
})
if (!page || !mayOnPage(req, 'read:pages', page)) {
@@ -0,0 +1,36 @@
CREATE TABLE "glossaryTermRelations" (
"termId" uuid,
"relatedId" uuid,
CONSTRAINT "glossaryTermRelations_pkey" PRIMARY KEY("termId","relatedId"),
CONSTRAINT "glossaryTermRelations_order_check" CHECK ("termId" < "relatedId")
);
--> statement-breakpoint
CREATE TABLE "glossaryTerms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"siteId" uuid NOT NULL,
"locale" varchar(255) NOT NULL,
"term" varchar(255) NOT NULL,
"expansion" varchar(255),
"definition" text DEFAULT '' NOT NULL,
"aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"documentationPath" varchar(255),
"documentationLabel" varchar(255),
"references" jsonb DEFAULT '[]' NOT NULL,
"caseSensitive" boolean DEFAULT false NOT NULL,
"autoLink" boolean DEFAULT true NOT NULL,
"category" varchar(255),
"creatorId" uuid,
"authorId" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "glossaryTermRelations_relatedId_idx" ON "glossaryTermRelations" ("relatedId");--> statement-breakpoint
CREATE INDEX "glossaryTerms_site_locale_idx" ON "glossaryTerms" ("siteId","locale");--> statement-breakpoint
CREATE UNIQUE INDEX "glossaryTerms_site_locale_term_key" ON "glossaryTerms" ("siteId","locale",lower("term"));--> statement-breakpoint
CREATE INDEX "glossaryTerms_documentation_idx" ON "glossaryTerms" ("siteId","locale","documentationPath") WHERE "documentationPath" IS NOT NULL;--> statement-breakpoint
ALTER TABLE "glossaryTermRelations" ADD CONSTRAINT "glossaryTermRelations_termId_glossaryTerms_id_fkey" FOREIGN KEY ("termId") REFERENCES "glossaryTerms"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "glossaryTermRelations" ADD CONSTRAINT "glossaryTermRelations_relatedId_glossaryTerms_id_fkey" FOREIGN KEY ("relatedId") REFERENCES "glossaryTerms"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_creatorId_users_id_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL;
File diff suppressed because it is too large Load Diff
+86
View File
@@ -3,6 +3,7 @@ import {
bigint,
boolean,
bytea,
check,
customType,
index,
integer,
@@ -365,6 +366,91 @@ export const commentAnnotations = pgTable(
(table) => [index('commentAnnotations_commentId_idx').on(table.commentId, table.position)]
)
// GLOSSARY ----------------------------
/**
* One term of a site's glossary, in one locale. See `dev/specs/glossary.md`.
*
* **A name means one term.** The term and every alias share one namespace per site and locale,
* compared with `lower()`. The unique index below is the database's half of that, term against term;
* everything involving an alias is checked by the model, since an index cannot reach into an array
* and compare it against another row's scalar.
*
* `caseSensitive` is about how the term is matched IN TEXT and never about uniqueness: `REST` and
* `rest` cannot be two terms either way.
*/
export const glossaryTerms = pgTable(
'glossaryTerms',
{
id: uuid().primaryKey().defaultRandom(),
siteId: uuid()
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
locale: varchar({ length: 255 }).notNull(),
term: varchar({ length: 255 }).notNull(),
/** The full form, when the term is an abbreviation. */
expansion: varchar({ length: 255 }),
/** Basic markdown source, rendered in the browser with raw HTML disabled. Never HTML. */
definition: text().notNull().default(''),
aliases: text()
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
/** A page path in the term's own locale, with no locale prefix. Looked up when read. */
documentationPath: varchar({ length: 255 }),
/** Null for the default, "Read more", which is a locale string and not stored. */
documentationLabel: varchar({ length: 255 }),
/** `[{ url, label }]`, in the order given. */
references: jsonb().notNull().default([]),
caseSensitive: boolean().notNull().default(false),
/** Whether the term is linked automatically in page text. Nothing reads it yet. */
autoLink: boolean().notNull().default(true),
category: varchar({ length: 255 }),
creatorId: uuid().references(() => users.id, { onDelete: 'set null' }),
/** Who saved it last. */
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp().notNull().defaultNow(),
/** Also the concurrency token: a save names the one it was opened with. */
updatedAt: timestamp().notNull().defaultNow()
},
(table) => [
index('glossaryTerms_site_locale_idx').on(table.siteId, table.locale),
uniqueIndex('glossaryTerms_site_locale_term_key').on(
table.siteId,
table.locale,
sql`lower(${table.term})`
),
// -> What a page move with `updateLinks` looks terms up by
index('glossaryTerms_documentation_idx')
.on(table.siteId, table.locale, table.documentationPath)
.where(sql`"documentationPath" IS NOT NULL`)
]
)
/**
* Two related glossary terms. Related is two-way, so a pair is ONE row, read from both sides.
*
* The check stores the smaller id first, which is what makes "A is related to B" and "B is related
* to A" the same row rather than two that could disagree. A join table rather than an array on the
* term, so that deleting a term takes it out of every other term's list by cascade alone.
*/
export const glossaryTermRelations = pgTable(
'glossaryTermRelations',
{
termId: uuid()
.notNull()
.references(() => glossaryTerms.id, { onDelete: 'cascade' }),
relatedId: uuid()
.notNull()
.references(() => glossaryTerms.id, { onDelete: 'cascade' })
},
(table) => [
primaryKey({ columns: [table.termId, table.relatedId] }),
index('glossaryTermRelations_relatedId_idx').on(table.relatedId),
check('glossaryTermRelations_order_check', sql`${table.termId} < ${table.relatedId}`)
]
)
// GROUPS ------------------------------
export const groups = pgTable(
'groups',
+1 -2
View File
@@ -381,8 +381,7 @@ async function bodyForBlog(siteId: string, page: PageDescription): Promise<strin
const { posts } = await WIKI.models.blogs.postsFor({
siteId,
blog,
actor: WIKI.models.groups.actorForPublic(),
publicOnly: true
actor: WIKI.models.groups.actorForPublic()
})
const intro = blog.settings.intro ? `<p>${htmlEscape(blog.settings.intro)}</p>` : ''
const items = posts
+47 -5
View File
@@ -192,16 +192,29 @@ function outranks(a: number[], b: number[]): boolean {
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 site and a locale, for the permissions that are about those rather than a page. */
export interface RuleLocaleRef {
siteId: string
locale?: string
}
/** Whether a rule speaks for this site and locale at all — its `sites` and `locales`, nothing else. */
function ruleIsScopedTo(rule: GroupRule, scope: RuleLocaleRef): boolean {
// -> 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)) {
if (rule.sites?.length > 0 && !rule.sites.includes(scope.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 && scope.locale && !rule.locales.includes(scope.locale)) {
return false
}
return true
}
/** Whether a rule addresses this page at all, ignoring what it then says about it. */
export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean {
if (!ruleIsScopedTo(rule, page)) {
return false
}
@@ -270,6 +283,35 @@ export function resolvePageRule(
return winner
}
/**
* The rule that decides a LOCALE permission (`LOCALE_PERMISSIONS` in `models/groups.ts`), for which a
* rule's `match`, `path` and `tags` mean nothing.
*
* Every rule naming the permission that is scoped to the site and locale applies. Steps 1 to 3 of the
* ordering above rank rules by how precisely they address a PAGE, and with no page being asked about
* every rule is as precise as every other — so step 4 alone decides: a DENY anywhere overrides every
* ALLOW, and a FORCE ALLOW anywhere overrides every DENY. A rule written for `/docs` therefore speaks
* for the whole locale, in both directions.
*
* @returns The deciding rule, or null when nothing names the permission — which means denied
*/
export function resolveLocaleRule(
rules: GroupRule[],
permission: string,
scope: RuleLocaleRef
): GroupRule | null {
let winner: GroupRule | null = null
for (const rule of rules) {
if (!rule.roles?.includes(permission) || !ruleIsScopedTo(rule, scope)) {
continue
}
if (!winner || MODE_PRIORITY.indexOf(rule.mode) > MODE_PRIORITY.indexOf(winner.mode)) {
winner = rule
}
}
return winner
}
/**
* Whether the caller's rules grant a permission on a page.
*
+64
View File
@@ -0,0 +1,64 @@
import { sql, type SQL } from 'drizzle-orm'
import type { PgColumn } from 'drizzle-orm/pg-core'
import type { AccessActor } from '../models/groups.ts'
import type { RulePageRef } from './pageRules.ts'
/**
* Whether a page is open to its readers, and who may see it while it is not.
*
* A page is LIVE when it is `published`, or `scheduled` and inside its window — on or after
* `publishStartDate` and before `publishEndDate`, either end left open when unset. A `draft` never
* is. The dates gate a `scheduled` page only: on a `published` one `publishStartDate` is the date a
* blog post is dated by, which an author may well set in the past or the future without meaning to
* take the post down.
*
* A page that is not live is the business of the people working on it, and of nobody else: whoever
* holds `write:pages` or `manage:pages` on it may see it, its source included, and every other
* reader is answered as though it were not there. A request without a session never sees one —
* anonymous readers and API keys alike — since a draft belongs to people, not to a token.
*
* Every read that answers a reader goes through these two, so that no route, listing or search can
* disagree with the page view about whether a page is there.
*/
type PublishColumns = {
publishState: PgColumn | SQL
publishStartDate: PgColumn | SQL
publishEndDate: PgColumn | SQL
}
/** What lets a requester see a page that is not live. */
export const UNPUBLISHED_PERMISSIONS = ['write:pages', 'manage:pages']
/**
* The SQL condition for a page being live now.
*
* Decided by the database so that it is one definition wherever a page is read. Compared against an
* ISO string cast to `timestamp`, which is how drizzle writes the two columns: they hold UTC with no
* zone, and comparing them to `now()` would read them in the session's zone instead.
*/
export function liveCondition(columns: PublishColumns): SQL {
const now = new Date().toISOString()
return sql`(${columns.publishState} = 'published' OR (${columns.publishState} = 'scheduled'
AND (${columns.publishStartDate} IS NULL OR ${columns.publishStartDate} <= ${now}::timestamp)
AND (${columns.publishEndDate} IS NULL OR ${columns.publishEndDate} > ${now}::timestamp)))`
}
/** Whether this actor may see this page while it is not live. */
export function maySeeUnpublished(actor: AccessActor, page: RulePageRef): boolean {
return UNPUBLISHED_PERMISSIONS.some((permission) =>
WIKI.models.groups.checkAccess(actor, permission, page)
)
}
/**
* Whether this actor could see an unpublished page ANYWHERE.
*
* A pre-filter for listings, never the answer for a page: an actor for whom this is false is held to
* live pages in SQL, and one for whom it is true has every row checked with `maySeeUnpublished`.
*/
export function maySeeUnpublishedAnywhere(actor: AccessActor): boolean {
return UNPUBLISHED_PERMISSIONS.some((permission) =>
WIKI.models.groups.grantsAnywhere(actor, permission)
)
}
+89
View File
@@ -140,6 +140,7 @@
"admin.audit.actions.createAuthStrategy": "Added an authentication strategy",
"admin.audit.actions.createComment": "Posted a comment",
"admin.audit.actions.createFolder": "Created a folder",
"admin.audit.actions.createGlossaryTerm": "Created a glossary term",
"admin.audit.actions.createGroup": "Created a group",
"admin.audit.actions.createHook": "Created a webhook",
"admin.audit.actions.createPage": "Created a page",
@@ -153,6 +154,7 @@
"admin.audit.actions.deleteBlock": "Deleted a custom block",
"admin.audit.actions.deleteComment": "Deleted a comment",
"admin.audit.actions.deleteFolder": "Deleted a folder",
"admin.audit.actions.deleteGlossaryTerm": "Deleted a glossary term",
"admin.audit.actions.deleteGroup": "Deleted a group",
"admin.audit.actions.deleteHook": "Deleted a webhook",
"admin.audit.actions.deleteIconSet": "Deleted an icon set",
@@ -233,6 +235,7 @@
"admin.audit.actions.updateEditorSettings": "Changed their editor settings",
"admin.audit.actions.updateFlags": "Changed the system flags",
"admin.audit.actions.updateFolder": "Renamed a folder",
"admin.audit.actions.updateGlossaryTerm": "Edited a glossary term",
"admin.audit.actions.updateGroup": "Updated a group",
"admin.audit.actions.updateHook": "Updated a webhook",
"admin.audit.actions.updateIconSet": "Enabled or disabled an icon set",
@@ -276,6 +279,7 @@
"admin.audit.kinds.asset": "File",
"admin.audit.kinds.auth": "Sign-in",
"admin.audit.kinds.comment": "Comment",
"admin.audit.kinds.glossary": "Glossary",
"admin.audit.kinds.page": "Page",
"admin.audit.kinds.profile": "Profile",
"admin.audit.loadActionsFailed": "Could not load the list of actions.",
@@ -501,6 +505,8 @@
"admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Changes are still only stored when someone saves the page.",
"admin.general.allowComments": "Allow Comments",
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
"admin.general.allowGlossary": "Glossary",
"admin.general.allowGlossaryHint": "Offer a glossary of terms in the Library menu. Who may read it is set by the Read Glossary page rule, and who may edit it by the Manage Glossary permission.",
"admin.general.allowLastEditedBy": "Allow Last Edited By",
"admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?",
"admin.general.allowListFolderChildren": "List Children on Folder Pages",
@@ -649,8 +655,12 @@
"admin.groups.redirectOnLogoutHint": "The path / URL where the user will be redirected upon logout. Leave empty to use the site-defined value.",
"admin.groups.refreshSuccess": "Groups refreshed successfully.",
"admin.groups.ruleAllow": "Allow",
"admin.groups.ruleAllowHint": "Grants the selected permissions.",
"admin.groups.ruleDelete": "Delete Rule",
"admin.groups.ruleDeny": "Deny",
"admin.groups.ruleDenyHint": "Refuses them, over an equally specific Allow.",
"admin.groups.ruleForceAllow": "Force Allow",
"admin.groups.ruleForceAllowHint": "Grants them, over an equally specific Deny.",
"admin.groups.ruleLocales": "Locale(s)",
"admin.groups.ruleMatch": "Match",
"admin.groups.ruleMatchEnd": "Path Ends With...",
@@ -661,6 +671,14 @@
"admin.groups.ruleMatchTag": "Has Any Tag...",
"admin.groups.ruleMatchTagAll": "Has All Tags...",
"admin.groups.rulePath": "Path",
"admin.groups.rulePermissionsCount": "No permissions selected | 1 permission selected | {count} permissions selected",
"admin.groups.rulePermissionsLocale": "Site + Locale Only",
"admin.groups.rulePermissionsLocaleHint": "The rule's pattern is ignored: the rule applies to its sites and locales as a whole, and a Deny anywhere outranks an Allow anywhere.",
"admin.groups.rulePermissionsNone": "No permissions selected.",
"admin.groups.rulePermissionsPage": "Site + Locale + Pattern",
"admin.groups.rulePermissionsPageHint": "Applies to the pages the rule's pattern matches, on its sites and in its locales.",
"admin.groups.rulePermissionsSelect": "Select Permissions...",
"admin.groups.rulePermissionsTitle": "Rule Permissions",
"admin.groups.ruleSites": "Site(s)",
"admin.groups.ruleTags": "Tags",
"admin.groups.ruleTagsFailed": "Failed to load the list of existing tags.",
@@ -2083,10 +2101,12 @@
"common.header.delete": "Delete",
"common.header.duplicate": "Duplicate",
"common.header.edit": "Edit",
"common.header.glossary": "Glossary",
"common.header.history": "History",
"common.header.home": "Home",
"common.header.imagesFiles": "Images & Files",
"common.header.language": "Language",
"common.header.library": "Library",
"common.header.login": "Login",
"common.header.logout": "Logout",
"common.header.move": "Move / Rename",
@@ -2919,6 +2939,75 @@
"folderDeleteDialog.deleteSuccess": "Folder has been deleted successfully.",
"folderDeleteDialog.folderId": "Folder ID {id}",
"folderDeleteDialog.title": "Confirm Delete Folder",
"glossary.alsoKnownAs": "Also known as",
"glossary.backToList": "Back to the list",
"glossary.category": "Category",
"glossary.createSuccess": "Term created.",
"glossary.deleteConfirm": "Delete the term \"{term}\"? It is also removed from the related terms of every other term.",
"glossary.deleteConfirmTitle": "Delete Term",
"glossary.deleteSuccess": "Term deleted.",
"glossary.documentation": "Documentation",
"glossary.documentationMissing": "The page /{path} was not found.",
"glossary.editTermTitle": "Edit Term",
"glossary.empty": "This glossary has no terms yet.",
"glossary.filter": "Filter terms...",
"glossary.forbidden": "You may not read the glossary in this locale.",
"glossary.form.addReference": "Add Reference",
"glossary.form.aliases": "Aliases",
"glossary.form.aliasesHint": "Alternate forms, spellings and inflections. Press Enter to add each one.",
"glossary.form.autoLink": "Auto-Link",
"glossary.form.autoLinkHint": "Link the term automatically where it appears in pages. Coming soon.",
"glossary.form.caseSensitive": "Case Sensitive",
"glossary.form.caseSensitiveHint": "Only match text written with the exact same case, e.g. \"REST\" but not \"rest\".",
"glossary.form.category": "Category",
"glossary.form.categoryHint": "Groups terms together in the list. Press Enter to use a new one.",
"glossary.form.definition": "Definition",
"glossary.form.definitionHint": "Basic markdown: emphasis, lists, links, quotes and code.",
"glossary.form.documentation": "Documentation Page",
"glossary.form.documentationHint": "The page on this wiki that documents the term, in the same locale.",
"glossary.form.documentationLabel": "Link Label",
"glossary.form.documentationNone": "No page selected",
"glossary.form.expansion": "Expansion",
"glossary.form.expansionHint": "The full form, if the term is an abbreviation.",
"glossary.form.invalidRelated": "A related term no longer exists. The list has been reloaded.",
"glossary.form.keepOldAsAlias": "Keep \"{name}\" as an alias",
"glossary.form.nameTaken": "\"{name}\" is already used by the term \"{holder}\".",
"glossary.form.notFound": "This term no longer exists.",
"glossary.form.preview": "Preview",
"glossary.form.referenceLabel": "Label (optional)",
"glossary.form.referenceUrl": "URL",
"glossary.form.referenceUrlInvalid": "Must be an http:// or https:// address.",
"glossary.form.references": "External References",
"glossary.form.relatedTerms": "Related Terms",
"glossary.form.relatedTermsHint": "Related terms are listed on both terms.",
"glossary.form.selectPage": "Select Page",
"glossary.form.staleMessage": "{name} saved it on {date}, after you started editing.",
"glossary.form.staleMessageNoAuthor": "It was saved on {date}, after you started editing.",
"glossary.form.staleOverwrite": "Save Over It",
"glossary.form.staleReload": "Discard My Changes",
"glossary.form.staleTitle": "This term was changed by somebody else",
"glossary.form.term": "Term",
"glossary.form.write": "Write",
"glossary.groupAz": "A–Z",
"glossary.groupCategory": "Category",
"glossary.grouping": "Group terms by",
"glossary.lastEdited": "Last edited by {name} on {date}",
"glossary.lastEditedNoAuthor": "Last edited on {date}",
"glossary.loadFailed": "Failed to load the glossary.",
"glossary.newTerm": "New Term",
"glossary.newTermTitle": "New Term",
"glossary.noDefinition": "No definition yet.",
"glossary.noMatch": "No term matches the filter.",
"glossary.pickTerm": "Select a term to read its definition.",
"glossary.pickTermManage": "Select a term to read its definition, or create a new one.",
"glossary.readMore": "Read more",
"glossary.references": "References",
"glossary.related": "Related",
"glossary.saveSuccess": "Term saved.",
"glossary.title": "Glossary",
"glossary.uncategorized": "Uncategorized",
"glossary.unsavedMessage": "This term has unsaved changes. Discard them?",
"glossary.unsavedTitle": "Discard Changes?",
"history.action.created": "Created",
"history.action.deleted": "Deleted",
"history.action.moved": "Moved",
+10 -1
View File
@@ -9,7 +9,15 @@ import { sanitizeMeta } from '../helpers/audit.ts'
* migration. The admin area's filter is built from this list, and `admin.audit.kinds.<kind>` is the
* translation of each.
*/
export const AUDIT_KINDS = ['page', 'asset', 'comment', 'auth', 'profile', 'admin'] as const
export const AUDIT_KINDS = [
'page',
'asset',
'comment',
'glossary',
'auth',
'profile',
'admin'
] as const
export type AuditKind = (typeof AUDIT_KINDS)[number]
/**
@@ -58,6 +66,7 @@ export const AUDIT_ACTIONS = {
'reopenAnnotation',
'deleteAnnotation'
],
glossary: ['createGlossaryTerm', 'updateGlossaryTerm', 'deleteGlossaryTerm'],
auth: [
'login',
'logout',
+25 -16
View File
@@ -3,6 +3,8 @@ import { pages as pagesTable, tree as treeTable, users as usersTable } from '../
import { decodeTreePath, encodeTreePath } from '../helpers/common.ts'
import { isBodylessEditor, parseBlogContent, type BlogContent } from './pages.ts'
import type { AccessActor } from './groups.ts'
import type { RulePageRef } from '../helpers/pageRules.ts'
import { liveCondition } from '../helpers/publishing.ts'
/**
* How many of a blog's posts are read out of the database for one request.
@@ -99,6 +101,7 @@ export interface BlogFilter {
/** One candidate row, before the page rules and the nesting rule have had their say. */
interface Candidate extends BlogPost {
editor: string
isLive: boolean
}
/**
@@ -288,18 +291,22 @@ class Blogs {
* - **A page the reader may not open**, by the same rule the page view applies. Filtered here rather
* than in SQL because a page rule is not a `WHERE` clause.
*
* @param publicOnly Restrict to what a reader with no session may see, i.e. published posts only.
* - **A post that is not live**, unless `unpublished` says this reader may see it — the page view's
* own rule (`helpers/publishing.ts`). Live posts only by default.
*
* @param unpublished As for `pages.getPage`: false holds the listing to live posts in SQL, and a
* predicate is asked of each post that is not live.
*/
async postsFor({
siteId,
blog,
actor,
publicOnly = true
unpublished = false
}: {
siteId: string
blog: BlogRef
actor?: AccessActor
publicOnly?: boolean
unpublished?: false | ((page: RulePageRef) => boolean)
}): Promise<{ posts: BlogPost[]; truncated: boolean }> {
const encodedPath = encodeTreePath(blog.path)
const depth = blog.settings.depth
@@ -326,8 +333,8 @@ class Blogs {
eq(treeTable.type, 'page'),
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`
]
if (publicOnly) {
conditions.push(eq(pagesTable.publishState, 'published'))
if (!unpublished) {
conditions.push(liveCondition(pagesTable))
}
const rows = await WIKI.db
@@ -343,6 +350,7 @@ class Blogs {
updatedAt: pagesTable.updatedAt,
authorId: pagesTable.authorId,
authorName: usersTable.name,
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
publishedAt
})
.from(treeTable)
@@ -366,7 +374,8 @@ class Blogs {
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
authorId: row.authorId,
authorName: row.authorName ?? ''
authorName: row.authorName ?? '',
isLive: row.isLive
}
})
@@ -386,18 +395,18 @@ class Blogs {
if (nestedRoots.some((root) => row.path.startsWith(root))) {
return false
}
const ref = { siteId, path: row.path, locale: blog.locale, tags: row.tags }
if (!row.isLive && !(unpublished && unpublished(ref))) {
return false
}
if (!actor) {
return true
}
return WIKI.models.groups.checkAccess(actor, 'read:pages', {
siteId,
path: row.path,
locale: blog.locale,
tags: row.tags
})
return WIKI.models.groups.checkAccess(actor, 'read:pages', ref)
})
// -> Which editor wrote a post is how it was filtered, not something a listing shows
// -> Which editor wrote a post, and whether it is live, are how it was filtered, not something a
// listing shows
return {
posts: posts.map((post) => ({
id: post.id,
@@ -459,16 +468,16 @@ class Blogs {
siteId,
blog,
actor,
publicOnly = true,
unpublished = false,
filter = {}
}: {
siteId: string
blog: BlogRef
actor?: AccessActor
publicOnly?: boolean
unpublished?: false | ((page: RulePageRef) => boolean)
filter?: BlogFilter
}): Promise<BlogListing> {
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, publicOnly })
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, unpublished })
const facets = this.facetsFor(readable)
const matching = readable.filter((post) => {
+6 -2
View File
@@ -15,6 +15,7 @@ import {
parseModuleProps
} from '../helpers/common.ts'
import type { ModuleProp } from '../helpers/common.ts'
import { liveCondition } from '../helpers/publishing.ts'
/**
* The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service.
@@ -759,7 +760,7 @@ class Comments {
* Its path, locale and tags because that is what a page rule is matched against, and
* `allowComments` because a page can be closed to discussion from its own properties dialog
* whatever the site has configured. Deliberately not `pages.getPage` — that assembles a page for
* reading, and this is four columns and a scoping check.
* reading, and this is a few columns and a scoping check. `isLive` is the caller's to act on.
*
* @returns The reference, or null when no such page exists on this site
*/
@@ -771,7 +772,10 @@ class Comments {
locale: pagesTable.locale,
title: pagesTable.title,
tags: pagesTable.tags,
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`,
// -> A discussion quotes its page, annotations word for word, so it is only there while the
// page is — see `helpers/publishing.ts`
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
})
.from(pagesTable)
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
+552
View File
@@ -0,0 +1,552 @@
import { and, asc, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm'
import {
glossaryTermRelations as relationsTable,
glossaryTerms as termsTable,
pages as pagesTable,
users as usersTable
} from '../db/schema.ts'
/**
* How much a term may carry. Shared with the route schemas, which are what actually enforce it.
*
* Per `dev/specs/glossary.md` §3.4. A name — the term, an alias, the expansion, the category, the
* documentation label — is a varchar(255) column or an entry like one.
*/
export const GLOSSARY_NAME_MAX_LENGTH = 255
export const GLOSSARY_ALIASES_MAX = 50
export const GLOSSARY_DEFINITION_MAX_LENGTH = 10000
export const GLOSSARY_REFERENCES_MAX = 20
export const GLOSSARY_RELATED_MAX = 50
/** An external reference: a link out, with an optional label the URL stands in for. */
export interface GlossaryReference {
url: string
label: string
}
/** What a create or a save carries. `locale` is fixed at creation, so a save never names one. */
export interface GlossaryTermInput {
term: string
expansion?: string | null
definition?: string
aliases?: string[]
relatedTerms?: string[]
documentationPath?: string | null
documentationLabel?: string | null
references?: GlossaryReference[]
caseSensitive?: boolean
autoLink?: boolean
category?: string | null
}
/**
* A term as the sidebar lists it.
*
* Also exactly what auto-linking will need to build its matcher (spec §9), which is why the two case
* and linking flags are here although the sidebar draws neither.
*/
export interface GlossaryTermSummary {
id: string
term: string
expansion: string | null
aliases: string[]
category: string | null
caseSensitive: boolean
autoLink: boolean
}
/** A term in full, as the main panel shows it and the form edits it. */
export interface GlossaryTerm extends GlossaryTermSummary {
siteId: string
locale: string
definition: string
references: GlossaryReference[]
relatedTerms: Array<{ id: string; term: string }>
/**
* The documentation page, resolved. Null when the term names none; `exists: false` when it names a
* path with no page, which the view says rather than offering a dead link.
*/
documentation: {
path: string
label: string | null
title: string | null
exists: boolean
} | null
author: { id: string; name: string } | null
createdAt: string
/** At millisecond precision — what a save hands back as `expectedUpdatedAt`. */
updatedAt: string
}
/**
* Why a write was refused, for the route to turn into a status code.
*
* Results rather than thrown errors, because two of them carry something the client needs beyond a
* message: who already holds a name, and the term as somebody else just saved it.
*/
export type GlossaryWriteResult =
| { ok: true; term: GlossaryTerm; changedFields: string[] }
| { ok: false; reason: 'notFound' }
| { ok: false; reason: 'invalidRelated' }
| {
ok: false
reason: 'nameTaken'
/** Which of the caller's fields holds the clashing name. */
field: 'term' | 'aliases'
name: string
holder: { id: string; term: string }
}
| { ok: false; reason: 'stale'; current: GlossaryTerm }
/** The fields compared to report what a save changed, for the audit log. */
const COMPARED_FIELDS = [
'term',
'expansion',
'definition',
'aliases',
'relatedTerms',
'documentationPath',
'documentationLabel',
'references',
'caseSensitive',
'autoLink',
'category'
] as const
/**
* A name as it is stored and compared: NFC, trimmed, inner whitespace collapsed to one space.
*
* So that `REST API`, `REST API` and a decomposed accent are each one name rather than three that
* look identical in the sidebar and collide nowhere.
*/
export function normalizeName(value: string): string {
return value.normalize('NFC').trim().replace(/\s+/g, ' ')
}
/** An optional text field: normalized like a name, with empty meaning absent. */
function optionalName(value: string | null | undefined): string | null {
const normalized = normalizeName(value ?? '')
return normalized.length > 0 ? normalized : null
}
/** Compared without leading or trailing slashes, as every page path is stored. */
function normalizePath(value: string | null | undefined): string | null {
const path = (value ?? '').trim().replace(/^\/+|\/+$/g, '')
return path.length > 0 ? path : null
}
/**
* The aliases as they will be stored.
*
* Normalized, then with every repeat and every copy of the term's own name dropped silently rather
* than refused (spec §3.3): saying a name twice is not a mistake worth stopping a save for.
*/
function cleanAliases(aliases: string[] | undefined, term: string): string[] {
const seen = new Set([term.toLowerCase()])
const out: string[] = []
for (const alias of aliases ?? []) {
const name = normalizeName(alias)
const key = name.toLowerCase()
if (name.length < 1 || seen.has(key)) {
continue
}
seen.add(key)
out.push(name)
}
return out
}
/** References with both halves trimmed, and any without a URL dropped. */
function cleanReferences(references: GlossaryReference[] | undefined): GlossaryReference[] {
return (references ?? [])
.map((ref) => ({ url: (ref.url ?? '').trim(), label: normalizeName(ref.label ?? '') }))
.filter((ref) => ref.url.length > 0)
}
/** A transaction, or the database outside one. Every read here works inside a write's transaction. */
type Executor = typeof WIKI.db | Parameters<Parameters<typeof WIKI.db.transaction>[0]>[0]
/** `updatedAt` as the API carries it. JS dates are milliseconds, which is the precision promised. */
function iso(value: Date): string {
return value.toISOString()
}
/**
* Glossary model. See `dev/specs/glossary.md`.
*/
class Glossary {
/**
* Whether the site has a glossary at all — `features.glossary`, General → Features.
*
* On unless switched off, the way `backlinks` and `comments` read: the switch exists to turn it off.
*/
isEnabled(siteId: string): boolean {
return WIKI.sites[siteId]?.config?.features?.glossary !== false
}
/** Whether a locale is one this site offers, which is the only kind a term may be written in. */
isActiveLocale(siteId: string, locale: string): boolean {
const active: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? []
return active.includes(locale)
}
/** Every term of a locale, A–Z, as the sidebar lists them. */
async list(siteId: string, locale: string): Promise<GlossaryTermSummary[]> {
return WIKI.db
.select({
id: termsTable.id,
term: termsTable.term,
expansion: termsTable.expansion,
aliases: termsTable.aliases,
category: termsTable.category,
caseSensitive: termsTable.caseSensitive,
autoLink: termsTable.autoLink
})
.from(termsTable)
.where(and(eq(termsTable.siteId, siteId), eq(termsTable.locale, locale)))
.orderBy(sql`lower(${termsTable.term})`, asc(termsTable.term))
}
/** The categories in use in a locale, for the form's suggestions. */
async categories(siteId: string, locale: string): Promise<string[]> {
const rows = await WIKI.db
.selectDistinct({ category: termsTable.category })
.from(termsTable)
.where(
and(
eq(termsTable.siteId, siteId),
eq(termsTable.locale, locale),
isNotNull(termsTable.category)
)
)
return rows
.map((row) => row.category as string)
.sort((a, b) => a.localeCompare(b, locale, { sensitivity: 'base' }))
}
/**
* The term a name or an alias belongs to, for a `?glossary=` link (spec §7).
*
* Aliases count, so that a term renamed with its old name kept as an alias keeps its old links.
*/
async lookup(siteId: string, locale: string, name: string): Promise<string | null> {
const wanted = normalizeName(name)
if (wanted.length < 1) {
return null
}
const rows = await WIKI.db
.select({ id: termsTable.id })
.from(termsTable)
.where(
and(
eq(termsTable.siteId, siteId),
eq(termsTable.locale, locale),
or(
sql`lower(${termsTable.term}) = lower(${wanted})`,
sql`EXISTS (SELECT 1 FROM unnest(${termsTable.aliases}) AS a WHERE lower(a) = lower(${wanted}))`
)
)
)
// -> The term's own name before an alias, should a race ever have let both exist
.orderBy(sql`lower(${termsTable.term}) = lower(${wanted}) DESC`)
.limit(1)
return rows[0]?.id ?? null
}
/** The locale a term is in, which is what every permission over it is asked about. */
async localeOf(siteId: string, id: string): Promise<string | null> {
const rows = await WIKI.db
.select({ locale: termsTable.locale })
.from(termsTable)
.where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId)))
.limit(1)
return rows[0]?.locale ?? null
}
/** A term in full, or null when there is none with this id on this site. */
async get(siteId: string, id: string, tx: Executor = WIKI.db): Promise<GlossaryTerm | null> {
const rows = await tx
.select({
row: termsTable,
authorName: usersTable.name
})
.from(termsTable)
.leftJoin(usersTable, eq(usersTable.id, termsTable.authorId))
.where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId)))
.limit(1)
if (rows.length < 1) {
return null
}
const { row, authorName } = rows[0]
// -> Both sides of the pair table: a relation is one row whichever term it was saved from
const related = await tx
.select({ id: termsTable.id, term: termsTable.term })
.from(relationsTable)
.innerJoin(
termsTable,
sql`${termsTable.id} = CASE WHEN ${relationsTable.termId} = ${id} THEN ${relationsTable.relatedId} ELSE ${relationsTable.termId} END`
)
.where(or(eq(relationsTable.termId, id), eq(relationsTable.relatedId, id)))
.orderBy(sql`lower(${termsTable.term})`)
let documentation: GlossaryTerm['documentation'] = null
if (row.documentationPath) {
const page = await tx
.select({ title: pagesTable.title })
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, row.locale),
eq(pagesTable.path, row.documentationPath)
)
)
.limit(1)
documentation = {
path: row.documentationPath,
label: row.documentationLabel,
title: page[0]?.title ?? null,
exists: page.length > 0
}
}
return {
id: row.id,
siteId: row.siteId,
locale: row.locale,
term: row.term,
expansion: row.expansion,
definition: row.definition,
aliases: row.aliases,
category: row.category,
caseSensitive: row.caseSensitive,
autoLink: row.autoLink,
references: (row.references ?? []) as GlossaryReference[],
relatedTerms: related,
documentation,
author: row.authorId && authorName ? { id: row.authorId, name: authorName } : null,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt)
}
}
/**
* Create a term.
*
* @param actorId The account creating it, or null for an API key
*/
async create(
siteId: string,
locale: string,
input: GlossaryTermInput,
actorId: string | null
): Promise<GlossaryWriteResult> {
return WIKI.db.transaction(async (tx) => {
const values = this.#valuesFrom(input)
const refused = await this.#checkWrite(tx, siteId, locale, null, values, input)
if (refused) {
return refused
}
// -> Both dates from JS rather than `now()`, so that what is stored is the millisecond value a
// save will name as `expectedUpdatedAt` — postgres would keep microseconds
const now = new Date()
const [created] = await tx
.insert(termsTable)
.values({
...values,
siteId,
locale,
creatorId: actorId,
authorId: actorId,
createdAt: now,
updatedAt: now
})
.returning({ id: termsTable.id })
await this.#replaceRelations(tx, created.id, input.relatedTerms ?? [])
const term = (await this.get(siteId, created.id, tx)) as GlossaryTerm
return { ok: true, term, changedFields: [] }
})
}
/**
* Replace a term, provided nobody else has saved it since `expectedUpdatedAt` (spec §5.2).
*
* The compare is made by the UPDATE itself, so there is no window between checking and writing.
*/
async update(
siteId: string,
id: string,
input: GlossaryTermInput,
expectedUpdatedAt: string,
actorId: string | null
): Promise<GlossaryWriteResult> {
return WIKI.db.transaction(async (tx) => {
const before = await this.get(siteId, id, tx)
if (!before) {
return { ok: false, reason: 'notFound' }
}
const values = this.#valuesFrom(input)
const refused = await this.#checkWrite(tx, siteId, before.locale, id, values, input)
if (refused) {
return refused
}
const expected = new Date(expectedUpdatedAt)
const updated = await tx
.update(termsTable)
.set({ ...values, authorId: actorId, updatedAt: new Date() })
.where(
and(
eq(termsTable.id, id),
eq(termsTable.siteId, siteId),
sql`date_trunc('milliseconds', ${termsTable.updatedAt}) = ${expected.toISOString()}::timestamp`
)
)
.returning({ id: termsTable.id })
if (updated.length < 1) {
return { ok: false, reason: 'stale', current: before }
}
await this.#replaceRelations(tx, id, input.relatedTerms ?? [])
const term = (await this.get(siteId, id, tx)) as GlossaryTerm
return { ok: true, term, changedFields: this.#changedFields(before, term) }
})
}
/** Delete a term. Its relations go with it, by cascade. Returns what was deleted, for the log. */
async remove(siteId: string, id: string): Promise<{ term: string; locale: string } | null> {
const deleted = await WIKI.db
.delete(termsTable)
.where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId)))
.returning({ term: termsTable.term, locale: termsTable.locale })
return deleted[0] ?? null
}
/** The columns a create or a save writes, normalized. */
#valuesFrom(input: GlossaryTermInput) {
const term = normalizeName(input.term)
const documentationPath = normalizePath(input.documentationPath)
return {
term,
expansion: optionalName(input.expansion),
definition: (input.definition ?? '').trim(),
aliases: cleanAliases(input.aliases, term),
documentationPath,
// -> A label for no link would be stored and never shown, then come back on the next link set
documentationLabel: documentationPath ? optionalName(input.documentationLabel) : null,
references: cleanReferences(input.references),
caseSensitive: input.caseSensitive ?? false,
autoLink: input.autoLink ?? true,
category: optionalName(input.category)
}
}
/**
* Everything that can refuse a write before it is made: the names, then the related terms.
*
* Inside the write's transaction. Two saves racing to claim the same ALIAS can still both pass —
* no index reaches into the arrays — and that is accepted (spec §3.3): the next edit of either term
* is refused, which surfaces it.
*/
async #checkWrite(
tx: Executor,
siteId: string,
locale: string,
selfId: string | null,
values: { term: string; aliases: string[] },
input: GlossaryTermInput
): Promise<GlossaryWriteResult | null> {
const names = [values.term, ...values.aliases]
const clashes = await tx
.select({ id: termsTable.id, term: termsTable.term, aliases: termsTable.aliases })
.from(termsTable)
.where(
and(
eq(termsTable.siteId, siteId),
eq(termsTable.locale, locale),
selfId ? ne(termsTable.id, selfId) : undefined,
or(
sql`lower(${termsTable.term}) IN (SELECT lower(n) FROM unnest(${sql.param(names)}::text[]) AS n)`,
sql`EXISTS (SELECT 1 FROM unnest(${termsTable.aliases}) AS a WHERE lower(a) IN (SELECT lower(n) FROM unnest(${sql.param(names)}::text[]) AS n))`
)
)
)
.limit(1)
if (clashes.length > 0) {
const holder = clashes[0]
const taken = new Set([holder.term, ...holder.aliases].map((n) => n.toLowerCase()))
// -> Which of the caller's names it was, so the form can mark the right field. The term first:
// a clash on the name itself is the one to fix before any alias
const name = names.find((n) => taken.has(n.toLowerCase())) ?? values.term
return {
ok: false,
reason: 'nameTaken',
field: name === values.term ? 'term' : 'aliases',
name,
holder: { id: holder.id, term: holder.term }
}
}
const related = [...new Set(input.relatedTerms ?? [])]
if (related.length > 0) {
if (selfId && related.includes(selfId)) {
return { ok: false, reason: 'invalidRelated' }
}
const found = await tx
.select({ id: termsTable.id })
.from(termsTable)
.where(
and(
inArray(termsTable.id, related),
eq(termsTable.siteId, siteId),
eq(termsTable.locale, locale)
)
)
if (found.length !== related.length) {
return { ok: false, reason: 'invalidRelated' }
}
}
return null
}
/**
* Make a term's related terms exactly these, from both sides.
*
* Every pair touching the term is replaced, so taking B out of A's list takes A out of B's. Each pair
* is written smaller id first, which is what the table's check requires and what makes it one row.
*/
async #replaceRelations(tx: Executor, id: string, relatedIds: string[]): Promise<void> {
await tx
.delete(relationsTable)
.where(or(eq(relationsTable.termId, id), eq(relationsTable.relatedId, id)))
const pairs = [...new Set(relatedIds)]
.filter((other) => other !== id)
.map((other) =>
id < other ? { termId: id, relatedId: other } : { termId: other, relatedId: id }
)
if (pairs.length > 0) {
await tx.insert(relationsTable).values(pairs).onConflictDoNothing()
}
}
/** Which fields a save changed — names only, never values (spec §8). */
#changedFields(before: GlossaryTerm, after: GlossaryTerm): string[] {
const shape = (term: GlossaryTerm) => ({
term: term.term,
expansion: term.expansion,
definition: term.definition,
aliases: term.aliases,
relatedTerms: term.relatedTerms.map((r) => r.id).sort(),
documentationPath: term.documentation?.path ?? null,
documentationLabel: term.documentation?.label ?? null,
references: term.references,
caseSensitive: term.caseSensitive,
autoLink: term.autoLink,
category: term.category
})
const a = shape(before)
const b = shape(after)
return COMPARED_FIELDS.filter((field) => JSON.stringify(a[field]) !== JSON.stringify(b[field]))
}
}
export const glossary = new Glossary()
+64 -6
View File
@@ -3,7 +3,7 @@ import { and, count, eq, ilike, ne, or, sql } from 'drizzle-orm'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import { invalidateAppShellCache } from '../helpers/appShell.ts'
import { CustomError } from '../helpers/common.ts'
import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
import { resolveLocaleRule, resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
import type { SystemIds } from './types.ts'
import type { FastifyRequest } from 'fastify'
@@ -91,9 +91,22 @@ export const PAGE_PERMISSIONS = [
'read:comments',
'write:comments',
'manage:comments',
'manage:navigation'
'manage:navigation',
'read:glossary',
'manage:glossary'
]
/**
* The page permissions that are about a site and a locale rather than a page.
*
* Granted through rules like every other page permission, but a rule's `match`, `path` and `tags` say
* nothing about them: a glossary has no path. Every rule naming one that is scoped to the site and the
* locale applies, and the modes rank as they always do — see `resolveLocaleRule` in
* `helpers/pageRules.ts`. `checkAccess` routes them there, so a caller asking about one at a page gets
* the locale's answer rather than one that changes from page to page.
*/
export const LOCALE_PERMISSIONS = ['read:glossary', 'manage:glossary']
/** Whether a permission list carries any of `ELEVATED_PERMISSIONS`. */
export function isElevated(permissions: readonly string[]): boolean {
return permissions.some((permission) =>
@@ -231,7 +244,8 @@ export const GUEST_ROLES = [
'read:history',
'read:assets',
'read:comments',
'write:comments'
'write:comments',
'read:glossary'
]
/**
@@ -333,10 +347,44 @@ class Groups {
if (actor.permissions.includes('manage:system')) {
return true
}
if (LOCALE_PERMISSIONS.includes(permission)) {
return this.checkLocaleAccess(actor, permission, page.siteId, page.locale)
}
const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page)
return rule ? rule.mode !== 'DENY' : false
}
/**
* Whether this caller holds a locale permission (`LOCALE_PERMISSIONS`) on a site, in a locale.
*
* The question `checkAccess` cannot ask without a page. Every rule naming the permission that is
* scoped to the site and the locale counts, whatever it addresses, and mode alone decides between
* them: a DENY anywhere closes the locale, a FORCE ALLOW anywhere reopens it.
*
* `manage:glossary` implies `read:glossary` in the same locale: editing a glossary one may not read is
* not a state worth supporting, and the editor needs the list to pick related terms from. Each is
* decided on its own rules first, so a DENY on reading does not take managing with it.
*
* @param locale Absent, rules limited to particular locales are not excluded — as for a page
* reference that leaves the locale out
*/
checkLocaleAccess(
actor: AccessActor,
permission: string,
siteId: string,
locale?: string
): boolean {
if (actor.permissions.includes('manage:system')) {
return true
}
const rules = this.rulesForGroups(actor.groupIds)
const grants = (name: string) => {
const rule = resolveLocaleRule(rules, name, { siteId, locale })
return rule ? rule.mode !== 'DENY' : false
}
return grants(permission) || (permission === 'read:glossary' && grants('manage:glossary'))
}
/**
* Whether any rule this actor holds grants a page permission ANYWHERE.
*
@@ -381,7 +429,13 @@ class Groups {
*/
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'],
roles: [
'read:pages',
'read:assets',
'read:comments',
'write:comments',
'read:glossary'
],
match: 'START',
mode: 'ALLOW',
path: '',
@@ -405,6 +459,10 @@ class Groups {
statement about comments: an operator opening the wiki up flips this one rule to ALLOW,
and what they get is the set the guests group is allowed to hold (`GUEST_ROLES`) rather
than a public wiki whose readers still cannot say anything.
`read:glossary` is deliberately NOT named here. It ignores the path, so a DENY for it
anywhere outranks every ALLOW for it everywhere: named in this rule, an operator's later
ALLOW for guests would do nothing. Guests are denied it anyway with no rule at all.
*/
roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'],
match: 'START',
@@ -462,9 +520,9 @@ class Groups {
async createGroup(name: string): Promise<string> {
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
// -> The rule grants one more than the group-wide list does: see the note on the Users group in
// -> The rule grants more than the group-wide list does: see the note on the Users group in
// `init()` for why the two differ
const startingRoles = [...startingPermissions, 'write:comments']
const startingRoles = [...startingPermissions, 'write:comments', 'read:glossary']
const result = await WIKI.db
.insert(groupsTable)
.values({
+2
View File
@@ -9,6 +9,7 @@ import { blogs } from './blogs.ts'
import { comments } from './comments.ts'
import { extensions } from './extensions.ts'
import { flags } from './flags.ts'
import { glossary } from './glossary.ts'
import { groups } from './groups.ts'
import { hooks } from './hooks.ts'
import { icons } from './icons.ts'
@@ -52,6 +53,7 @@ export default {
comments,
extensions,
flags,
glossary,
groups,
hooks,
icons,
+19 -14
View File
@@ -5,6 +5,7 @@ import {
tree as treeTable
} from '../db/schema.ts'
import { decodeTreePath } from '../helpers/common.ts'
import { liveCondition } from '../helpers/publishing.ts'
/**
* Page graph model
@@ -48,7 +49,8 @@ export interface GraphPageRef {
locale: string
path: string
tags: string[]
publishState: string
/** Whether the page is open to its readers now — see `helpers/publishing.ts`. */
isLive: boolean
}
export interface GraphPage {
@@ -90,21 +92,24 @@ interface PageRow {
path: string
title: string
tags: string[] | null
publishState: string
isLive: boolean
editor: string
alias: string | null
relations: unknown
}
const pageColumns = {
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
tags: pagesTable.tags,
publishState: pagesTable.publishState,
editor: pagesTable.editor,
alias: pagesTable.alias
/** A function, not a constant: whether a page is live is a question about the time of the query. */
function pageColumns() {
return {
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
tags: pagesTable.tags,
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
editor: pagesTable.editor,
alias: pagesTable.alias
}
}
/** Precedence when one page reaches another in more than one way: the strongest link is kept. */
@@ -133,7 +138,7 @@ class PageGraphModel {
// -> The locale's own pages. Relations are read here and nowhere else: they are what tells a
// relation apart from a link in the rows below, and only a source page's matter
const pageRows = (await WIKI.db
.select({ ...pageColumns, relations: pagesTable.relations })
.select({ ...pageColumns(), relations: pagesTable.relations })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale)))
.orderBy(pagesTable.path)
@@ -163,7 +168,7 @@ class PageGraphModel {
locale: row.locale,
path: row.path,
tags: row.tags ?? [],
publishState: row.publishState
isLive: row.isLive
})
if (!seen) {
refused.add(row.id)
@@ -284,7 +289,7 @@ class PageGraphModel {
lookups.push(sql`${pagesTable.alias} = ANY(${sql.param(refs)}::text[])`)
}
const others = (await WIKI.db
.select(pageColumns)
.select(pageColumns())
.from(pagesTable)
.where(
and(eq(pagesTable.siteId, siteId), sql`(${sql.join(lookups, sql` OR `)})`)
+80 -46
View File
@@ -12,6 +12,7 @@ import {
timingSafeCompare
} from '../helpers/common.ts'
import { invalidateAppShellCache } from '../helpers/appShell.ts'
import { liveCondition, maySeeUnpublished } from '../helpers/publishing.ts'
import { rewriteSourceLinks } from '../helpers/linkRewrite.ts'
import type { SourceSyntax } from '../helpers/linkRewrite.ts'
import { relinkHref, rewriteRenderLinks } from '../helpers/pageLinks.ts'
@@ -899,7 +900,7 @@ class Pages {
* Answered from the group id the page already carries, so a page that is not part of one costs no
* query at all — which is nearly every page on nearly every site.
*
* `publicOnly` narrows it the same way it narrows the page itself: a draft translation is not
* `unpublished` narrows it the same way it narrows the page itself: a draft translation is not
* something to offer a reader who could not open it. It is what makes the locale selector's list of
* languages the list of languages this reader can actually reach.
*/
@@ -908,8 +909,13 @@ class Pages {
{
localeGroupId,
id,
publicOnly = false
}: { localeGroupId: string | null; id: string; publicOnly?: boolean }
unpublished = true
}: {
localeGroupId: string | null
id: string
/** As for `getPage`. */
unpublished?: boolean | ((page: RulePageRef) => boolean)
}
): Promise<PageLocaleRelation[]> {
if (!localeGroupId) {
return []
@@ -919,19 +925,28 @@ class Pages {
eq(pagesTable.localeGroupId, localeGroupId),
ne(pagesTable.id, id)
]
if (publicOnly) {
conditions.push(eq(pagesTable.publishState, 'published'))
if (unpublished === false) {
conditions.push(liveCondition(pagesTable))
}
return await WIKI.db
const rows = await WIKI.db
.select({
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
tags: pagesTable.tags
tags: pagesTable.tags,
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
})
.from(pagesTable)
.where(and(...conditions))
.orderBy(pagesTable.locale)
return rows
.filter(
(row) =>
row.isLive ||
typeof unpublished !== 'function' ||
unpublished({ siteId, locale: row.locale, path: row.path, tags: row.tags ?? [] })
)
.map(({ locale, path, title, tags }) => ({ locale, path, title, tags }))
}
/**
@@ -1255,7 +1270,7 @@ class Pages {
* through the same check, because the enforcement is this method and not the client.
*
* **The defaults hand over the whole page**, `unlocked` and `withPassword` included, the way they do
* for `publicOnly` beside them: most callers here are a save, a move, a delete or a re-render, and
* for `unpublished` beside them: most callers here are a save, a move, a delete or a re-render, and
* none of those is a reader — a save that got a withheld body back would answer its author with an
* empty page, and a re-render would store one. A path that serves a reader has to say so, and there
* are exactly two: the `GET` route, and `unlockPage` below.
@@ -1377,8 +1392,9 @@ class Pages {
* A sitemap is a list handed to search engines, so the question is not what exists but what a
* crawler may both reach and index. Four things decide it, three of them in SQL:
*
* - **Published**, on the same reading as everywhere else — a `scheduled` page is not published yet
* whatever its dates say, and a draft never was.
* - **Live**, on the same reading as everywhere else (`helpers/publishing.ts`) — a `scheduled` page
* inside its window is listed, one outside it is not, and a draft never is. The list is cached, so a
* window opening or closing reaches it within `SITEMAP_TTL`.
* - **Not a redirection**, which has no body to index and sends its reader elsewhere anyway.
* - **Not password protected**, since what a crawler would reach there is the lock screen.
* - **`isSearchable`**, the page property whose whole purpose is keeping a page out of search
@@ -1403,7 +1419,7 @@ class Pages {
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.publishState, 'published'),
liveCondition(pagesTable),
eq(pagesTable.isSearchable, true),
ne(pagesTable.editor, REDIRECT_EDITOR),
isNull(pagesTable.password)
@@ -1435,14 +1451,14 @@ class Pages {
* decide and nothing about the requester does, which is what makes the answer the same for whoever
* fetched it and therefore safe to cache and hand on.
*
* Published only, as it is everywhere the public is being answered — which is also what lets the app
* Live pages only, as everywhere the public is being answered — which is also what lets the app
* shell say 404 rather than 200 at a path with nothing published at it.
*/
async describePageForPublic(
siteId: string,
ref: { locale: string; path: string }
): Promise<PageDescription | null> {
return this.describePage(siteId, ref, WIKI.models.groups.actorForPublic(), true)
return this.describePage(siteId, ref, WIKI.models.groups.actorForPublic(), false)
}
/**
@@ -1450,9 +1466,9 @@ class Pages {
*
* Deliberately the same cut the page route itself makes (`GET /sites/:siteId/pages/:pageIdOrHash`),
* so that the title in the document and the page the app then draws can never be about different
* things: its `read:pages` check per path, and its `publicOnly: !actorFrom(req)` — which is to say
* any signed-in session sees an unpublished page, and an API key is answered as the public is, since
* a draft belongs to the people working on it rather than to whatever holds a token.
* things: its `read:pages` check per path, and its `unpublished` — a page that is not live is
* described only to a signed-in session holding `write:pages` or `manage:pages` on it, and an API
* key is answered as the public is (`helpers/publishing.ts`).
*
* Not for caching across requesters: the answer is one reader's.
*/
@@ -1462,13 +1478,19 @@ class Pages {
req: FastifyRequest
): Promise<PageDescription | null> {
const isSession = Boolean(req.session?.authenticated && req.session.user?.id)
return this.describePage(siteId, ref, WIKI.models.groups.actorForRequest(req), !isSession)
const actor = WIKI.models.groups.actorForRequest(req)
return this.describePage(
siteId,
ref,
actor,
isSession ? (page: RulePageRef) => maySeeUnpublished(actor, page) : false
)
}
/**
* The read behind both, which is the only thing that should call it.
*
* `actor` and `publicOnly` are paired by the two methods above rather than left to a caller, because
* `actor` and `unpublished` are paired by the two methods above rather than left to a caller, because
* the wrong pairing — the public actor with unpublished pages included — would describe a draft to
* whoever asked.
*/
@@ -1476,7 +1498,7 @@ class Pages {
siteId: string,
{ locale, path }: { locale: string; path: string },
actor: AccessActor,
publicOnly: boolean
unpublished: false | ((page: RulePageRef) => boolean)
): Promise<PageDescription | null> {
const conditions = [
eq(pagesTable.siteId, siteId),
@@ -1486,12 +1508,13 @@ class Pages {
// whoever asked simply need not have — nothing here was handed a hash to look up
eq(pagesTable.path, path)
]
if (publicOnly) {
conditions.push(eq(pagesTable.publishState, 'published'))
if (unpublished === false) {
conditions.push(liveCondition(pagesTable))
}
const rows = await WIKI.db
.select({
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
@@ -1515,6 +1538,9 @@ class Pages {
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', { ...row, siteId })) {
return null
}
if (!row.isLive && unpublished && !unpublished({ ...row, siteId })) {
return null
}
// -> A page and its lock are two things: the title and the description are not what a password
// covers (the search index treats them the same way), so they travel and the body does not.
@@ -1538,11 +1564,11 @@ class Pages {
/**
* The locales a page can be read in, for the alternates a document names.
*
* Published and readable by this actor, and including the page itself, which is what an `hreflang`
* Live and readable by this actor, and including the page itself, which is what an `hreflang`
* set calls for. A translation the asker may not read is not a URL to point them at — for the public
* that is the same cut the sitemap makes, which is why the two say the same thing about a page.
*
* Published regardless of who is asking, unlike the page itself: a draft translation is not an
* Live regardless of who is asking, unlike the page itself: a draft translation is not an
* alternate version of a document, it is one that does not exist yet.
*/
private async readableAlternates(
@@ -1560,7 +1586,7 @@ class Pages {
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.localeGroupId, localeGroupId),
eq(pagesTable.publishState, 'published')
liveCondition(pagesTable)
)
)
.orderBy(pagesTable.locale)
@@ -1595,7 +1621,7 @@ class Pages {
hash,
locale,
withContent = false,
publicOnly = false,
unpublished = true,
unlocked = true,
withPassword = true
}: {
@@ -1609,17 +1635,21 @@ class Pages {
* request addressing a page by hash has in hand before the row is read.
*/
withContent?: boolean | ((page: RulePageRef) => boolean)
/** Restrict to what a reader with no session may see: published pages. */
publicOnly?: boolean
/**
* Whether a page that is not live (`helpers/publishing.ts`) may be returned: `false` holds the
* read to live pages, and a predicate is asked of a page that is not live once its row is read.
* Open by default, since most callers are the wiki acting on its own pages; anything answering a
* reader passes one.
*/
unpublished?: boolean | ((page: RulePageRef) => boolean)
unlocked?: boolean | ((pageId: string) => boolean)
withPassword?: boolean
}): Promise<Page | null> {
const conditions = [eq(pagesTable.siteId, siteId)]
if (publicOnly) {
// -> Page-level access rules are not implemented, so this is the whole of it: an anonymous
// reader sees published pages, and nothing else. A password does not hide a page from them —
// it withholds the body until they enter it, which is what `locked` below does.
conditions.push(eq(pagesTable.publishState, 'published'))
if (unpublished === false) {
// -> A password does not hide a page from a reader — it withholds the body until they enter
// it, which is what `locked` below does. Being unpublished does.
conditions.push(liveCondition(pagesTable))
}
if (id) {
conditions.push(eq(pagesTable.id, id))
@@ -1634,6 +1664,7 @@ class Pages {
const results = await WIKI.db
.select({
page: pagesTable,
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
authorName: usersTable.name,
authorHasAvatar: usersTable.hasAvatar,
navigationId: treeTable.navigationId,
@@ -1649,16 +1680,18 @@ class Pages {
if (!row) {
return null
}
const ref: RulePageRef = {
path: row.page.path,
locale: row.page.locale,
siteId,
tags: row.page.tags ?? []
}
// -> Not there, rather than refused: whether a draft exists at a path is the draft's business
if (!row.isLive && typeof unpublished === 'function' && !unpublished(ref)) {
return null
}
const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked
const includeContent =
typeof withContent === 'function'
? withContent({
path: row.page.path,
locale: row.page.locale,
siteId,
tags: row.page.tags ?? []
})
: withContent
const includeContent = typeof withContent === 'function' ? withContent(ref) : withContent
return this.toPage(
{
...row.page,
@@ -1670,7 +1703,7 @@ class Pages {
localeRelations: await this.localeRelationsFor(siteId, {
localeGroupId: row.page.localeGroupId,
id: row.page.id,
publicOnly
unpublished
})
},
{
@@ -1696,14 +1729,15 @@ class Pages {
hash,
locale,
password,
publicOnly = false
unpublished = true
}: {
siteId: string
id?: string
hash?: string
locale?: string
password: string
publicOnly?: boolean
/** As for `getPage`. */
unpublished?: boolean | ((page: RulePageRef) => boolean)
}): Promise<Page | null> {
/*
Asked for as a reader would see it, for two reasons: a wrong guess must not assemble the body in
@@ -1714,7 +1748,7 @@ class Pages {
id,
hash,
locale,
publicOnly,
unpublished,
unlocked: false,
withPassword: false
})
@@ -1735,7 +1769,7 @@ class Pages {
return this.getPage({
siteId,
id: page.id,
publicOnly,
unpublished,
unlocked: true,
withPassword: false
})
+34 -17
View File
@@ -1,4 +1,9 @@
import { sql } from 'drizzle-orm'
import {
liveCondition,
maySeeUnpublished,
maySeeUnpublishedAnywhere
} from '../helpers/publishing.ts'
import type { AccessActor } from './groups.ts'
/**
@@ -117,10 +122,12 @@ export interface SearchPagesParams {
orderByDirection?: 'asc' | 'desc'
offset?: number
limit?: number
/** Restrict to what a reader with no session may see: published pages. */
publicOnly?: boolean
/** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */
includeDrafts?: boolean
/**
* Whether a page that is not live (`helpers/publishing.ts`) may be listed, for an `actor` holding
* `write:pages` or `manage:pages` on it. False — a request without a session — holds every result
* to live pages.
*/
unpublished?: boolean
/**
* Who is searching, so that a result they could not open never reaches them.
*
@@ -258,8 +265,7 @@ class Search {
orderByDirection = 'desc',
offset = 0,
limit = 25,
publicOnly = false,
includeDrafts = false,
unpublished = false,
hideProtectedContent = true,
actor
}: SearchPagesParams): Promise<SearchPagesResult> {
@@ -286,12 +292,19 @@ class Search {
if (hasQuery) {
conditions.push(sql`p.ts @@ ${tsQuery}`)
}
if (publicOnly) {
// -> Matches what a page view shows an anonymous reader, so that search cannot surface a page
// that could not then be opened
conditions.push(sql`p."publishState" = 'published'`)
} else if (!includeDrafts) {
conditions.push(sql`p."publishState" <> 'draft'`)
/*
Matches what the page view shows, so that search cannot surface a page that could not then be
opened. Who may see a page that is not live is a page rule's answer, so it is settled per row
below; a searcher who holds neither permission anywhere is held to live pages here instead.
*/
const liveNow = liveCondition({
publishState: sql`p."publishState"`,
publishStartDate: sql`p."publishStartDate"`,
publishEndDate: sql`p."publishEndDate"`
})
const checkUnpublished = unpublished && !!actor && maySeeUnpublishedAnywhere(actor)
if (!checkUnpublished) {
conditions.push(liveNow)
}
if (hideProtectedContent && hasQuery) {
/*
@@ -392,19 +405,23 @@ class Search {
let totalHits: number
if (checkRules) {
const candidates = await WIKI.db.execute(sql`
SELECT p.id, p.path, p.locale, p.tags, ${relevancy} AS relevancy
SELECT p.id, p.path, p.locale, p.tags, ${liveNow} AS "isLive", ${relevancy} AS relevancy
FROM pages p
WHERE ${where}
ORDER BY ${ordering}
`)
const readable = ((candidates.rows ?? candidates) as any[]).filter((row) =>
WIKI.models.groups.checkAccess(actor, 'read:pages', {
const readable = ((candidates.rows ?? candidates) as any[]).filter((row) => {
const ref = {
siteId,
path: row.path as string,
locale: row.locale as string,
tags: (row.tags ?? []) as string[]
})
)
}
return (
WIKI.models.groups.checkAccess(actor, 'read:pages', ref) &&
(row.isLive === true || maySeeUnpublished(actor, ref))
)
})
pageIds = readable.slice(offset, offset + limit).map((row) => row.id as string)
totalHits = readable.length
} else {
+4
View File
@@ -139,6 +139,9 @@ class Sites {
// 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,
// -> On: a glossary with no terms costs a reader nothing, and the menu row that opens it is
// also gated on `read:glossary`, which nobody but a fresh install's users holds
glossary: true,
lastEditedBy: true,
// -> On: a reader who follows a link to a folder rather than a page is better served by
// what is in it than by a notice that there is nothing there
@@ -468,6 +471,7 @@ class Sites {
collaborativeEditing: true,
ratingsMode: 'off',
comments: true,
glossary: true,
lastEditedBy: true,
listFolderChildren: true,
notifications: true,
+64 -24
View File
@@ -1,5 +1,7 @@
import { and, asc, desc, eq, exists, inArray, ne, not, or, sql, type SQL } from 'drizzle-orm'
import { alias, type PgColumn } from 'drizzle-orm/pg-core'
import { liveCondition } from '../helpers/publishing.ts'
import type { RulePageRef } from '../helpers/pageRules.ts'
import { pages as pagesTable, tree as treeTable } from '../db/schema.ts'
import {
CustomError,
@@ -217,10 +219,12 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
}
/**
* What a page has to be for a reader to be shown that it exists.
* What a page has to be for a reader to be shown that it exists, as far as SQL can say.
*
* Deliberately the same rule the page view itself applies (see `pages.getPage`'s `publicOnly`), so
* that a menu never offers a page that would answer 404 — nor hides one that would open.
* Deliberately the same rule the page view itself applies (see `pages.getPage`'s `unpublished`), so
* that a menu never offers a page that would answer 404 — nor hides one that would open. With
* `unpublished` false a page has to be live; with a predicate every state comes back and the rows are
* held to it by `isListable`, since who may see a page that is not live is a page rule's answer.
*
* A password-protected page is listed. It is not hidden but locked: opening it puts the reader in
* front of the unlock prompt, which is exactly where someone who has the password wants to end up,
@@ -229,17 +233,27 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
* The columns come in one by one rather than as a table, because this is applied both to `pages` and
* to an alias of it, and an alias is a different type.
*
* @param publicOnly Restrict to what a reader with no session may see. `isBrowsable` applies either
* way: it is the author saying "not in the tree", not an access rule.
* @param unpublished As for `pages.getPage`. `isBrowsable` applies either way: it is the author saying
* "not in the tree", not an access rule.
*/
function pageIsVisible(
columns: { isBrowsable: PgColumn; publishState: PgColumn },
publicOnly: boolean
columns: {
isBrowsable: PgColumn
publishState: PgColumn
publishStartDate: PgColumn
publishEndDate: PgColumn
},
unpublished: Unpublished
): (SQL | undefined)[] {
return [
eq(columns.isBrowsable, true),
...(publicOnly ? [eq(columns.publishState, 'published')] : [])
]
return [eq(columns.isBrowsable, true), ...(unpublished ? [] : [liveCondition(columns)])]
}
/** Whether a page that is not live may be listed: `getPage`'s `unpublished`, minus the `true`. */
type Unpublished = false | ((page: RulePageRef) => boolean)
/** The half of `pageIsVisible` that has to be asked of each row once it is read. */
function isListable(unpublished: Unpublished, isLive: boolean, page: RulePageRef): boolean {
return isLive || (unpublished !== false && unpublished(page))
}
/**
@@ -373,7 +387,7 @@ class Tree {
* @param path Slash-separated path to list. The site root when empty.
* @param depth How many folders below the path to include. 0, the default, is the path itself.
* @param tags Only pages carrying every one of these tags.
* @param publicOnly Restrict to what a reader with no session may see. See `pageIsVisible`.
* @param unpublished Whether a page that is not live may be listed. See `pageIsVisible`.
*/
async listPages({
siteId,
@@ -384,7 +398,7 @@ class Tree {
orderBy = 'title',
orderByDirection = 'asc',
depth = 0,
publicOnly = true
unpublished = false
}: {
siteId: string
path?: string | null
@@ -394,7 +408,7 @@ class Tree {
orderBy?: TreeOrderBy
orderByDirection?: 'asc' | 'desc'
depth?: number
publicOnly?: boolean
unpublished?: Unpublished
}): Promise<ListedPage[]> {
if (limit < 1 || limit > MAX_LIMIT) {
throw new CustomError('treeInvalidLimit', `The limit must be between 1 and ${MAX_LIMIT}.`)
@@ -416,7 +430,8 @@ class Tree {
title: treeTable.title,
tags: treeTable.tags,
description: pagesTable.description,
icon: pagesTable.icon
icon: pagesTable.icon,
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
})
.from(treeTable)
.innerJoin(pagesTable, eq(pagesTable.id, treeTable.id))
@@ -427,15 +442,15 @@ class Tree {
eq(treeTable.type, 'page'),
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`,
...(tags && tags.length > 0 ? [sql`${treeTable.tags} @> ${sql.param(tags)}`] : []),
...pageIsVisible(pagesTable, publicOnly)
...pageIsVisible(pagesTable, unpublished)
)
)
.orderBy(direction(treeTable[orderBy]))
.limit(limit)
return rows.map((row) => {
return rows.flatMap((row) => {
const folderPath = decodeTreePath(row.folderPath ?? '') ?? ''
return {
const page = {
id: row.id,
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
title: row.title,
@@ -443,6 +458,14 @@ class Tree {
icon: row.icon ?? '',
tags: row.tags ?? []
}
return isListable(unpublished, row.isLive, {
siteId,
locale,
path: page.path,
tags: page.tags
})
? [page]
: []
})
}
@@ -456,19 +479,19 @@ class Tree {
* invisible is a dead end rather than something to offer.
*
* @param path Slash-separated path of the folder to list. The site root when empty.
* @param publicOnly Restrict pages to what a reader with no session may see. See `pageIsVisible`.
* @param unpublished Whether a page that is not live may be listed. See `pageIsVisible`.
* @returns The level, or null when there is no such folder
*/
async browse({
siteId,
path,
locale,
publicOnly = true
unpublished = false
}: {
siteId: string
path?: string | null
locale: string
publicOnly?: boolean
unpublished?: Unpublished
}): Promise<BrowseLevel | null> {
const encodedPath = encodeTreePath(path)
const basePath = decodeTreePath(encodedPath) ?? ''
@@ -509,6 +532,10 @@ class Tree {
A folder is created for whatever is put in it, so it can end up holding only assets, only
drafts, or nothing at all — descending into any of those lands on an empty menu. `EXISTS` stops
at the first hit, so this costs an index lookup per folder in the level rather than a count.
Approximate for a reader who may see SOME unpublished pages: whether they may see a particular
one below is a page rule's answer, which SQL cannot ask, so for them any state counts here. The
same is already true of `read:pages`, which is not asked of a folder's contents either.
*/
const holdsVisiblePages = exists(
WIKI.db
@@ -521,7 +548,7 @@ class Tree {
eq(descendant.locale, treeTable.locale),
eq(descendant.type, 'page'),
sql`${descendant.folderPath} <@ (${childPathPrefix}::text || ${treeTable.fileName})::ltree`,
...pageIsVisible(descendantPage, publicOnly)
...pageIsVisible(descendantPage, unpublished)
)
)
)
@@ -538,6 +565,7 @@ class Tree {
title: treeTable.title,
tags: treeTable.tags,
icon: pagesTable.icon,
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
holdsVisiblePages: sql<boolean>`${holdsVisiblePages}`.mapWith(Boolean)
})
.from(treeTable)
@@ -549,7 +577,7 @@ class Tree {
eq(treeTable.folderPath, encodedPath),
or(
eq(treeTable.type, 'folder'),
and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, publicOnly))
and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, unpublished))
)
)
)
@@ -561,8 +589,20 @@ class Tree {
if (row.type === 'folder' && !row.holdsVisiblePages) {
continue
}
const rowPath = basePath ? `${basePath}/${row.fileName}` : row.fileName
if (
row.type === 'page' &&
!isListable(unpublished, row.isLive, {
siteId,
locale,
path: rowPath,
tags: row.tags ?? []
})
) {
continue
}
const entry = merged.get(row.fileName) ?? {
path: basePath ? `${basePath}/${row.fileName}` : row.fileName,
path: rowPath,
fileName: row.fileName,
title: row.title,
icon: null,
+510
View File
@@ -0,0 +1,510 @@
# Glossary
**Status:** phases 1 to 3 of [§11](#11-suggested-order) implemented — storage, permissions, the API
and the overlay. URLs (§7) and the page-move rewrite (§5.3) are still to come. Where building it
changed the design, this document was changed with it.
**Covers:** what a glossary term is, how it is stored, who may read and edit it, the overlay it is read
and edited in, its URL, and what the first version does to leave room for linking terms automatically
in page text later.
A wiki accumulates vocabulary: product names, acronyms, words that mean something narrower here than
they do anywhere else. A glossary gives each of them one place to be defined, one page that documents
it properly, and a way of saying which terms belong together.
The constraints this is written against:
- **One site, one locale, one glossary.** A term belongs to a site and to a locale, the same way a page
does. Nothing is shared between sites, and a term is not translated by being edited in another
locale.
- **A name means one term.** A term's name and every one of its aliases are unique within its site and
locale, compared case-insensitively. That is what lets a URL, a related-terms list and, later, a
word in a paragraph each resolve to exactly one entry.
- **The glossary is read over the page, not instead of it.** It is an overlay, fetched the first time
somebody opens it, so a reader who never does pays nothing for it.
---
## 1. Goals and non-goals
**Goals**
- A **Glossary overlay**, opened from the header's **Library** menu: terms listed in a sidebar, the
selected term in the main panel, and a **New Term** button at the top of the sidebar that opens the
form in the main panel.
- The fields in [§3.1](#31-glossaryterms): term, expansion, definition, aliases, related terms,
documentation page and its label, external references, case sensitivity, category, and auto-link.
- **Two-way related terms.** Relating A to B relates B to A.
- **A URL per term**, so a term can be linked to from a chat message or another page
([§7](#7-urls)).
- **Two new permissions** ([§4](#4-permissions)): `read:glossary` for reading a site's glossary in a
locale, and `manage:glossary` for creating, editing and deleting its terms. Both are page-rule
permissions whose rule's path is ignored.
- **Moving a page with `updateLinks` updates every term documented by it** ([§5.3](#53-moving-a-page)).
- **Lost updates are refused, not silent**: a save against a term somebody else changed in the meantime
answers 409 ([§5.2](#52-concurrent-edits)).
- **A per-site switch**, `features.glossary`.
**Non-goals for the first version**
- **Linking terms in page text automatically.** Planned; the `autoLink` column ships now and its
toggle is shown disabled. [§9](#9-auto-linking-later) is the design the first version is built to
accommodate.
- **A `block-glossary` content block**, listing terms (or one category's) inside a page, and an inline
block for marking a term explicitly.
- **Glossary terms in site search results.**
- **Linking equivalent terms across locales**, the way pages are grouped into locale groups.
- **Import and export** (CSV, JSON).
- **Revision history of a term.** The audit log records who changed what and which fields; it does not
keep the previous text ([§8](#8-audit-log)).
- **Real-time collaborative editing.** [§5.2](#52-concurrent-edits) is the whole of the concurrency
story.
---
## 2. Architecture
| Piece | Where |
| --- | --- |
| Tables | `glossaryTerms`, `glossaryTermRelations` in `backend/db/schema.ts`, one generated migration (`--name=glossary`) |
| Model | `backend/models/glossary.ts`, exposed as `WIKI.models.glossary` |
| API | `backend/api/glossary.ts`, schemas in `backend/api/schemas/` |
| Permissions | `read:glossary` and `manage:glossary` in `PAGE_PERMISSIONS` and `LOCALE_PERMISSIONS` (`models/groups.ts`); the group editor's rule list (`GroupEditOverlay.vue`); the lists in CLAUDE.md |
| Overlay | `frontend/src/components/GlossaryOverlay.vue`, registered in `MainOverlayDialog.vue`, opened by `siteStore.openGlossary()` |
| Definition renderer | `frontend/src/renderers/glossary.js`, built on `createProseMarkdown` from `renderers/comment.js` ([§6.4](#64-the-definition)) |
| Access | `frontend/src/stores/glossary.js`, from `GET /glossary/access` ([§4.5](#45-on-the-frontend)) |
| Entry point | The Glossary row of `HeaderLibraryMenu.vue` |
| Strings | `glossary.*` in `backend/locales/en.json` |
---
## 3. Storage
### 3.1 `glossaryTerms`
| Column | Type | Notes |
| --- | --- | --- |
| `id` | uuid pk | |
| `siteId` | uuid, → `sites`, on delete cascade | |
| `locale` | varchar(255), not null | Same shape as `pages.locale` |
| `term` | varchar(255), not null | Trimmed, inner whitespace collapsed, NFC |
| `expansion` | varchar(255), nullable | The full form, when the term is an abbreviation |
| `definition` | text, not null, default `''` | Basic markdown source, never HTML ([§6.4](#64-the-definition)) |
| `aliases` | text[], not null, default `{}` | Alternate forms and inflections; normalized like `term` |
| `documentationPath` | varchar(255), nullable | A page path in the term's own locale, no locale prefix |
| `documentationLabel` | varchar(255), nullable | Null means the default, "Read more", which comes from the locale strings and is not stored |
| `references` | jsonb, not null, default `[]` | `[{ url, label }]`, in the order given |
| `caseSensitive` | boolean, not null, default false | How the term is *matched in text*; never how it is compared for uniqueness ([§3.3](#33-uniqueness)) |
| `autoLink` | boolean, not null, default true | Whether the term is linked automatically in page text; no effect until [§9](#9-auto-linking-later) ships |
| `category` | varchar(255), nullable | One per term, free text, used to group terms in the sidebar |
| `creatorId` | uuid, → `users`, on delete set null | |
| `authorId` | uuid, → `users`, on delete set null | Who saved it last |
| `createdAt` | timestamp, default now | |
| `updatedAt` | timestamp, default now | Also the concurrency token ([§5.2](#52-concurrent-edits)) |
Indexes:
- `(siteId, locale)` for the list.
- **Unique `(siteId, locale, lower(term))`**: the database's half of [§3.3](#33-uniqueness).
- `(siteId, locale, documentationPath)` where `documentationPath` is not null, for
[§5.3](#53-moving-a-page).
**Category is a column, not the site `tags` table.** That table counts how many pages use each tag and
is what `/_tags` is drawn from; glossary categories in it would appear on the Tags page as page tags
that no page carries. The category suggestions in the form come from
`SELECT DISTINCT category` over the locale's terms.
### 3.2 `glossaryTermRelations`
| Column | Type |
| --- | --- |
| `termId` | uuid, → `glossaryTerms`, on delete cascade |
| `relatedId` | uuid, → `glossaryTerms`, on delete cascade |
Primary key `(termId, relatedId)`, a `CHECK (termId < relatedId)`, and an index on `relatedId`.
**One row per pair, read from both sides.** The check stores each relation exactly once with the
smaller id first, so "B is related to A" and "A is related to B" are the same row and cannot drift
apart. A term's related terms are the union of `relatedId where termId = $1` and
`termId where relatedId = $1`. Saving a term's `relatedTerms` replaces the rows touching that term, in
the same transaction as the term itself, so removing B from A's list removes A from B's.
A join table rather than a uuid array on the term, so that deleting a term takes it out of every other
term's list through the cascade alone, with no cleanup code to forget.
Related terms must be in the same site and locale; the model refuses any other id with 400.
### 3.3 Uniqueness
**A term's name and its aliases share one namespace with every other term's name and aliases** in the
same site and locale, compared with `lower()` after normalization. So:
- `REST` and `rest` cannot be two terms. `caseSensitive` on `REST` says that the word `rest` in a
paragraph is not a mention of it; it does not make room for a second entry spelled `rest`.
- An alias may not equal another term's name, nor one of its aliases.
- An alias equal to the term's own name, or repeated, is dropped silently on save rather than refused.
The unique index covers term against term. Everything involving an alias is checked in the model,
inside the save's transaction, and refused with **409** naming the term that already holds the name, so
the form can say which one. Two saves racing to claim the same alias are not caught by the index; that
is accepted, since the outcome is two terms sharing an alias, which a later edit of either one refuses
and so surfaces.
Comparison uses Postgres `lower()` on the server and `toLocaleLowerCase(locale)` in the browser. The two
agree for every case a glossary is likely to meet; the server is the one that decides.
### 3.4 Limits
| Field | Limit |
| --- | --- |
| `term`, `expansion`, `category`, `documentationLabel`, each alias | 255 characters |
| `aliases` | 50 |
| `definition` | 10,000 characters |
| `references` | 20; `url` is `http:` or `https:` only, `label` up to 255 characters and optional (the URL stands in) |
| `relatedTerms` | 50 |
---
## 4. Permissions
Two permissions of the same kind: page-rule permissions whose rule's path is ignored, so that both are
granted per site and per locale. A site may open its glossary to the readers of one locale and not
another, and hand the maintenance of each locale's glossary to the people who write in it.
### 4.1 The rule
Both are **page-rule permissions**, added to `PAGE_PERMISSIONS` and granted through a group's rules like
`read:pages`. They are the page permissions whose rule's **path is ignored** (`LOCALE_PERMISSIONS`): the
question they answer is about a site and a locale, and a glossary has no path.
**Every rule naming the permission that is scoped to the site and the locale applies, whatever its
`match`, `path` or `tags`, and the modes rank as they do for any other rule:**
ALLOW < DENY < FORCE ALLOW
So: no such rule, denied; only ALLOWs, granted; any DENY overrides every ALLOW; any FORCE ALLOW
overrides every DENY. This is step 4 of the ordering in
[`helpers/pageRules.ts`](../../backend/helpers/pageRules.ts) on its own (`resolveLocaleRule`). Steps 1
to 3, which rank rules by how precisely they address a page, have nothing to rank when no page is being
asked about, so every rule counts as equally specific and mode alone decides.
What follows from ignoring the path, and what the permissions' hints in the group editor have to say:
- **A rule anywhere speaks for the whole locale.** An ALLOW written for `/docs` opens the entire
glossary, the same as one for the site root.
- **A DENY anywhere closes the whole locale**, and a more specific ALLOW does not reopen it the way it
would for pages. Only a FORCE ALLOW does. A DENY rule for `/confidential` that also names
`read:glossary` therefore hides the glossary from that group entirely.
Both are asked by `groups.checkLocaleAccess(actor, permission, siteId, locale)`, and `checkAccess` routes
them there, so asking about one at a page gets the locale's answer. The worker-side `rulesAllow` is not
involved: nothing in a worker reads the glossary. `manage:system` implies both, as it implies
everything. No route declares either in `config.permissions` (the hook reads the global list only);
every glossary route checks in the handler, with a `No route-level permissions:` comment as
`api/pages.ts` does.
### 4.2 `read:glossary`
- **`manage:glossary` in a locale implies it there.** Editing a glossary one may not read is not a state
worth supporting, and the editor has to see the list to pick related terms. Each is decided on its own
rules first, so a DENY on reading does not take managing with it.
- **Guests may hold it**: it joins `GUEST_ROLES` in `models/groups.ts` (and the copy in
`GroupEditOverlay.vue`), which is what lets a public wiki open its glossary to readers without an
account.
- **Seeding**: `read:glossary` is added beside `read:pages` in the Users group's seeded ALLOW rule
(`groups.init()`) and in the starting rule of a newly created group (`createGroup`). It is
deliberately **not** added to the Guests group's seeded DENY rule. Guests are denied anyway with no
rule at all, and a DENY there would mean an administrator's later ALLOW for guests did nothing (the
DENY outranks it, path or no path), leaving FORCE ALLOW or editing the default rule as the only ways
in. Without it, a fresh install's guests cannot read the glossary until an administrator writes an
ALLOW rule for it, as with pages.
### 4.3 `manage:glossary`
Create, edit and delete terms in the rule's locales. A write is asked about in the locale of the term it
touches: the body's `locale` on a create, the stored term's on a save or a delete. A term the caller may
not even read answers 404 there, as it does on the read route; one they may read but not manage, 403.
- **Not seeded anywhere** and not in `GUEST_ROLES`: maintaining the glossary is something an
administrator hands out, and a guest has no session to attribute an edit to.
### 4.4 What a reader sees
**The documentation link is shown to every reader of the term**, whether or not they may read the page
it points at; following it answers as the page route always does. Hiding it would need a page-rule check
per term in the list, for no information the reader does not already have from the term itself.
### 4.5 On the frontend
`userStore.pagePermissions` cannot answer `read:glossary`: it is what the session holds at the current
PATH, it now arrives with the page payload rather than from `pages/userPermissions`, and it is empty on
every route that is not a page — `/_tags` included, where the Library menu is still drawn. So the
answer has an endpoint of its own, `GET /sites/:siteId/glossary/access`, which reports the site switch,
the active locales the session may read the glossary in, and the ones it may edit it in.
`stores/glossary.js` asks it once per site and session (again after a login or a logout), since none
of it changes from page to page.
- The Library menu's Glossary row shows when `features.glossary` is on and at least one locale is
readable. The overlay opens in the interface's locale when that one is readable, and in the first
readable one otherwise.
- New Term, Edit and Delete show where the locale on screen is one of the editable ones.
- The overlay's locale picker offers every active locale. One the session may not read the glossary in
answers the list with 403, and the sidebar says so in place of the list.
---
## 5. Backend
### 5.1 API
All under `/_api/sites/:siteId/glossary`, tagged `Glossary`.
| Method and path | Permission | What it does |
| --- | --- | --- |
| `GET /access` | none (it reports the permissions) | `{ enabled, readableLocales, manageableLocales }` for the caller ([§4.5](#45-on-the-frontend)) |
| `GET /?locale=` | `read:glossary` in the locale | The list: `id`, `term`, `expansion`, `aliases`, `category`, `caseSensitive`, `autoLink`. Everything the sidebar draws and everything [§9](#9-auto-linking-later) will need, and nothing else |
| `GET /categories?locale=` | `read:glossary` in the locale | Distinct categories, for the form's suggestions |
| `GET /:termId` | `read:glossary` in the term's locale | The whole term, with `relatedTerms` as `[{ id, term }]` and `documentation` as `{ path, label, title, exists }` |
| `GET /lookup?locale=&name=` | `read:glossary` in the locale | Resolves a name or alias to a term id, for [§7](#7-urls); 404 when nothing matches |
| `POST /` | `manage:glossary` in the body's locale | Creates a term; answers it in full |
| `PUT /:termId` | `manage:glossary` in the term's locale | Replaces a term; carries `expectedUpdatedAt`; answers it in full |
| `DELETE /:termId` | `manage:glossary` in the term's locale | Deletes a term and, by cascade, its relations |
`relatedTerms` is written as an array of ids. `locale` is set on create and cannot be changed by `PUT`:
moving a term to another locale would orphan its relations and its documentation path at once, and a
term in another locale is a different term.
Every write fails with 404 when `features.glossary` is off for the site, the two lists answer empty, and
a single term and a lookup answer 404, so that turning the switch off hides the glossary without
deleting it.
### 5.2 Concurrent edits
`PUT` carries `expectedUpdatedAt`, the `updatedAt` the form was opened with. The model compares it inside
the save's transaction (`UPDATE … WHERE id = $1 AND "updatedAt" = $2`), and when no row matches because
the term has changed since, answers **409** with the term as it now stands. The form then says that
somebody else changed it, shows who and when, and offers to reload it (losing the draft) or to keep the
draft and save over the newer version, which re-sends with the new `updatedAt`.
A term deleted while it was open answers 404, and the form says so.
`updatedAt` is compared at millisecond precision, as written by the rest of the codebase.
### 5.3 Moving a page
`pages.relinkMovedPage` is what `PUT /pages/:id/move` calls when `updateLinks` is set. It also rewrites
`documentationPath` on every term in the page's site and old locale that points at the old path, in the
same call:
- **Same locale**: the path is replaced.
- **Another locale**: the term's path is cleared, since a documentation page must be in the term's own
locale. The move's result reports the terms affected, so the dialog can say so.
These are not edits to somebody else's work in the way relinking a page is, so they are not asked about
per term: the person moving the page with `updateLinks` has asked for what pointed at it to follow.
`updatedAt` is bumped on each term touched, so a form open on one of them gets the 409 above.
**Not covered**: a move without `updateLinks`, a deletion, and a **folder rename**, which moves the pages
beneath it without relinking anything today. Folder renames are deliberately out of scope here; they
will be handled, for links and documentation paths together, as a separate improvement. A term then
points at a path with no page, and the term view says the page was not found rather than offering a
dead link.
### 5.4 Site switch
`features.glossary`, on by default, in the site defaults in `models/sites.ts` and under **General →
Features**. Off, the Library menu has no Glossary row and the API behaves as in
[§5.1](#51-api). Nothing is deleted.
---
## 6. The overlay
### 6.1 Layout
```
┌──────────────────────────────────────────────────────────────────────┐
│ Glossary [ en ▾ ] [×] │
├──────────────────────┬───────────────────────────────────────────────┤
│ [ + New Term ] │ │
│ [ Filter… ] │ TERM │
│ [Category ▾] │ Expansion │
│ │ │
│ A │ Definition… │
│ API │ │
│ Application Prog… │ Also: aliases │
│ ▸ ACL │ Related: [chip] [chip] │
│ B │ Read more → /docs/api │
│ … │ References: … │
│ │ Category: Networking │
│ │ [Edit] [Delete] │
└──────────────────────┴───────────────────────────────────────────────┘
```
- **Header**: title, the locale picker (only where the site has more than one active locale), close.
- **Sidebar**: New Term (for `manage:glossary`), a filter field matching names and aliases, an
**A–Z / Category** switch, then the terms, each with its expansion as a second line.
- **A–Z**, the default: under letter headings. Letters are the term's first character, upper-cased
in the locale; everything that is not a letter goes under `#`.
- **Category**: under one heading per category, sorted by name, A–Z within each, with the terms that
have none last under "Uncategorized".
The switch is remembered per browser (`localStorage`, as a convenience that may come back empty). The
whole list for the locale is fetched once when the overlay opens, which is comfortable to a few
thousand terms; beyond that the filter would move to the server.
- **Main panel**: one of three states, below.
Below 900px the two columns become a list → detail flow: the list fills the overlay, a term replaces it,
and a back button returns. The same breakpoint the header's action buttons collapse at.
### 6.2 Viewing a term
Name, expansion, the rendered definition, aliases, related terms as chips (clicking one moves the panel to
it and updates the URL), the documentation link (its label, or "Read more"; "page not found" where the
path no longer resolves), references as a list of external links, and the category (clicking it switches the
sidebar to Category and scrolls to that heading). Edit and Delete for `manage:glossary`; Delete confirms first.
### 6.3 The form
Shown by New Term and by Edit, in the main panel.
| Field | Control |
| --- | --- |
| Term | Text input, required |
| Expansion | Text input |
| Definition | Textarea with a Write / Preview toggle |
| Aliases | Chip input |
| Related terms | Autocomplete over the locale's terms, excluding this one |
| Documentation page | `LinkPickerDialog` with `pagesOnly` (a new prop hiding the URL tab), locked to the term's locale; a clear button |
| Documentation label | Text input, placeholder "Read more" |
| References | Repeatable rows of URL + label, with add and remove |
| Case sensitive | Toggle |
| Auto-link | Toggle, shown on and **disabled**, with a "Coming soon" hint ([§9](#9-auto-linking-later)) |
| Category | Select with suggestions from `GET /categories`; Enter on a new name uses it; shown as a removable chip |
**Renaming.** When the Term field of an existing term is changed, a checkbox appears under it: *Keep
"‹old name›" as an alias*, **unchecked by default**. Checked, the form adds the old name to `aliases`
before saving, so that `?glossary=` links to the old name keep resolving ([§7](#7-urls)). It is the
form's doing rather than the API's: the server sees an ordinary save, and the uniqueness check runs
against the term's new name and aliases as usual, which the old name, now freed by the rename, passes.
A name collision answers 409 from the server and is shown against the field that caused it, naming the
term that holds the name. The sidebar's list is also checked as the user types, which catches most
collisions before a save; the server's answer is the one that counts.
**Unsaved changes**: choosing another term, New Term, switching locale or closing the overlay with a
changed form asks before discarding it.
### 6.4 The definition
**Basic markdown, stored as source and rendered in the browser at display time**, which is the bargain
the built-in comments already make. `html: false` is the security boundary: nothing stored is ever HTML,
so there is nothing to sanitize and nothing sanitized by older rules to serve back.
The comment renderer (`renderers/comment.js`) is the right feature set (paragraphs, emphasis, inline
code, code blocks, lists, links; no headings, images or tables) with two things the glossary does not
want: `@handle` mentions, and `rel="nofollow ugc"` on links that a holder of `manage:glossary`, not an
anonymous commenter, wrote. So `comment.js` exports `createProseMarkdown({ rel })`, the configuration
both use, and `renderers/glossary.js` builds its own instance from it with `rel="noopener"` and no
mention pass.
---
## 7. URLs
**`?glossary=<name>` on any path the main layout draws** opens the overlay on that term, in the locale of
the path. `<name>` is the term's name, URL-encoded, resolved with `GET /lookup`, which matches aliases
too, so a term renamed with its old name kept as an alias keeps its old links working.
- Opening the overlay from the menu adds `?glossary` with no value (the list, nothing selected).
- Selecting a term replaces the value; closing the overlay removes the parameter. Both use
`router.replace`, so browsing the glossary does not fill the history with one entry per term.
- A name that resolves to nothing opens the list with a notice, rather than an error page.
- Switching locale in the overlay adds `&glossaryLocale=<code>` when it differs from the path's locale,
and removes it when it does not.
A query parameter rather than a route of its own, so a shared link opens the term **over the page it was
shared from**, which is usually the context it was being discussed in. The overlay is only mounted by
`MainLayout`, so the admin area and the profile pages ignore the parameter.
---
## 8. Audit log
A new kind, `glossary`, with three actions: `createGlossaryTerm`, `updateGlossaryTerm`,
`deleteGlossaryTerm`. Each records `siteId`, `locale`, `termId` and `term`; an update also records which
fields changed, not their values, as configuration routes do. The terms rewritten by a page move are
recorded in that move's own entry (`meta.glossaryRelinked: [termId…]`) rather than as entries of their
own.
Strings: `admin.audit.kinds.glossary` and `admin.audit.actions.<action>`.
---
## 9. Auto-linking (later)
Recognising terms in page text and linking them to their definitions. Not built in the first version,
but the first version is shaped for it: the `autoLink` and `caseSensitive` columns, aliases, the list
endpoint carrying exactly what a matcher needs, and the uniqueness rule that makes every match resolve to
a single term.
**It happens in the reader's browser, when the page is drawn.** A page's render is produced once at save
time; linking terms into it then would mean re-rendering every page whenever any term changes, and would
freeze a link to whatever a word meant on the day the page was saved. Drawn at display time, a new term
appears in every page at once and a deleted one disappears.
Sketch, to be settled when it is built:
- **The list**, `GET /?locale=` filtered to `autoLink`, fetched once per locale per session and
revalidated with an ETag derived from the locale's newest `updatedAt` and term count.
- **The matcher** is compiled from every name and alias, longest first, so `REST API` wins over `REST`.
Case-insensitive terms compare with `toLocaleLowerCase(locale)`. Word boundaries come from
`Intl.Segmenter(locale, { granularity: 'word' })`, which is what makes it work in languages written
without spaces.
- **Where it looks**: text in the article body only. Not in headings, links, `code`, `pre`, `kbd`, blocks,
or the table of contents.
- **How often**: the first occurrence of each term per page, so a paragraph about REST is not a wall of
underlines.
- **What a match looks like**: an underline, a hover card (expansion and the first lines of the
definition), and a click that opens the overlay on the term. It has to be focusable and reachable by
keyboard.
- **Switches**: the term's `autoLink`, a site setting, and a page property to opt a page out.
- **Crawlers** never see it. The app shell's prerendered body is the stored render, unchanged.
**Open design point: spans or highlights.** Wrapping matches in elements is the simplest way to get
hover, focus and keyboard access, but changes the DOM of the article. Annotations deliberately insert
nothing and use the CSS Custom Highlight API with click hit-testing (`PageAnnotationsLayer.vue`); the two
features have to coexist on one article, and annotations find their passages again by text, which
wrapped matches must not disturb.
---
## 10. Behaviour worth pinning down
- **Deleting a term** removes it from every related list (cascade) and breaks any `?glossary=` link to it,
which then opens the list with a notice.
- **Renaming a term** breaks `?glossary=` links to the old name, unless the editor ticks the option to
keep it as an alias ([§6.3](#63-the-form)).
- **A related term in the list but deleted before the save** is refused with 400, and the form reloads the
list.
- **A term's documentation page deleted** leaves the path in place, shown as "page not found", so that
restoring the page restores the link.
- **Turning `features.glossary` off** hides the glossary everywhere and keeps every term.
- **Deleting a site** deletes its terms (cascade on `siteId`).
---
## 11. Suggested order
1. Schema and migration; `read:glossary` and `manage:glossary` (model lists, `checkLocaleAccess`, the
seeded default rule, the group editor, CLAUDE.md); the site switch.
2. Model and API, including uniqueness, two-way relations, the `updatedAt` check and the audit entries.
3. The overlay: list, view, form, and the Library menu row.
4. URLs.
5. `relinkMovedPage` rewriting documentation paths.
6. Later: auto-linking ([§9](#9-auto-linking-later)), then the other non-goals as they are wanted.
---
## 12. Open questions
None at the moment.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5 35.5L1.5 4.5 11.793 4.5 14.793 7.5 35.5 7.5 35.5 35.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h20v27H2V5H11.586 M12,4H1v32h35V7H15L12,4L12,4z"/><path fill="#fff" d="M4.5 7.5H35.5V25.5H4.5z"/><path fill="#4788c7" d="M35,8v17H5V8H35 M36,7H4v19h32V7L36,7z"/><g><path fill="#dff0fe" d="M1.599 35.5L5.417 14.5 16.151 14.5 19.151 12.5 39.41 12.5 35.577 35.5z"/><path fill="#4788c7" d="M38.82,13l-3.667,22H2.198l3.636-20H16h0.303l0.252-0.168L19.303,13H38.82 M40,12H19l-3,2H5L1,36 h35L40,12L40,12z"/></g></svg>

After

Width:  |  Height:  |  Size: 639 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M26.5,8.5V6c0-0.857,1.35-3.5,3-3.5H35c1.93,0,3.5,1.57,3.5,3.5v2.5H26.5z"/><path fill="#4788c7" d="M35,3c1.654,0,3,1.346,3,3v2H27V6c0-0.672,1.211-3,2.5-3H35 M35,2h-5.5C27.465,2,26,4.96,26,6v3h13 V6C39,3.8,37.2,2,35,2L35,2z"/><path fill="#dff0fe" d="M18.792,37.5l-7.643-5H6.5v-7.246L8.225,24L6.5,22.746V7c0-2.481,2.019-4.5,4.5-4.5h23.5v0.036 c-1.694,0.243-3,1.704-3,3.464v5.717L28.528,13.5l2.972,1.783v3.408L29.882,19.5l1.618,0.809v6.446L29.892,28l1.608,1.245V34 c0,1.93-1.57,3.5-3.5,3.5H18.792z"/><path fill="#4788c7" d="M32.357,3C31.525,3.734,31,4.807,31,6v3v2.434l-2.014,1.209L27.556,13.5l1.429,0.857L31,15.566 v2.816l-0.447,0.224L28.764,19.5l1.789,0.894L31,20.618v5.891l-0.904,0.7L29.075,28l1.021,0.791L31,29.49V34c0,1.654-1.346,3-3,3 h-9.059l-7.393-4.837L11.298,32H11H7v-6.491l0.963-0.7L9.075,24l-1.112-0.809L7,22.491V9V7c0-2.206,1.794-4,4-4H32.357 M35,2H11 C8.25,2,6,4.25,6,7v2v14l1.375,1L6,25v8h5l7.643,5H28c2.2,0,4-1.8,4-4v-5l-1.292-1L32,27v-7l-1-0.5l1-0.5v-4l-2.5-1.5L32,12V9V6 c0-1.657,1.343-3,3-3C35,3,35,2.622,35,2L35,2z"/><path fill="#4788c7" d="M24.5 10h-12C12.224 10 12 9.776 12 9.5v0C12 9.224 12.224 9 12.5 9h12C24.776 9 25 9.224 25 9.5v0C25 9.776 24.776 10 24.5 10zM22.5 16h-10c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h10c.276 0 .5.224.5.5v0C23 15.776 22.776 16 22.5 16zM24.5 22h-12c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h12c.276 0 .5.224.5.5l0 0C25 21.776 24.776 22 24.5 22zM22.5 28h-10c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h10c.276 0 .5.224.5.5l0 0C23 27.776 22.776 28 22.5 28z"/><g><path fill="#b6dcfe" d="M5,37.5c-1.93,0-3.5-1.57-3.5-3.5v-2.5h23V34c0,1.76,1.306,3.221,3,3.464V37.5H5z"/><path fill="#4788c7" d="M24,32v2c0,1.193,0.525,2.266,1.357,3H5c-1.654,0-3-1.346-3-3v-2H24 M25,31H1v3c0,2.2,1.8,4,4,4h23 v-1c-1.657,0-3-1.343-3-3V31L25,31z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M14.93,32.5c-0.478,0-0.927-0.186-1.265-0.524L2.024,20.336c-0.697-0.698-0.697-1.833,0-2.53L17.739,2.133c0.408-0.408,0.952-0.633,1.53-0.633H30c1.379,0,2.5,1.122,2.5,2.5v10.77c0,0.599-0.243,1.184-0.667,1.607L16.194,31.976C15.856,32.314,15.407,32.5,14.93,32.5z M28,4.5c-0.827,0-1.5,0.673-1.5,1.5s0.673,1.5,1.5,1.5s1.5-0.673,1.5-1.5S28.827,4.5,28,4.5z"/><path fill="#4788c7" d="M30,2c1.103,0,2,0.897,2,2v10.77c0,0.474-0.185,0.918-0.52,1.253l-15.639,15.6C15.597,31.866,15.274,32,14.929,32c-0.344,0-0.668-0.134-0.911-0.377l-11.64-11.64c-0.502-0.502-0.502-1.32-0.001-1.822L18.092,2.487C18.406,2.173,18.824,2,19.269,2H30 M28,8c1.103,0,2-0.897,2-2s-0.897-2-2-2s-2,0.897-2,2S26.897,8,28,8 M30,1H19.269c-0.706,0-1.383,0.28-1.883,0.779L1.671,17.453c-0.894,0.894-0.894,2.343,0,3.237l11.64,11.64c0.447,0.447,1.033,0.67,1.618,0.67c0.586,0,1.172-0.223,1.618-0.67l15.639-15.599C32.707,16.211,33,15.506,33,14.77V4C33,2.343,31.657,1,30,1L30,1z M28,7c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S28.552,7,28,7L28,7z"/><path fill="#dff0fe" d="M20.93,38.5c-0.478,0-0.927-0.186-1.265-0.524L8.024,26.336c-0.697-0.698-0.697-1.833,0-2.53L23.739,8.133c0.408-0.408,0.952-0.633,1.53-0.633H36c1.379,0,2.5,1.122,2.5,2.5v10.77c0,0.599-0.243,1.184-0.667,1.607L22.194,37.976C21.856,38.314,21.407,38.5,20.93,38.5z M34,10.5c-0.827,0-1.5,0.673-1.5,1.5s0.673,1.5,1.5,1.5s1.5-0.673,1.5-1.5S34.827,10.5,34,10.5z"/><path fill="#4788c7" d="M36,8c1.103,0,2,0.897,2,2v10.77c0,0.474-0.185,0.918-0.52,1.253l-15.639,15.6C21.597,37.866,21.274,38,20.929,38c-0.344,0-0.668-0.134-0.911-0.377l-11.64-11.64c-0.502-0.502-0.502-1.32-0.001-1.822L24.092,8.487C24.406,8.173,24.824,8,25.269,8H36 M34,14c1.103,0,2-0.897,2-2s-0.897-2-2-2s-2,0.897-2,2S32.897,14,34,14 M36,7H25.269c-0.706,0-1.383,0.28-1.883,0.779L7.671,23.453c-0.894,0.894-0.894,2.343,0,3.237l11.64,11.64c0.447,0.447,1.033,0.67,1.618,0.67c0.586,0,1.172-0.223,1.618-0.67l15.639-15.599C38.707,22.211,39,21.506,39,20.77V10C39,8.343,37.657,7,36,7L36,7z M34,13c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S34.552,13,34,13L34,13z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+4 -1
View File
@@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
308 icons.
311 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@@ -23,6 +23,7 @@ export const BUNDLED_ICONS = {
"la:bell": {"body":"<path fill=\"currentColor\" d=\"M16 3a2 2 0 0 0-2 2c0 .086.02.168.031.25C10.574 6.133 8 9.273 8 13v9c0 .566-.434 1-1 1H6v2h7.188A3 3 0 0 0 13 26c0 1.645 1.355 3 3 3s3-1.355 3-3a3 3 0 0 0-.188-1H26v-2h-1c-.566 0-1-.434-1-1v-8.719c0-3.758-2.512-7.11-6.031-8.031c.011-.082.031-.164.031-.25a2 2 0 0 0-2-2m-.438 4c.145-.012.29 0 .438 0h.188C19.453 7.098 22 9.96 22 13.281V22c0 .352.074.684.188 1H9.813A3 3 0 0 0 10 22v-9a6.005 6.005 0 0 1 5.563-6zM16 25c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32},
"la:bolt": {"body":"<path fill=\"currentColor\" d=\"M16 6v9l4 1l-3.898 10H16v-8.031l-4-1L15.898 6zm2-2h-3.512l-.472 1.328l-3.903 10.973l-.734 2.074l2.137.535l2.484.621V28h3.469l.496-1.273l3.898-10l.825-2.118L18 13.438z\"/>","width":32,"height":32},
"la:book": {"body":"<path fill=\"currentColor\" d=\"M9 4C7.355 4 6 5.355 6 7v18c0 1.645 1.355 3 3 3h17V4zm0 2h15v16H9a3 3 0 0 0-1 .188V7c0-.566.434-1 1-1m2 3v2h11V9zM9 24h15v2H9c-.566 0-1-.434-1-1s.434-1 1-1\"/>","width":32,"height":32},
"la:book-open": {"body":"<path fill=\"currentColor\" d=\"M3 6v19h10c1.102 0 2 .898 2 2h2c0-1.102.898-2 2-2h10V6H19a4 4 0 0 0-3 1.36A4 4 0 0 0 13 6zm2 2h8c1.102 0 2 .898 2 2h2c0-1.102.898-2 2-2h8v15h-8a4 4 0 0 0-3 1.36A4 4 0 0 0 13 23H5zm10 4v2h2v-2zm0 4v2h2v-2zm0 4v2h2v-2z\"/>","width":32,"height":32},
"la:broadcast-tower": {"body":"<path fill=\"currentColor\" d=\"M7.188 4.188c-4.297 4.183-4.282 11.125 0 15.406l1.406-1.407c-3.52-3.519-3.504-9.148 0-12.562zm17.625.093L23.405 5.72c3.524 3.523 3.524 9.039 0 12.562l1.407 1.438a10.897 10.897 0 0 0 0-15.438zM9.905 7.188c-2.586 2.585-2.586 6.82 0 9.406l1.406-1.407a4.68 4.68 0 0 1 0-6.593zm12.188.093L20.687 8.72a4.64 4.64 0 0 1 0 6.562l1.407 1.438c2.586-2.586 2.586-6.852 0-9.438zM16 10a2 2 0 0 0-2 2c0 .625.3 1.164.75 1.531L10.312 26H9v2h4v-2h-.594L16 15.969L19.594 26H19v2h4v-2h-1.313L17.25 13.531c.45-.367.75-.906.75-1.531a2 2 0 0 0-2-2\"/>","width":32,"height":32},
"la:broom": {"body":"<path fill=\"currentColor\" d=\"m28.281 2.281l-10 10L17 11v-.031l-.031-.031c-.64-.57-1.477-.844-2.282-.844s-1.582.3-2.187.906l-.156.125l-.5.5l-.344.281L2.375 19l-.875.719L12.281 30.5l.719-.875l7.063-9.063l.03.032l1-1h.032l.031-.032c1.14-1.285 1.149-3.257-.062-4.468l-1.375-1.375l10-10zm-13.593 9.813a1.4 1.4 0 0 1 .906.312c.011.008.02.024.031.031l4.063 4.063c.375.375.41 1.172 0 1.688c-.016.019-.016.042-.032.062l-.312.281l-5.782-5.781l.344-.344c.192-.191.473-.304.781-.312zM12.03 14.03l5.938 5.938l-5.875 7.5l-1.438-1.438l2.156-2.25l-1.437-1.375l-2.125 2.219l-1.313-1.313l3.875-3.906L10.406 18L6.5 21.875l-1.969-1.969z\"/>","width":32,"height":32},
"la:calendar": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32},
@@ -155,6 +156,7 @@ export const BUNDLED_ICONS = {
"la:stream": {"body":"<path fill=\"currentColor\" d=\"M5 5v6h19V5zm2 2h15v2H7zm2 6v6h19v-6zm2 2h15v2H11zm-6 6v6h19v-6zm2 2h15v2H7z\"/>","width":32,"height":32},
"la:sun": {"body":"<path fill=\"currentColor\" d=\"M15 3v5h2V3zM7.5 6.094L6.094 7.5l3.531 3.563l1.438-1.438zm17 0l-3.563 3.531l1.438 1.438L25.906 7.5zM16 9c-3.855 0-7 3.145-7 7s3.145 7 7 7s7-3.145 7-7s-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5M3 15v2h5v-2zm21 0v2h5v-2zM9.625 20.938L6.094 24.5L7.5 25.906l3.563-3.531zm12.75 0l-1.438 1.437l3.563 3.531l1.406-1.406zM15 24v5h2v-5z\"/>","width":32,"height":32},
"la:sync-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4c-5.113 0-9.383 3.16-11.125 7.625l1.844.75C8.176 8.641 11.71 6 16 6c3.242 0 6.133 1.59 7.938 4H20v2h7V5h-2v3.094A11.94 11.94 0 0 0 16 4m9.281 15.625C23.824 23.359 20.29 26 16 26c-3.277 0-6.156-1.613-7.969-4H12v-2H5v7h2v-3.094C9.188 26.386 12.395 28 16 28c5.113 0 9.383-3.16 11.125-7.625z\"/>","width":32,"height":32},
"la:tag": {"body":"<path fill=\"currentColor\" d=\"m16 5l-.313.281L4.281 16.813l-.687.687l.687.719l9.5 9.5l.719.687l.688-.687l11.53-11.407L27 16V5zm.844 2H25v8.156L14.5 25.594L6.406 17.5zM22 9c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:tags": {"body":"<path fill=\"currentColor\" d=\"m14.594 4l-.313.281l-11 11l-.687.719l.687.719l9 9l.719.687l.719-.687l11-11l.281-.313V4zm.844 2H23v7.563l-10 10L5.437 16zM26 7v2h1v8.156l-9.5 9.438l-1.25-1.25l-1.406 1.406l1.937 1.969l.719.687l.688-.687l10.53-10.407L29 18V7zm-6 1c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:tasks": {"body":"<path fill=\"currentColor\" d=\"M10.293 5.293L7 8.586L5.707 7.293L4.293 8.707L7 11.414l4.707-4.707zM14 7v2h14V7zm0 8v2h14v-2zm0 8v2h14v-2z\"/>","width":32,"height":32},
"la:th-list": {"body":"<path fill=\"currentColor\" d=\"M4 6v20h24V6zm2 2h5v4H6zm7 0h13v4H13zm-7 6h5v4H6zm7 0h13v4H13zm-7 6h5v4H6zm7 0h13v4H13z\"/>","width":32,"height":32},
@@ -282,6 +284,7 @@ export const BUNDLED_ICONS = {
"mdi:logout": {"body":"<path fill=\"currentColor\" d=\"m17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5M4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4z\"/>","width":24,"height":24},
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
"mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24},
"mdi:museum-outline": {"body":"<path fill=\"currentColor\" d=\"M6.5 10h-2v7h2zm6 0h-2v7h2zm8.5 9H2v2h19zm-2.5-9h-2v7h2zm-7-6.74L16.71 6H6.29zm0-2.26L2 6v2h19V6z\"/>","width":24,"height":24},
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},
"mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24},
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
File diff suppressed because it is too large Load Diff
+213 -154
View File
@@ -235,90 +235,81 @@
<w-card class="shadow-1 pb-2" v-else>
<w-card-section>
<div class="admin-groups-rule" v-for="rule of state.group.rules" :key="rule.id">
<div class="admin-groups-rule-icon" :class="getRuleModeColor(rule.mode)">
<w-icon
:name="getRuleModeIcon(rule.mode)"
color="white"
@click="cycleRuleMode(rule)" />
</div>
<!-- -> The timeline down the left, which the mode button's disc sits at the top of -->
<div class="admin-groups-rule-icon" :class="getRuleModeColor(rule.mode)" />
<div class="admin-groups-rule-name">
<div class="admin-groups-rule-name-text">
<strong :class="getRuleModeColor(rule.mode)">{{
getRuleModeName(rule.mode)
}}</strong>
</div>
<!--
The disc and the mode name are one button opening the choice of mode
(`RuleModeMenu`), so the whole of it is one target with one hover. It reaches back
over the timeline's column to put the disc where it has always been. Disabled for a
reader who may look at a rule but not change it.
-->
<button
type="button"
class="admin-groups-rule-mode w-unstyled"
:class="getRuleModeColor(rule.mode)"
:disabled="!canManage">
<span class="admin-groups-rule-mode-disc">
<w-icon :name="getRuleModeIcon(rule.mode)" />
</span>
<strong>{{ getRuleModeName(rule.mode) }}</strong>
<w-icon v-if="canManage" name="la:angle-down" size="10px" />
<rule-mode-menu :mode="rule.mode" @select="(mode) => setRuleMode(rule, mode)" />
</button>
<w-separator class="ml-2 mr-1" vertical />
<input
type="text"
v-model="rule.name"
placeholder="Rule Name"
:disabled="!canManage" />
<w-btn
v-if="canManage"
class="admin-groups-rule-delete"
flat
round
dense
size="sm"
icon="la:trash"
color="negative"
:aria-label="t(`admin.groups.ruleDelete`)"
@click="deleteRule(rule.id)">
<w-tooltip>{{ t('admin.groups.ruleDelete') }}</w-tooltip>
</w-btn>
</div>
<w-card class="admin-groups-rule-card mt-4" flat>
<w-card-section
class="admin-groups-rule-card-permissions"
:class="getRuleModeClass(rule.mode)">
<w-select
class="mt-1"
standout
v-model="rule.roles"
emit-value
map-options
dense
:aria-label="t(`admin.groups.ruleSites`)"
:options="ruleOptions"
:disable="!canManage"
placeholder="Select permissions..."
option-value="permission"
option-label="title"
options-dense
multiple
use-chips
stack-label>
<template #selected-item="scope">
<w-chip
square
dense
:tabindex="scope.tabindex"
:color="getRuleModeBgColor(rule.mode)"
text-color="white">
<span class="text-caption">{{ scope.opt.title }}</span>
</w-chip>
</template>
<template #option="{ itemProps, itemEvents, opt, selected, toggleOption }">
<w-item v-bind="itemProps" v-on="itemEvents">
<w-item-section side>
<w-toggle
:model-value="selected"
@update:model-value="toggleOption(opt)"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:aria-label="opt.label" />
</w-item-section>
<!-- q-item-section(side, style='flex-basis: 150px;') -->
<!-- q-chip.text-caption( -->
<!-- square -->
<!-- color='teal' -->
<!-- text-color='white' -->
<!-- dense -->
<!-- ) {{opt.permission}} -->
<w-item-section>
<w-item-label>{{ opt.title }}</w-item-label>
<w-item-label caption>{{ opt.hint }}</w-item-label>
</w-item-section>
</w-item>
</template>
</w-select>
<!--
The permissions themselves, in the bar rather than behind a dropdown: a rule is
read far more often than it is changed, and what it grants is the first thing a
reader wants. Picking them is a dialog of its own (`RulePermissionsDialog`), which
has room to lay them out by what the rule's pattern means to each.
-->
<div class="admin-groups-rule-roles">
<w-chip
v-for="perm of ruleRoleChips(rule)"
:key="perm.permission"
square
dense
:color="getRuleModeBgColor(rule.mode)"
text-color="white">
<span class="text-caption">{{ perm.title }}</span>
</w-chip>
<span v-if="rule.roles.length < 1" class="text-caption text-grey-6">
{{ t('admin.groups.rulePermissionsNone') }}
</span>
</div>
<w-btn
class="acrylic-btn ml-4"
flat
icon="la:trash"
color="negative"
padding="sm sm"
size="md"
v-if="canManage"
@click="deleteRule(rule.id)" />
class="admin-groups-rule-select acrylic-btn ml-4 shrink-0"
flat
no-caps
:color="getRuleModeBgColor(rule.mode)"
icon="la:list-alt"
padding="xs md"
:label="t(`admin.groups.rulePermissionsSelect`)"
@click="selectRulePermissions(rule)" />
</w-card-section>
<w-card-section horizontal>
<w-card-section class="admin-groups-rule-card-filters">
@@ -336,28 +327,11 @@
option-value="id"
option-label="title"
multiple
behavior="dialog"
:display-value="
t(`admin.groups.selectedSites`, rule.sites.length, {
count: rule.sites.length
})
">
<template #option="{ itemProps, itemEvents, opt, selected, toggleOption }">
<w-item v-bind="itemProps" v-on="itemEvents">
<w-item-section>
<w-item-label>{{ opt.title }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
:model-value="selected"
@update:model-value="toggleOption(opt)"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:aria-label="opt.label" />
</w-item-section>
</w-item>
</template>
</w-select>
<w-select
class="mt-2"
@@ -372,7 +346,6 @@
option-value="code"
option-label="name"
multiple
behavior="dialog"
:display-value="
t(
`admin.groups.selectedLocales`,
@@ -385,22 +358,6 @@
rule.locales.length
)
">
<template #option="{ itemProps, opt, selected, toggleOption }">
<w-item v-bind="itemProps">
<w-item-section>
<w-item-label>{{ opt.name }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
:model-value="selected"
@update:model-value="toggleOption(opt)"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:aria-label="opt.name" />
</w-item-section>
</w-item>
</template>
</w-select>
</w-card-section>
<w-card-section class="admin-groups-rule-card-pattern">
@@ -794,6 +751,8 @@ import { useUserStore } from '@/stores/user'
import { v4 as uuid } from 'uuid'
import { fileOpen, fileSave } from 'browser-fs-access'
import RuleModeMenu from '@/components/RuleModeMenu.vue'
import RulePermissionsDialog from '@/components/RulePermissionsDialog.vue'
import UserSearchDialog from '@/components/UserSearchDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
@@ -1028,7 +987,8 @@ const GUEST_ROLES = [
'read:history',
'read:assets',
'read:comments',
'write:comments'
'write:comments',
'read:glossary'
]
const rules = [
@@ -1167,6 +1127,32 @@ const rules = [
warning: false,
restrictedForSystem: true,
disabled: false
},
/*
The two rule permissions that ignore the path: a glossary belongs to a site and a locale, so every
rule naming one that is scoped to them applies, and mode alone decides -- see `resolveLocaleRule`
in `helpers/pageRules.ts`. The hints say so, because a DENY written for one folder closing the
whole glossary is not what anybody would guess. `scope: 'locale'` is what puts them in the
picker's second column (`RulePermissionsDialog`), whose heading carries that warning for both,
and has to match `LOCALE_PERMISSIONS` in `models/groups.ts`.
*/
{
permission: 'read:glossary',
scope: 'locale',
title: 'Read Glossary',
hint: "Can view the site's glossary.",
warning: false,
restrictedForSystem: false,
disabled: false
},
{
permission: 'manage:glossary',
scope: 'locale',
title: 'Manage Glossary',
hint: 'Can create, edit and delete glossary terms, and view them.',
warning: false,
restrictedForSystem: true,
disabled: false
}
]
@@ -1299,16 +1285,6 @@ function getRuleModeIcon(mode) {
)
}
function getNextRuleMode(mode) {
return (
{
DENY: 'FORCEALLOW',
ALLOW: 'DENY',
FORCEALLOW: 'ALLOW'
}[mode] || 'ALLOW'
)
}
function getRuleModeName(mode) {
switch (mode) {
case 'ALLOW':
@@ -1435,15 +1411,39 @@ function newRule() {
}
/*
Cycle a rule between ALLOW, DENY and FORCEALLOW. Guarded here rather than in the template, because
the control is an icon with a click handler rather than a form control there is a `disable` to set
-- a reader holding `read:groups` may look at a rule, not re-point it.
Set what a rule does. Guarded here as well as by the disabled triggers, since the menu is the only
thing between a reader holding `read:groups` and re-pointing a rule.
*/
function cycleRuleMode(rule) {
function setRuleMode(rule, mode) {
if (!canManage.value) {
return
}
rule.mode = getNextRuleMode(rule.mode)
rule.mode = mode
}
/**
* A rule's permissions as the bar draws them: in the order the picker offers them, with anything the
* rule names that is not on offer (an imported rule may) shown under its raw name at the end.
*/
function ruleRoleChips(rule) {
const roles = new Set(rule.roles)
const known = rules.filter((opt) => roles.has(opt.permission))
const unknown = rule.roles
.filter((role) => !rules.some((opt) => opt.permission === role))
.map((role) => ({ permission: role, title: role }))
return [...known, ...unknown]
}
function selectRulePermissions(rule) {
dialog({
component: RulePermissionsDialog,
componentProps: {
options: ruleOptions.value,
selected: rule.roles
}
}).onOk((roles) => {
rule.roles = roles
})
}
function deleteRule(id) {
@@ -1628,22 +1628,14 @@ onMounted(() => {
position: relative;
padding: 10px 0 24px 40px;
/* -> Drawing only, under the mode button's disc: it must never take the click meant for it */
&-icon {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 31px;
&::before {
position: absolute;
content: '';
border-radius: 100%;
width: 31px;
height: 31px;
background-color: currentColor;
top: 4px;
}
pointer-events: none;
&::after {
position: absolute;
@@ -1656,25 +1648,65 @@ onMounted(() => {
background-color: currentColor;
display: block;
}
}
/*
Sized and placed to the disc `::before` draws, with the glyph inset by the padding: an inline
<svg> scales its viewBox to whatever box it is given, so the old `width: 100%; height: 38px`
-- metrics for the icon FONT this replaced, where `font-size` did the sizing -- stretched the
mark across the whole circle.
/*
The mode: disc, name and caret as one button, in the rule's colour (`currentColor` throughout, from
the `text-*` class the template sets).
The box stays the full 31px even though the glyph is 15px, so the click target is the disc a
reader is aiming at rather than the mark inside it.
*/
.w-icon {
position: absolute;
top: 4px;
left: 0;
box-sizing: border-box;
It sits in the title row but has to put its disc where the timeline starts, at the rule's left
edge and 4px down: hence the negative left margin over the row's 40px of padding, and the negative
vertical margins that let a 31px disc ride a 12px row without making it taller. The 9px gap is what
leaves the name where it always was, 40px in.
*/
&-mode {
position: relative;
flex: 0 0 auto;
align-self: center;
display: inline-flex;
align-items: center;
gap: 9px;
height: 31px;
margin: -9.5px 0 -9.5px -40px;
padding-right: 10px;
border-radius: 999px;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover:not(:disabled),
&:focus-visible {
background-color: color-mix(in srgb, currentColor 12%, transparent);
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
&:disabled {
cursor: default;
}
&-disc {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 31px;
height: 31px;
padding: 8px;
cursor: pointer;
border-radius: 100%;
background-color: currentColor;
.w-icon {
width: 15px;
height: 15px;
color: #fff;
}
}
> .w-icon {
margin-left: -5px;
}
}
@@ -1691,11 +1723,6 @@ onMounted(() => {
align-items: baseline;
padding-top: 4px;
&-text {
flex: 0 0;
white-space: nowrap;
}
input {
font-weight: 700;
color: $grey-6;
@@ -1722,6 +1749,23 @@ onMounted(() => {
}
}
/* -> At the end of the title row, level with the mode name and the rule's title */
&-delete {
align-self: center;
margin: -6px 0 -6px 0.5rem;
}
/* -> The chips wrap onto as many lines as the rule needs; the picker button keeps its place */
&-roles {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: 4px;
min-width: 0;
min-height: 32px;
}
&-card {
background-color: $grey-2 !important;
@@ -1735,10 +1779,6 @@ onMounted(() => {
display: flex;
align-items: center;
.w-select {
flex-basis: 100%;
}
&.is-allow {
background-color: rgba($positive, 0.1);
border-bottom: 1px solid rgba($positive, 0.3);
@@ -1751,6 +1791,25 @@ onMounted(() => {
background-color: rgba($blue, 0.1);
border-bottom: 1px solid rgba($blue, 0.3);
}
/*
-> The mode's colour on its own pale bar is too light to read as text, in light mode: each one
darkened by the same amount rather than swapped for a deeper Material shade, which would be
another hue. `!important` because `WBtn` writes its colour as an inline style, which
nothing else outranks.
*/
@at-root .body--light & .admin-groups-rule-select {
color: color-mix(in srgb, var(--rule-select-color) 65%, black) !important;
}
@at-root .body--light &.is-allow {
--rule-select-color: var(--color-positive);
}
@at-root .body--light &.is-deny {
--rule-select-color: var(--color-negative);
}
@at-root .body--light &.is-forceallow {
--rule-select-color: var(--color-blue);
}
}
&-filters {
+16 -50
View File
@@ -42,9 +42,9 @@
choice, from the same component, as the header button on a wide screen. It anchors to this row
because `WMenu` takes the nearest `.w-item` as its trigger.
`hide-asset-btn` because this menu has a File Manager row of its own directly below, and
`@new-page` closes this menu once the submenu has acted: the editor opens behind it otherwise,
with the menu still floating over it.
`hide-asset-btn` because the Library submenu directly below has a File Manager row of its own,
and `@new-page` closes this menu once the submenu has acted: the editor opens behind it
otherwise, with the menu still floating over it.
-->
<w-item v-if="userStore.can(`write:pages`)" clickable>
<w-item-section avatar>
@@ -56,17 +56,17 @@
</w-item-section>
<page-new-menu hide-asset-btn @new-page="close" />
</w-item>
<!--
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author
whose rules cover the pages but not the assets beside them, since the editor sends them
here to insert an image. Every folder and every file is checked again by the endpoints
behind the manager, which answer per path, so this decides only whether the door is shown.
-->
<w-item v-if="canUseFileManager" clickable @click="openFileManager">
<!-- -> A submenu for the same reason as New Page above: the wide header's folder button is a menu
too, and this opens the same one. Everybody's, since only its File Manager row is gated -->
<w-item clickable>
<w-item-section avatar>
<w-icon name="la:folder-open" class="text-positive" />
<w-icon name="mdi:museum-outline" class="text-positive" />
</w-item-section>
<w-item-section>File Manager</w-item-section>
<w-item-section>{{ t('common.header.library') }}</w-item-section>
<w-item-section side>
<w-icon name="la:angle-right" />
</w-item-section>
<header-library-menu @navigate="close" />
</w-item>
<w-item v-if="userStore.authenticated" clickable to="/_inbox" @click="close">
<w-item-section avatar>
@@ -83,10 +83,7 @@
</w-item-section>
<w-item-section>{{ t('common.header.admin') }}</w-item-section>
</w-item>
<!-- -> Only once there is something above it to divide from the account rows below: a guest whose
rules grant nothing but reading has none of the four, and the menu opened on a rule with
blank space over it and Login alone underneath -->
<w-separator v-if="hasActionRows" class="my-2" />
<w-separator class="my-2" />
<!--
The account rows, flattened into this list rather than opened as a second submenu: they are two
plain actions, and the panel they live in on a wide screen is 300px of card -- wider than this
@@ -120,18 +117,18 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNotificationsStore } from '@/stores/notifications'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import HeaderLibraryMenu from '@/components/HeaderLibraryMenu.vue'
import PageNewMenu from '@/components/PageNewMenu.vue'
/**
* The phone header's overflow menu: one button standing in for every action the bar shows as its own
* icon on a wide screen -- New Page, File Manager, Inbox, Administration, and the account.
* icon on a wide screen -- New Page, Library, Inbox, Administration, and the account.
*
* Rendered only below 900px; see `HeaderNav`. The rows repeat the permission tests the buttons they
* replace make, rather than being handed a list, so the two cannot drift apart.
@@ -139,7 +136,6 @@ import PageNewMenu from '@/components/PageNewMenu.vue'
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
const notificationsStore = useNotificationsStore()
@@ -151,27 +147,6 @@ const { t } = useI18n()
const menu = ref(null)
// COMPUTED
const canUseFileManager = computed(
() => userStore.can('write:assets') || userStore.can('write:pages')
)
/**
* Whether any row is shown above the account group, which is what decides the rule between the two.
*
* Restates the test each of those four rows makes rather than a shorter equivalent — `canUseFileManager`
* already implies the New Page row's permission today, but a row added or a test changed up there would
* otherwise leave this behind, and the failure is silent.
*/
const hasActionRows = computed(
() =>
userStore.can('write:pages') ||
canUseFileManager.value ||
userStore.authenticated ||
userStore.can('access:admin')
)
// METHODS
/**
@@ -185,15 +160,6 @@ function close() {
menu.value?.hide()
}
/*
Closed before the manager opens: it is a full-screen overlay, and a menu teleported to the body
outranks it -- so the menu would be left floating over the panel it had just opened.
*/
function openFileManager() {
close()
siteStore.openFileManager()
}
function logout() {
close()
userStore.logout()
@@ -0,0 +1,102 @@
<template>
<w-menu class="translucent-menu" auto-close anchor="bottom right" self="top right">
<w-list padding>
<!--
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author
whose rules cover the pages but not the assets beside them, since the editor sends them here
to insert an image. Every folder and every file is checked again by the endpoints behind
the manager, which answer per path, so this decides only whether the door is shown.
-->
<w-item v-if="canUseFileManager" clickable @click="openFileManager">
<blueprint-icon icon="live-folder" />
<w-item-section class="pr-2">{{ t('fileman.title') }}</w-item-section>
</w-item>
<!--
-> Where the site has a glossary and this session may read it in at least one locale --
`read:glossary` ignores the path, so the answer does not change from page to page and is
asked once (`stores/glossary.js`). Experimental for now: this row is the glossary's only
way in, so the flag hides the feature as a whole.
-->
<w-item
v-if="flagsStore.experimental && glossaryStore.isAvailable"
clickable
@click="openGlossary">
<blueprint-icon icon="parchment" />
<w-item-section class="pr-2">{{ t('common.header.glossary') }}</w-item-section>
</w-item>
<w-item clickable to="/_tags" @click="emit('navigate')">
<blueprint-icon icon="tags" />
<w-item-section class="pr-2">{{ t('tags.title') }}</w-item-section>
</w-item>
</w-list>
</w-menu>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFlagsStore } from '@/stores/flags'
import { useGlossaryStore } from '@/stores/glossary'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/**
* The header's Library menu: the File Manager, the Glossary and the Tags page.
*
* Its own component, like `PageNewMenu`, because it is opened from two places -- the folder button on
* a wide header, and a submenu of the overflow menu below 900px. `navigate` is emitted as a row acts,
* so that a menu holding this one as a submenu can close itself too.
*/
// EMITS
const emit = defineEmits(['navigate'])
// STORES
const flagsStore = useFlagsStore()
const glossaryStore = useGlossaryStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// COMPUTED
const canUseFileManager = computed(
() => userStore.can('write:assets') || userStore.can('write:pages')
)
// WATCHERS
// -> Asked again whenever the answer could have changed: a login, a logout, another site -- and not
// at all while the glossary is behind the experimental flag and nothing would show it
watch(
() => [flagsStore.experimental, siteStore.id, userStore.authenticated, userStore.id],
() => {
if (flagsStore.experimental) {
glossaryStore.ensureAccess()
}
},
{ immediate: true }
)
// METHODS
/*
`navigate` first: the manager is a full-screen overlay, and a parent menu teleported to the body
outranks it -- so the parent has to be gone before the panel opens.
*/
function openFileManager() {
emit('navigate')
siteStore.openFileManager()
}
function openGlossary() {
emit('navigate')
siteStore.openGlossary()
}
</script>
+7 -16
View File
@@ -57,24 +57,18 @@
<w-tooltip>Create New Page</w-tooltip>
<new-menu />
</w-btn>
<!--
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an
author whose rules cover the pages but not the assets beside them, since the editor
sends them here to insert an image. Every folder and every file is checked again by the
endpoints behind the manager, which answer per path, so this decides only whether the
door is shown.
-->
<!-- -> Shown to everyone: Tags and the Glossary are for readers, and the menu holds back its
File Manager row by itself; see `HeaderLibraryMenu` -->
<w-btn
v-if="userStore.can(`write:assets`) || userStore.can(`write:pages`)"
class="ml-4"
flat
round
dense
icon="la:folder-open"
icon="mdi:museum-outline"
color="positive"
aria-label="File Manager"
@click="openFileManager">
<w-tooltip>File Manager</w-tooltip>
:aria-label="t(`common.header.library`)">
<w-tooltip>{{ t('common.header.library') }}</w-tooltip>
<header-library-menu />
</w-btn>
<w-btn
v-if="userStore.authenticated"
@@ -159,6 +153,7 @@ import { useUserStore } from '@/stores/user'
import AccountMenu from '@/components/AccountMenu.vue'
import NewMenu from '@/components/PageNewMenu.vue'
import HeaderActionsMenu from '@/components/HeaderActionsMenu.vue'
import HeaderLibraryMenu from '@/components/HeaderLibraryMenu.vue'
import HeaderSearch from '@/components/HeaderSearch.vue'
/**
@@ -308,10 +303,6 @@ function onKeydown(ev) {
function toggleSearchRow() {
searchRowIsOpen.value = !searchRowIsOpen.value
}
function openFileManager() {
siteStore.openFileManager()
}
</script>
<style scoped lang="scss">
+14 -3
View File
@@ -30,7 +30,7 @@
a track of its own, so it sits ON the card rather than spanning it edge to edge -->
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
<w-tab name="page" icon="la:file-alt" :label="t(`linkPicker.page`)" />
<w-tab name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
<w-tab v-if="!props.pagesOnly" name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
</w-tabs>
<w-separator />
<w-tab-panels v-model="state.currentTab">
@@ -212,6 +212,15 @@ const props = defineProps({
lockLocale: {
type: Boolean,
default: false
},
/**
* Offer the Page tab alone, for a caller whose slot holds a page of this wiki and nothing else -- a
* glossary term's documentation page. An `initialHref` that is a URL then opens on the page tree
* rather than on a tab that is not there.
*/
pagesOnly: {
type: Boolean,
default: false
}
})
@@ -445,10 +454,12 @@ onMounted(async () => {
highlighted row once its folder is listed, and in the footer either way.
*/
if (props.initialHref) {
if (/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')) {
const isUrl =
/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')
if (isUrl && !props.pagesOnly) {
state.currentTab = 'url'
state.url = props.initialHref
} else {
} else if (!isUrl) {
/*
Re-opening on a link that already carries a prefix: the locale comes off it, so the picker
starts in the tree the link points into rather than in the page's own.
@@ -33,6 +33,10 @@ const overlays = {
loader: () => import('./FileManager.vue'),
loadingComponent: LoadingGeneric
}),
Glossary: defineAsyncComponent({
loader: () => import('./GlossaryOverlay.vue'),
loadingComponent: LoadingGeneric
}),
NavEdit: defineAsyncComponent({
loader: () => import('./NavEditOverlay.vue'),
loadingComponent: LoadingGeneric
+100
View File
@@ -0,0 +1,100 @@
<template>
<w-menu class="translucent-menu" auto-close anchor="bottom left" self="top left">
<w-list padding style="min-width: 280px">
<w-item
v-for="mode of modes"
:key="mode.value"
clickable
:active="mode.value === props.mode"
@click="emit('select', mode.value)">
<w-item-section avatar>
<!--
The disc the rule itself is drawn with, so a choice here looks like what it turns the rule
into. Its colour through the CSS variable rather than a `bg-*` class built at runtime,
which Tailwind would never emit.
-->
<span class="rule-mode-disc" :style="{ backgroundColor: `var(--color-${mode.color})` }">
<w-icon :name="mode.icon" size="14px" />
</span>
</w-item-section>
<w-item-section class="pr-2">
<w-item-label>{{ mode.label }}</w-item-label>
<w-item-label caption>{{ mode.hint }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</w-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
/**
* The choice of what a page rule does: Allow, Deny or Force Allow.
*
* Written as the last child of its trigger, like any `WMenu` -- the group editor opens it from both the
* rule's disc and the mode name beside it. It only reports the choice (`select`); the rule is the
* caller's to change.
*/
// PROPS
const props = defineProps({
/** The mode the rule has now, which the menu marks. */
mode: {
type: String,
default: null
}
})
// EMITS
const emit = defineEmits(['select'])
// I18N
const { t } = useI18n()
// COMPUTED
/**
* Weakest first, which is also the order they outrank each other in when two rules are equally
* specific -- see `helpers/pageRules.ts`. Icon names are literals for the icon bundle's sake.
*/
const modes = computed(() => [
{
value: 'ALLOW',
label: t('admin.groups.ruleAllow'),
hint: t('admin.groups.ruleAllowHint'),
icon: 'la:check',
color: 'positive'
},
{
value: 'DENY',
label: t('admin.groups.ruleDeny'),
hint: t('admin.groups.ruleDenyHint'),
icon: 'la:ban',
color: 'negative'
},
{
value: 'FORCEALLOW',
label: t('admin.groups.ruleForceAllow'),
hint: t('admin.groups.ruleForceAllowHint'),
icon: 'la:check-double',
color: 'blue'
}
])
</script>
<style lang="scss">
.rule-mode-disc {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 100%;
color: #fff;
}
</style>
@@ -0,0 +1,306 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="rule-perms" style="width: 1280px; max-width: 95vw">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-security-lock.svg" size="sm" class="mr-2" />
<span>{{ t('admin.groups.rulePermissionsTitle') }}</span>
<w-space />
<!-- -> The help button the overlays carry in their headers -- see `FileManager` -->
<w-btn
class="-my-2"
flat
round
dense
color="white"
icon="la:question-circle"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/permissions#permissions`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
</w-card-section>
<!--
Grouped by what a rule's pattern means to the permission: most are decided per page, and the
pattern says which pages; a few are about a site and a locale, and the pattern says nothing to
them (`LOCALE_PERMISSIONS` in `backend/models/groups.ts`). Putting them side by side is what
tells an administrator that a rule for `/docs` granting one of the second kind grants it
everywhere.
The first group is long enough to take two lists of its own where the dialog is wide, so the
layout is three columns, two, or one -- see the container queries below.
-->
<div class="rule-perms-body">
<section
v-for="group of groups"
:key="group.key"
class="rule-perms-group"
:class="`is-${group.key}`">
<div class="rule-perms-group-header">
<div class="text-subtitle2">{{ group.title }}</div>
<div class="text-caption text-grey-7">{{ group.hint }}</div>
</div>
<div class="rule-perms-lists">
<!--
-> Not `dense`, on the list or the rows: `WList` compresses a dense list's rows with a
scoped rule that outranks `.rule-perms-row`, which is what sets their spacing here.
-->
<w-list v-for="(list, idx) of group.lists" :key="idx">
<w-item
v-for="opt of list"
:key="opt.permission"
class="rule-perms-row"
:class="{ 'is-section-end': SECTION_ENDS.includes(opt.permission) }"
tag="label">
<w-item-section avatar class="!min-w-0 !pr-3">
<w-toggle
v-model="state.selected"
:val="opt.permission"
dense
:aria-label="opt.title" />
</w-item-section>
<w-item-section>
<w-item-label>{{ opt.title }}</w-item-label>
<w-item-label caption>{{ opt.hint }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</div>
</section>
</div>
<w-card-actions class="card-actions">
<span class="text-caption text-grey-7 pl-2">
{{
t(
'admin.groups.rulePermissionsCount',
{ count: state.selected.length },
state.selected.length
)
}}
</span>
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.apply`)"
color="primary"
padding="xs md"
@click="apply" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site'
/**
* Pick the permissions one page rule grants or denies.
*
* Opened from a rule in the group editor (`GroupEditOverlay`), which owns the list of what may be
* offered -- the guests group is offered fewer -- and gets back the chosen permissions, in the order
* they are offered here, through `onOk`.
*/
// PROPS
const props = defineProps({
/**
* What may be picked: `{ permission, title, hint, scope }`, where `scope` is `locale` for the
* permissions a rule's pattern does not apply to.
*/
options: {
type: Array,
default: () => []
},
/** The permissions the rule holds now. */
selected: {
type: Array,
default: () => []
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
selected: [...props.selected]
})
// COMPUTED
/**
* What the second list of the per-page group holds: the permissions over the things that hang off a
* page rather than the page itself -- its assets, its comments and its navigation menu. Everything
* else about pages stays in the first. Tested against the option, not the position, so the order of
* the list each comes from is kept.
*/
/**
* The last permission of each run of related ones -- the page lifecycle, what may go into a page,
* assets, comments -- which a dotted rule closes off from the next run. Only ever drawn where the
* permission is not the last of its list, so a column never ends on a rule.
*/
const SECTION_ENDS = ['delete:pages', 'write:scripts', 'manage:assets', 'manage:comments']
function isAttachedToPage(opt) {
return /:(assets|comments)$/.test(opt.permission) || opt.permission === 'manage:navigation'
}
const groups = computed(() => {
const perPage = props.options.filter((opt) => opt.scope !== 'locale')
return [
{
key: 'page',
title: t('admin.groups.rulePermissionsPage'),
hint: t('admin.groups.rulePermissionsPageHint'),
lists: [
perPage.filter((opt) => !isAttachedToPage(opt)),
// -> Navigation last, after the assets and comments, wherever the source list has it
[
...perPage.filter(
(opt) => isAttachedToPage(opt) && opt.permission !== 'manage:navigation'
),
...perPage.filter((opt) => opt.permission === 'manage:navigation')
]
].filter((list) => list.length > 0)
},
{
key: 'locale',
title: t('admin.groups.rulePermissionsLocale'),
hint: t('admin.groups.rulePermissionsLocaleHint'),
lists: [props.options.filter((opt) => opt.scope === 'locale')]
}
]
})
// METHODS
/**
* Hand back what is ticked, in the order offered, followed by anything the rule held that this list
* does not offer -- a rule imported from elsewhere may name one, and picking from a narrower list is
* not a reason to drop it silently.
*/
function apply() {
const offered = props.options.map((opt) => opt.permission)
const ticked = new Set(state.selected)
onDialogOK([
...offered.filter((permission) => ticked.has(permission)),
...props.selected.filter((permission) => !offered.includes(permission))
])
}
</script>
<style lang="scss">
/*
Three columns, two or one, decided by the card's own width rather than the window's (the dialog is
capped at 95vw, and a container query cannot be fooled by that):
- 1100px and up: the per-page group takes two thirds, as two lists side by side; the locale group
takes the last third.
- 700px and up: two equal columns, the per-page group's lists stacked in the first.
- Below that: everything in one column.
*/
.rule-perms {
container-type: inline-size;
&-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0 1rem;
/* -> The window less the header, the actions and the dialog's own margins */
max-height: calc(100vh - 180px);
overflow: auto;
padding: 0.5rem 0.5rem 0.75rem;
@container (min-width: 700px) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@container (min-width: 1100px) {
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
}
&-lists {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0 1rem;
align-items: start;
}
@container (min-width: 1100px) {
.is-page &-lists {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
/* -> Room under the hint, so it reads as the group's caption rather than the first row's */
&-group-header {
padding: 0.5rem 1rem 0.75rem;
}
/*
-> A rule between the two groups wherever they sit side by side, since that is the line the dialog
exists to draw: what a rule's pattern applies to, and what it does not.
*/
@container (min-width: 700px) {
.is-locale {
border-left: 1px solid rgba(0, 0, 0, 0.12);
padding-left: 0.5rem;
@at-root .body--dark & {
border-left-color: rgba(255, 255, 255, 0.12);
}
}
}
/*
-> Each permission its own row, with room between them: a title and its hint read as a pair, and
the next pair starts clearly below. The toggle is centred on the two together.
*/
&-row {
min-height: 0;
padding-top: 5px;
padding-bottom: 5px;
.w-item-label--caption {
line-height: 1.3;
}
&.is-section-end:not(:last-child) {
border-bottom: 2px dotted rgba(0, 0, 0, 0.16);
margin-bottom: 5px;
padding-bottom: 10px;
@at-root .body--dark & {
border-bottom-color: rgba(255, 255, 255, 0.16);
}
}
}
}
</style>
+15
View File
@@ -279,6 +279,19 @@
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="parchment" />
<w-item-section>
<w-item-label>{{ t(`admin.general.allowGlossary`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.allowGlossaryHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.features.glossary"
:aria-label="t(`admin.general.allowGlossary`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<!--
What the missing-page screen shows at a path with pages under it. Off, that screen is
only the notice and the Create button.
@@ -732,6 +745,7 @@ function defaultConfig() {
collaborativeEditing: false,
ratingsMode: 'off',
comments: true,
glossary: true,
lastEditedBy: true,
listFolderChildren: false,
notifications: true,
@@ -875,6 +889,7 @@ async function save() {
// silent: the toggle moved, the save succeeded, and the next load put it back.
collaborativeEditing: state.config.features?.collaborativeEditing ?? false,
comments: state.config.features?.comments ?? true,
glossary: state.config.features?.glossary ?? true,
lastEditedBy: state.config.features?.lastEditedBy ?? true,
listFolderChildren: state.config.features?.listFolderChildren ?? false,
notifications: state.config.features?.notifications ?? true,
+57 -38
View File
@@ -20,38 +20,62 @@ import MarkdownIt from 'markdown-it'
* tables, no footnotes, no HTML. Links are rendered but every one of them leaves with
* `rel="nofollow ugc noopener"` and opens in a new tab.
*/
const md = new MarkdownIt('zero', {
html: false,
linkify: true,
breaks: true,
typographer: false
})
.enable([
'blockquote',
'code',
'emphasis',
'entity',
'escape',
'fence',
'linkify',
'list',
'newline',
'backticks',
'link',
'strikethrough'
])
// -> A comment is prose, and a rule across it is furniture; a heading in one would outrank the
// page's own headings in the outline of the view it sits in
.disable([
'heading',
'lheading',
'hr',
'image',
'table',
'reference',
'html_block',
'html_inline'
])
/**
* The prose markdown this file renders, as a fresh instance whose links carry `rel`.
*
* Exported for the glossary's definitions (`renderers/glossary.js`), which want exactly this feature
* set and differ only in who wrote the links -- see there. Everything said above about `html: false`
* holds for both.
*
* @param {object} opts
* @param {string} opts.rel What every link leaves with
*/
export function createProseMarkdown({ rel }) {
const instance = new MarkdownIt('zero', {
html: false,
linkify: true,
breaks: true,
typographer: false
})
.enable([
'blockquote',
'code',
'emphasis',
'entity',
'escape',
'fence',
'linkify',
'list',
'newline',
'backticks',
'link',
'strikethrough'
])
// -> Prose, and a rule across it is furniture; a heading in one would outrank the page's own
// headings in the outline of the view it sits in
.disable([
'heading',
'lheading',
'hr',
'image',
'table',
'reference',
'html_block',
'html_inline'
])
// -> Every link opens in a new tab: the view it is drawn in is something the reader is in the middle
// of, and `noopener` (which every `rel` given here carries) keeps the tab from handing back a
// handle to it
instance.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
token.attrSet('rel', rel)
token.attrSet('target', '_blank')
return self.renderToken(tokens, idx, options)
}
return instance
}
/**
* Every link a comment carries, whoever wrote it.
@@ -60,12 +84,7 @@ const md = new MarkdownIt('zero', {
* two attributes exist to say -- and `noopener` because the tab is opened by the wiki and must not
* hand the opened page a handle back to it.
*/
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
token.attrSet('rel', 'nofollow ugc noopener')
token.attrSet('target', '_blank')
return self.renderToken(tokens, idx, options)
}
const md = createProseMarkdown({ rel: 'nofollow ugc noopener' })
/**
* A mention as it is written in a comment: `@handle`.
+23
View File
@@ -0,0 +1,23 @@
import { createProseMarkdown } from './comment.js'
/**
* The markdown a glossary definition may be written in: a comment's, link for link.
*
* The same feature set and the same security boundary (`html: false` -- see `renderers/comment.js`), so
* nothing stored is ever HTML. Two differences, both about who wrote it: links lose `nofollow ugc`,
* since a definition is written by somebody holding `manage:glossary` rather than by whoever may
* comment, and `@handle` is not a mention, since nobody is being addressed.
*/
const md = createProseMarkdown({ rel: 'noopener' })
/**
* Render one definition.
*
* @param {string} source Markdown as it was typed
* @returns {string} HTML, safe to hand to `v-html`: nothing in it came from the source unescaped
*/
export function renderDefinition(source) {
return md.render(source ?? '')
}
export default renderDefinition
+74
View File
@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { useSiteStore } from './site'
import { useUserStore } from './user'
/**
* What the current session may do with this site's glossary.
*
* Asked once per site and session (`glossary/access`) rather than per route, because `read:glossary`
* does not depend on the page: it is a page permission whose rule's path is ignored, answered per
* locale. That is also why it is not part of `userStore.pagePermissions`, which is fetched with a page
* and is empty on every route that is not one -- the Library menu has to know on `/_tags` too.
*
* The terms themselves are not held here: the overlay is the only thing that reads them, and it
* fetches them when it opens.
*/
export const useGlossaryStore = defineStore('glossary', {
state: () => ({
/** Whose answer this is -- `siteId:userId` -- so a login, a logout or another site asks again. */
loadedFor: null,
enabled: false,
readableLocales: [],
/** The locales this session may edit the glossary in -- `manage:glossary`, asked per locale. */
manageableLocales: []
}),
getters: {
/** Whether there is a glossary this session may open, in any locale. */
isAvailable: (state) => state.enabled && state.readableLocales.length > 0,
/** Whether this session may create, edit and delete terms in a locale. */
canManageIn: (state) => (locale) => state.manageableLocales.includes(locale)
},
actions: {
/**
* Fetch the access answer, unless it is already the one for this site and this session.
*
* A failure leaves the glossary unavailable rather than throwing: the menu row is the only thing
* waiting on it, and a missing row is the right answer to not knowing.
*/
async ensureAccess() {
const siteStore = useSiteStore()
const userStore = useUserStore()
if (!siteStore.id) {
return
}
const key = `${siteStore.id}:${userStore.authenticated ? userStore.id : 'guest'}`
if (this.loadedFor === key) {
return
}
this.loadedFor = key
try {
const access = await API_CLIENT.get(`sites/${siteStore.id}/glossary/access`).json()
// -> Asked again for somebody else while this was in flight: that answer is the one to keep
if (this.loadedFor !== key) {
return
}
this.$patch({
enabled: access.enabled,
readableLocales: access.readableLocales ?? [],
manageableLocales: access.manageableLocales ?? []
})
} catch (err) {
console.warn(`Failed to fetch glossary access: ${err.message}`)
if (this.loadedFor === key) {
this.$patch({
loadedFor: null,
enabled: false,
readableLocales: [],
manageableLocales: []
})
}
}
}
}
})
+16
View File
@@ -119,6 +119,8 @@ export const useSiteStore = defineStore('site', {
backlinks: true,
browse: false,
collaborativeEditing: false,
// -> On, for the reason `backlinks` is: the server reads a missing key as on
glossary: true,
lastEditedBy: true,
listFolderChildren: false,
// -> On, for the reason `backlinks` is: the server reads a missing key as on, and the badge
@@ -413,6 +415,20 @@ export const useSiteStore = defineStore('site', {
}
})
},
/**
* @param {object} [opts]
* @param {?string} [opts.termId] The term to open on. Absent, the list with nothing selected.
* @param {?string} [opts.locale] The locale to open in. Absent, the one the interface is in.
*/
openGlossary(opts) {
this.$patch({
overlay: 'Glossary',
overlayOpts: {
termId: opts?.termId ?? null,
locale: opts?.locale ?? null
}
})
},
async loadSite(hostname) {
try {
// -> The locale descriptors come alongside rather than after, so the selector's label is