You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/models/approvals.ts

807 lines
28 KiB

import { createHash } from 'node:crypto'
import { createPatch } from 'diff'
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
import {
approvalRules as approvalRulesTable,
groups as groupsTable,
pageEditSubmissions as submissionsTable,
pages as pagesTable,
users as usersTable
} from '../db/schema.ts'
/**
* How a rule decides which pages it covers. The same set group page rules use, so an administrator
* writing one has learnt the other.
*/
export const approvalMatchModes = ['START', 'EXACT', 'END', 'REGEX', 'TAG', 'TAGALL'] as const
export type ApprovalMatchMode = (typeof approvalMatchModes)[number]
/** The part of a page a rule is matched against. */
/** What a rule is matched against: where the page is, and what it is tagged with. */
export interface ApprovalPageMatch {
path: string
tags: string[]
}
export interface ApprovalPageRef extends ApprovalPageMatch {
id: string
/**
* The page's own switch, from its properties. A page with contributions turned off takes no
* suggestions whatever the rules say — which is how a single page is exempted without writing a
* rule around it.
*/
allowContributions: boolean
}
/**
* Who is reviewing, as the rules see them.
*
* `reviewsAll` covers the two ways of being a reviewer without a rule naming your group: the
* `manage:system` permission, which sees everything everywhere, and `review:pages`, which is granted
* to review pages and would be worth nothing if it could not. Neither widens WHICH pages take
* suggestions -- a page still needs a rule -- only who may answer them.
*/
export interface ReviewerScope {
groupIds: string[]
reviewsAll?: boolean
}
/** An edit suggested against a page, as the author's own view of it. */
export interface PageEditSubmission {
id: string
content: string
baseHash: string
createdAt: Date
updatedAt: Date
}
/** A submission as a reviewer sees it in their queue. */
export interface ReviewableSubmission {
id: string
createdAt: Date
updatedAt: Date
/** Whether the page has changed since the suggestion was made against it. */
isStale: boolean
page: {
id: string
path: string
title: string
locale: string
}
author: {
/** Null for a guest, who has no account to point at. */
id: string | null
name: string
email: string
isGuest: boolean
}
}
/** A submission opened for review, with everything the diff needs. */
export interface ReviewableSubmissionDetail extends ReviewableSubmission {
/** What the suggestion proposes the page should say. */
content: string
/** What it currently says, i.e. the other side of the diff. */
pageContent: string
/** Unified diff against the page as it stood when the suggestion was made. */
patch: string
}
/** An approval rule as the API exposes it. */
export interface ApprovalRule {
id: string
name: string
isEnabled: boolean
match: ApprovalMatchMode
path: string
/** IDs of the groups whose members may submit edit suggestions for a matching page. */
submitterGroups: string[]
/** IDs of the groups that review those submissions, and are notified of new ones. */
reviewerGroups: string[]
createdAt: Date
updatedAt: Date
}
/** The fields a rule is created or updated with. */
export interface ApprovalRulePatch {
name?: string
isEnabled?: boolean
match?: ApprovalMatchMode
path?: string
submitterGroups?: string[]
reviewerGroups?: string[]
}
/**
* The tags of a tag-mode rule, as they are written into the one pattern field: comma-separated, and
* compared in lower case the way page tags are stored.
*/
function parseTags(value: string): string[] {
return value
.split(',')
.map((tag) => tag.trim().toLowerCase())
.filter((tag) => tag.length > 0)
}
const ruleSelection = {
id: approvalRulesTable.id,
name: approvalRulesTable.name,
isEnabled: approvalRulesTable.isEnabled,
match: approvalRulesTable.match,
path: approvalRulesTable.path,
submitterGroups: approvalRulesTable.submitterGroups,
reviewerGroups: approvalRulesTable.reviewerGroups,
createdAt: approvalRulesTable.createdAt,
updatedAt: approvalRulesTable.updatedAt
}
/**
* Every site's rules, by site id, in the order `getRules` promises.
*
* Cached for the reason the group rules are (`models/groups.ts`): whether a page takes suggestions
* and who reviews it are questions the page view asks about every page it draws, and answering them
* from the database would put two queries in front of every page read. Rules change from one admin
* screen, and the cache is reloaded there.
*
* A single instance's memory, like the group and site caches beside it: a rule changed on one node of
* a cluster reaches the others when they next reload.
*/
let rulesCache: Record<string, ApprovalRule[]> = {}
/**
* Approvals model
*
* Only the rules for now: which pages accept edit suggestions, from whom, and who reviews them. The
* submissions themselves are a separate concern and are not stored yet.
*/
class Approvals {
/**
* Reload every site's rules into memory.
*
* Called at boot and after any change to a rule, so that an administrator's edit takes effect on the
* next request — the same contract `models/groups.ts` gives page rules.
*/
async reloadCache(): Promise<void> {
const rows = (await WIKI.db
.select({ ...ruleSelection, siteId: approvalRulesTable.siteId })
.from(approvalRulesTable)
.orderBy(
asc(sql`lower(${approvalRulesTable.name})`),
asc(approvalRulesTable.createdAt)
)) as (ApprovalRule & { siteId: string })[]
rulesCache = {}
for (const { siteId, ...rule } of rows) {
rulesCache[siteId] ??= []
rulesCache[siteId].push(rule as ApprovalRule)
}
WIKI.logger.info(`Loaded ${rows.length} approval rules [ OK ]`)
}
/**
* Every rule configured for a site, by name.
*
* Order carries no meaning — a page is covered if any enabled rule matches it — so the list is
* sorted for the reader: alphabetically, ignoring case, since `Zoo` sorting before `apple` is not
* what alphabetical means to anyone. Two rules sharing a name keep a stable order by age.
*
* From `rulesCache`, so this costs nothing to ask; async because every caller awaits it and because
* where the rules come from is this model's business. The array is the cached one — read it, do not
* sort or splice it.
*/
async getRules(siteId: string): Promise<ApprovalRule[]> {
return rulesCache[siteId] ?? []
}
/**
* A single rule, scoped to its site so that an ID from another site cannot be reached through it.
*
* @returns The rule, or null if this site has no such rule
*/
async getRule(siteId: string, id: string): Promise<ApprovalRule | null> {
const rows = await WIKI.db
.select(ruleSelection)
.from(approvalRulesTable)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
.limit(1)
return (rows[0] as ApprovalRule) ?? null
}
/**
* The IDs among those given that are not groups on this instance.
*
* A picker only offers real groups, so a miss means a stale client or a group deleted mid-edit —
* worth reporting rather than storing an ID that resolves to nobody.
*/
async getUnknownGroupIds(groupIds: string[]): Promise<string[]> {
const wanted = [...new Set(groupIds)]
if (wanted.length < 1) {
return []
}
const found = await WIKI.db
.select({ id: groupsTable.id })
.from(groupsTable)
.where(inArray(groupsTable.id, wanted))
const foundIds = new Set(found.map((g: any) => g.id))
return wanted.filter((id) => !foundIds.has(id))
}
/**
* Create a rule for a site.
*
* @returns The rule as stored
*/
async createRule(siteId: string, patch: ApprovalRulePatch): Promise<ApprovalRule> {
const rows = await WIKI.db
.insert(approvalRulesTable)
.values({
siteId,
name: patch.name ?? '',
isEnabled: patch.isEnabled ?? true,
match: patch.match ?? 'START',
// -> Trimmed, so a pattern typed with a stray space still matches what it reads as -- and so
// that a `START` path of nothing but spaces is the whole site rather than a rule that
// quietly covers no page at all
path: (patch.path ?? '').trim(),
submitterGroups: patch.submitterGroups ?? [],
reviewerGroups: patch.reviewerGroups ?? []
})
.returning(ruleSelection)
// -> Every rule read afterwards comes from the cache, so it has to know about this one
await this.reloadCache()
return rows[0] as ApprovalRule
}
/**
* Update a rule, leaving out fields alone.
*
* @returns The updated rule, or null if this site has no such rule
*/
async updateRule(
siteId: string,
id: string,
patch: ApprovalRulePatch
): Promise<ApprovalRule | null> {
const values: Record<string, any> = { updatedAt: new Date() }
for (const key of [
'name',
'isEnabled',
'match',
'path',
'submitterGroups',
'reviewerGroups'
] as const) {
if (patch[key] !== undefined) {
// -> Trimmed for the same reason it is on create
values[key] = key === 'path' ? String(patch[key]).trim() : patch[key]
}
}
const rows = await WIKI.db
.update(approvalRulesTable)
.set(values)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
.returning(ruleSelection)
await this.reloadCache()
return (rows[0] as ApprovalRule) ?? null
}
/**
* Whether a rule covers a page.
*
* Paths are compared without a leading slash on either side, which is how they are stored and how
* the rule is written. A regular expression that will not compile matches nothing rather than
* throwing: the rule is already refused at the API, so this is only reached by one that was valid
* when it was written and stopped being so.
*/
matchesPage(rule: ApprovalRule, page: ApprovalPageMatch): boolean {
const pagePath = page.path.replace(/^\/+/, '')
const rulePath = rule.path.replace(/^\/+/, '')
switch (rule.match) {
case 'START':
return pagePath.startsWith(rulePath)
case 'EXACT':
return pagePath === rulePath
case 'END':
return pagePath.endsWith(rulePath)
case 'REGEX':
try {
return new RegExp(rulePath).test(pagePath)
} catch {
return false
}
case 'TAG':
return parseTags(rule.path).some((tag) => page.tags.includes(tag))
case 'TAGALL': {
const wanted = parseTags(rule.path)
return wanted.length > 0 && wanted.every((tag) => page.tags.includes(tag))
}
default:
return false
}
}
/**
* The groups an actor belongs to, as the rules see them.
*
* A request with no session is not nobody: it is the guests group, and a rule naming that group is
* how an administrator opens suggestions to anyone reading the site. Taken from the fixed ID in the
* configuration rather than by reading the guest account's membership — that account's groups cannot
* be changed, and the ID of the account itself only exists while an instance is being seeded.
*/
getActorGroupIds(req: any): string[] {
if (req.session?.authenticated && req.session.user?.id) {
return req.session.groups ?? []
}
return [WIKI.data.systemIds.guestsGroupId]
}
/**
* The enabled rule that lets these groups suggest an edit to this page, if there is one.
*
* The page's own `allowContributions` is a veto rather than another condition to match: a rule says
* which pages MAY take suggestions, and turning the switch off on one page says that this one does
* not — no rule has to be rewritten, narrowed or excluded around it.
*
* Everything asking whether a page takes a suggestion asks this, which is why the check lives here
* rather than at either route.
*
* @returns The first matching rule, or null when the page takes no suggestions from them
*/
async findSubmitRule(
siteId: string,
page: ApprovalPageRef,
groupIds: string[]
): Promise<ApprovalRule | null> {
if (groupIds.length < 1 || !page.allowContributions) {
return null
}
const rules = await this.getRules(siteId)
return (
rules.find(
(rule) =>
rule.isEnabled &&
rule.submitterGroups.some((id) => groupIds.includes(id)) &&
this.matchesPage(rule, page)
) ?? null
)
}
/**
* Whether this reviewer has any business reviewing this page at all.
*
* What decides whether the page view offers a review button, so it is about the page rather than
* about what happens to be waiting on it: a reviewer of a page with an empty queue is still its
* reviewer. A page no rule covers takes no suggestions, so nobody reviews it -- not even an
* administrator, who would only be offered a button that could never have anything behind it.
*/
async canReviewPage(
siteId: string,
page: ApprovalPageMatch,
{ groupIds, reviewsAll = false }: ReviewerScope
): Promise<boolean> {
if (!reviewsAll && groupIds.length < 1) {
return false
}
const rules = await this.getRules(siteId)
return rules.some(
(rule) =>
rule.isEnabled &&
(reviewsAll || rule.reviewerGroups.some((id) => groupIds.includes(id))) &&
this.matchesPage(rule, page)
)
}
/**
* Whether this request could review anything at all, i.e. it is a logged in user.
*
* Reads the session and nothing else, so a guest can be turned away before a single query is made on
* their behalf. A guest counts as a member of the guests group everywhere else, which is right for
* SUBMITTING — anonymous suggestions are a feature — but a review is an act with an author.
*/
isReviewerSession(req: any): boolean {
return Boolean(req.session?.authenticated && req.session.user?.id)
}
/**
* Where this reader stands on this page: may they suggest an edit to it, and do they review it.
*
* Answered here, in one place, because it is answered on EVERY page view — the page route carries it
* back with the page rather than leaving the browser to ask two more questions about a page it has
* just been given. The cost is kept to what is actually needed: the rules are in memory, and neither
* of the two queries below is reached by a reader the rules say nothing about.
*
* @param req The request, for its session; both answers are about who is asking
*/
async pageViewerState(
req: any,
siteId: string,
page: ApprovalPageRef
): Promise<{
canSuggestEdits: boolean
hasOpenSuggestion: boolean
canReview: boolean
pendingSubmissions: ReviewableSubmission[]
}> {
const actorId = req.session?.authenticated ? (req.session.user?.id ?? null) : null
const groupIds = this.getActorGroupIds(req)
const submitRule = await this.findSubmitRule(siteId, page, groupIds)
/*
Only a logged in author can have one waiting: a guest suggestion is attributed to nobody, so
there is nothing to look up and nothing to carry on from. `getOwnSubmission` says the same, and
this keeps the query from being made at all.
*/
const hasOpenSuggestion = Boolean(
submitRule && actorId && (await this.getOwnSubmission(page.id, actorId))
)
const reviewerScope = this.isReviewerSession(req)
? {
groupIds,
reviewsAll:
(req.session?.permissions ?? []).includes('manage:system') ||
WIKI.models.groups.checkAccess(
WIKI.models.groups.actorForRequest(req),
'review:pages',
{
path: page.path,
tags: page.tags
}
)
}
: { groupIds: [], reviewsAll: false }
const canReview = await this.canReviewPage(siteId, page, reviewerScope)
return {
canSuggestEdits: Boolean(submitRule),
hasOpenSuggestion,
canReview,
pendingSubmissions: canReview
? await this.getReviewableSubmissions(siteId, { ...reviewerScope, pageId: page.id })
: []
}
}
/**
* The suggestion this user already has open on this page, if any.
*
* Guests get null whoever they are: there is no account to look one up by, so every guest
* suggestion is a new one.
*/
async getOwnSubmission(
pageId: string,
authorId: string | null
): Promise<PageEditSubmission | null> {
if (!authorId) {
return null
}
const rows = await WIKI.db
.select({
id: submissionsTable.id,
content: submissionsTable.content,
baseHash: submissionsTable.baseHash,
createdAt: submissionsTable.createdAt,
updatedAt: submissionsTable.updatedAt
})
.from(submissionsTable)
.where(and(eq(submissionsTable.pageId, pageId), eq(submissionsTable.authorId, authorId)))
.limit(1)
return (rows[0] as PageEditSubmission) ?? null
}
/**
* Store an edit somebody has suggested for a page.
*
* The patch is taken against the page as it stands right now, which is what makes two suggestions to
* different parts of the same page both applicable later. A logged in author has one open suggestion
* per page and this replaces it; a guest has no identity to match on, so each submission is its own.
*
* @param baseContent The page source the suggestion was made against
* @returns The stored suggestion
*/
async saveSubmission({
siteId,
page,
baseContent,
content,
authorId,
guestName,
guestEmail
}: {
siteId: string
page: ApprovalPageRef
baseContent: string
content: string
authorId: string | null
guestName?: string
guestEmail?: string
}): Promise<PageEditSubmission> {
const values = {
siteId,
pageId: page.id,
authorId,
content,
patch: createPatch(page.path, baseContent, content),
baseHash: createHash('sha256').update(baseContent).digest('hex'),
guestName: authorId ? null : (guestName ?? ''),
guestEmail: authorId ? null : (guestEmail ?? ''),
updatedAt: new Date()
}
const rows = authorId
? await WIKI.db
.insert(submissionsTable)
.values(values)
.onConflictDoUpdate({
target: [submissionsTable.pageId, submissionsTable.authorId],
// -> Matches the partial index, which only covers rows with an author
targetWhere: sql`"authorId" IS NOT NULL`,
set: {
content: values.content,
patch: values.patch,
baseHash: values.baseHash,
updatedAt: values.updatedAt
}
})
.returning()
: await WIKI.db.insert(submissionsTable).values(values).returning()
const stored = rows[0]
WIKI.logger.debug(
`Stored an edit suggestion for page ${page.id} from ${authorId ?? `guest <${guestEmail}>`}`
)
return {
id: stored.id,
content: stored.content,
baseHash: stored.baseHash,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt
}
}
/**
* How many suggestions are waiting on a page. Counted for every reviewer, whoever wrote them.
*/
async countSubmissions(pageId: string): Promise<number> {
return WIKI.db.$count(submissionsTable, eq(submissionsTable.pageId, pageId))
}
/**
* Every suggestion waiting on this reviewer, oldest first.
*
* A suggestion is theirs to review when an enabled rule covers its page and names a group they are
* in — the same rules that let it be submitted, read from the other side. Someone holding
* `manage:system` sees the site's whole queue, as they do everywhere else.
*
* Ordered oldest first because a queue is worked through in the order things arrived.
*/
async getReviewableSubmissions(
siteId: string,
{ groupIds, reviewsAll = false, pageId }: ReviewerScope & { pageId?: string }
): Promise<ReviewableSubmission[]> {
if (!reviewsAll && groupIds.length < 1) {
return []
}
const rules = (await this.getRules(siteId)).filter(
(rule) =>
rule.isEnabled && (reviewsAll || rule.reviewerGroups.some((id) => groupIds.includes(id)))
)
if (rules.length < 1) {
return []
}
const rows = await WIKI.db
.select({
id: submissionsTable.id,
baseHash: submissionsTable.baseHash,
guestName: submissionsTable.guestName,
guestEmail: submissionsTable.guestEmail,
createdAt: submissionsTable.createdAt,
updatedAt: submissionsTable.updatedAt,
pageId: pagesTable.id,
pagePath: pagesTable.path,
pageTitle: pagesTable.title,
pageLocale: pagesTable.locale,
pageTags: pagesTable.tags,
pageContent: pagesTable.content,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(submissionsTable)
.innerJoin(pagesTable, eq(pagesTable.id, submissionsTable.pageId))
.leftJoin(usersTable, eq(usersTable.id, submissionsTable.authorId))
.where(
pageId
? and(eq(submissionsTable.siteId, siteId), eq(submissionsTable.pageId, pageId))
: eq(submissionsTable.siteId, siteId)
)
.orderBy(asc(submissionsTable.createdAt))
// -> Matched in memory rather than in SQL: a rule can be a regular expression or a set of tags,
// which no `WHERE` clause here could express, and a review queue is small
return rows
.filter((row: any) =>
rules.some((rule) =>
/*
No `allowContributions` here, deliberately: that switch governs whether a suggestion may
be MADE. One already sent stays in its reviewers' queue if the page is later closed to
contributions -- otherwise turning the switch off would silently strand work somebody had
submitted in good faith, with nobody able to accept or decline it.
*/
this.matchesPage(rule, { path: row.pagePath, tags: row.pageTags ?? [] })
)
)
.map((row: any) => this.toReviewable(row))
}
/**
* One submission, if it is this reviewer's to look at, with both sides of the diff.
*
* @returns The submission, or null when it does not exist or is not theirs to review
*/
async getSubmissionForReview(
siteId: string,
submissionId: string,
{ groupIds, reviewsAll = false }: ReviewerScope
): Promise<ReviewableSubmissionDetail | null> {
// -> Reuses the queue rather than re-deriving who may see what: one definition of reviewable
const reviewable = await this.getReviewableSubmissions(siteId, { groupIds, reviewsAll })
if (!reviewable.some((s) => s.id === submissionId)) {
return null
}
const rows = await WIKI.db
.select({
content: submissionsTable.content,
patch: submissionsTable.patch,
pageContent: pagesTable.content
})
.from(submissionsTable)
.innerJoin(pagesTable, eq(pagesTable.id, submissionsTable.pageId))
.where(eq(submissionsTable.id, submissionId))
.limit(1)
const detail = rows[0]
if (!detail) {
return null
}
return {
...reviewable.find((s) => s.id === submissionId)!,
content: detail.content,
pageContent: detail.pageContent ?? '',
patch: detail.patch
}
}
/**
* Accept a suggestion: write it to the page and close the suggestion out.
*
* The content applied is whatever the reviewer settled on, which is not necessarily what was
* submitted — the review screen lets them adjust it before accepting. It is written as an ordinary
* page edit, so the render, the search index and the page hooks all happen the way they do for any
* other save, with the reviewer recorded as the author: they are the one putting it on the page, and
* a guest submitter has no account to attribute it to.
*
* @returns False when there is no such submission
*/
async approveSubmission({
siteId,
submissionId,
content,
render,
actor
}: {
siteId: string
submissionId: string
content: string
/** The rendered HTML. Rendered here instead when the caller has none, which needs an extension. */
render?: string
actor: { id: string; permissions: string[] }
}): Promise<boolean> {
const rows = await WIKI.db
.select({ id: submissionsTable.id, pageId: submissionsTable.pageId })
.from(submissionsTable)
.where(and(eq(submissionsTable.id, submissionId), eq(submissionsTable.siteId, siteId)))
.limit(1)
const submission = rows[0]
if (!submission) {
return false
}
const page = await WIKI.models.pages.getPage({
siteId,
id: submission.pageId,
withContent: true
})
if (!page) {
return false
}
/*
The render has to move with the content, or the page keeps serving HTML that no longer matches
its source. The markdown pipeline lives in the frontend, so the reviewer's browser produces it
the same way the editor does on any other save, and it arrives with the approval.
Falling back to the server-side renderer covers an API client that has no pipeline of its own.
That one needs the Puppeteer extension and says so before the content is written if it is
missing, rather than leaving a stale render on a page somebody just changed with no prospect of
it being corrected.
*/
if (!render) {
await WIKI.models.rendering.ensureCanRender(page.editor)
}
await WIKI.models.pages.updatePage(
siteId,
page.id,
{ content, ...(render && { render }) },
actor
)
if (!render) {
// -> Briefly stale rather than wrong: the browser is a queue away, and a suggestion approved
// while it is busy waits its turn instead of starting a second one
await WIKI.models.pages.queueRerender(siteId, page.id, actor)
}
await WIKI.db.delete(submissionsTable).where(eq(submissionsTable.id, submissionId))
WIKI.logger.debug(`Approved edit suggestion ${submissionId} onto page ${page.id}`)
return true
}
/**
* Decline a suggestion, which discards it. The page is untouched.
*
* @returns False when there is no such submission
*/
async rejectSubmission(siteId: string, submissionId: string): Promise<boolean> {
const result = await WIKI.db
.delete(submissionsTable)
.where(and(eq(submissionsTable.id, submissionId), eq(submissionsTable.siteId, siteId)))
return (result.rowCount ?? 0) > 0
}
/** One joined row, as the review queue presents it. */
toReviewable(row: any): ReviewableSubmission {
return {
id: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
// -> The page has moved on since this was written, so accepting it wholesale would undo whatever
// changed in between. The reviewer is shown the current page as the other side of the diff
// either way; this is what tells them to look closely.
isStale:
createHash('sha256')
.update(row.pageContent ?? '')
.digest('hex') !== row.baseHash,
page: {
id: row.pageId,
path: row.pagePath,
title: row.pageTitle,
locale: row.pageLocale
},
author: {
id: row.authorId ?? null,
name: row.authorName ?? row.guestName ?? '',
email: row.authorEmail ?? row.guestEmail ?? '',
isGuest: !row.authorId
}
}
}
/**
* Delete a rule.
*
* @returns Whether a rule was deleted
*/
async deleteRule(siteId: string, id: string): Promise<boolean> {
const result = await WIKI.db
.delete(approvalRulesTable)
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
await this.reloadCache()
return (result.rowCount ?? 0) > 0
}
}
export const approvals = new Approvals()