mirror of https://github.com/requarks/wiki
parent
072e1dcc42
commit
957efebecb
@ -0,0 +1,523 @@
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { actorFrom, mayBypassPassword, unlockedFor } from './pages.ts'
|
||||
import type { ApprovalPageRef, ApprovalRulePatch } 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
|
||||
* 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)
|
||||
return WIKI.models.pages.getPage({
|
||||
siteId,
|
||||
id: pageId,
|
||||
withContent: true,
|
||||
publicOnly: !actor,
|
||||
unlocked: (id: string) => unlockedFor(req, id),
|
||||
withPassword: mayBypassPassword(req)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a rule has to satisfy beyond what the JSON Schema already enforces.
|
||||
*
|
||||
* All of it comes down to the same thing: a rule that cannot match a page, or that nobody is on either
|
||||
* side of, is a rule that does nothing, and storing one silently is worse than refusing it.
|
||||
*
|
||||
* @returns A `CustomError` to throw, or null when the rule is usable
|
||||
*/
|
||||
function validateRule({
|
||||
name,
|
||||
match,
|
||||
path,
|
||||
submitterGroups,
|
||||
reviewerGroups
|
||||
}: {
|
||||
name: string
|
||||
match: string
|
||||
path: string
|
||||
submitterGroups: string[]
|
||||
reviewerGroups: string[]
|
||||
}): CustomError | null {
|
||||
if (!name || name.trim().length < 1) {
|
||||
return new CustomError('approvalRuleEmptyName', 'A rule name is required.')
|
||||
}
|
||||
if (!path || path.trim().length < 1) {
|
||||
return new CustomError(
|
||||
'approvalRuleEmptyPath',
|
||||
match === 'TAG' || match === 'TAGALL'
|
||||
? 'At least one tag is required.'
|
||||
: 'A path is required.'
|
||||
)
|
||||
}
|
||||
if (match === 'REGEX') {
|
||||
try {
|
||||
new RegExp(path)
|
||||
} catch (err: any) {
|
||||
return new CustomError(
|
||||
'approvalRuleInvalidRegex',
|
||||
`Not a valid regular expression: ${err.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (submitterGroups.length < 1) {
|
||||
return new CustomError(
|
||||
'approvalRuleNoSubmitters',
|
||||
'At least one group has to be able to submit edits.'
|
||||
)
|
||||
}
|
||||
if (reviewerGroups.length < 1) {
|
||||
return new CustomError(
|
||||
'approvalRuleNoReviewers',
|
||||
'At least one group has to review submissions.'
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject group IDs that are not groups on this instance, for either list.
|
||||
*
|
||||
* @returns Whether the reply has been sent
|
||||
*/
|
||||
async function rejectUnknownGroups(
|
||||
reply: FastifyReply,
|
||||
groupIds: (string[] | undefined)[]
|
||||
): Promise<boolean> {
|
||||
const unknown = await WIKI.models.approvals.getUnknownGroupIds(
|
||||
groupIds.flatMap((ids) => ids ?? [])
|
||||
)
|
||||
if (unknown.length > 0) {
|
||||
reply.badRequest(`No such group: ${unknown.join(', ')}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Approvals API Routes
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* LIST SITE APPROVAL RULES
|
||||
*/
|
||||
app.get<{ Params: { siteId: string } }>(
|
||||
'/sites/:siteId/approvals/rules',
|
||||
{
|
||||
config: {
|
||||
permissions: ['read:sites', 'manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'List the approval rules of a site',
|
||||
description:
|
||||
'Each rule says which pages accept edit suggestions, which groups may submit them, and which groups review them. A page matched by no rule accepts none, so a site with no rules has the feature off.',
|
||||
tags: ['Approvals'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of approval rules',
|
||||
type: 'array',
|
||||
items: { $ref: 'ApprovalRule#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
return WIKI.models.approvals.getRules(req.params.siteId)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* CREATE AN APPROVAL RULE
|
||||
*/
|
||||
app.post<{ Params: { siteId: string }; Body: ApprovalRulePatch }>(
|
||||
'/sites/:siteId/approvals/rules',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Create an approval rule',
|
||||
description:
|
||||
'Rules are not ordered: a page is covered when any rule matches it, so a new one only ever adds coverage.',
|
||||
tags: ['Approvals'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
body: {
|
||||
allOf: [
|
||||
{ $ref: 'ApprovalRuleInput#' },
|
||||
{ type: 'object', required: ['name', 'match', 'path'] }
|
||||
]
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Rule created successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
rule: { $ref: 'ApprovalRule#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
|
||||
const invalid = validateRule({
|
||||
name: req.body.name!,
|
||||
match: req.body.match!,
|
||||
path: req.body.path!,
|
||||
submitterGroups: req.body.submitterGroups ?? [],
|
||||
reviewerGroups: req.body.reviewerGroups ?? []
|
||||
})
|
||||
if (invalid) {
|
||||
throw invalid
|
||||
}
|
||||
if (await rejectUnknownGroups(reply, [req.body.submitterGroups, req.body.reviewerGroups])) {
|
||||
return reply
|
||||
}
|
||||
|
||||
const rule = await WIKI.models.approvals.createRule(req.params.siteId, req.body)
|
||||
return {
|
||||
ok: true,
|
||||
rule
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* UPDATE AN APPROVAL RULE
|
||||
*/
|
||||
app.put<{ Params: { siteId: string; ruleId: string }; Body: ApprovalRulePatch }>(
|
||||
'/sites/:siteId/approvals/rules/:ruleId',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Update an approval rule',
|
||||
description: 'Accepts any subset of the fields; omitted ones are left unchanged.',
|
||||
tags: ['Approvals'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
ruleId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId', 'ruleId']
|
||||
},
|
||||
body: { $ref: 'ApprovalRuleInput#' },
|
||||
response: {
|
||||
200: {
|
||||
description: 'Rule updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
rule: { $ref: 'ApprovalRule#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const current = await WIKI.models.approvals.getRule(req.params.siteId, req.params.ruleId)
|
||||
if (!current) {
|
||||
return reply.notFound('Approval rule does not exist.')
|
||||
}
|
||||
if (Object.keys(req.body).length < 1) {
|
||||
throw new CustomError('approvalRuleEmpty', 'No rule fields provided to update.')
|
||||
}
|
||||
|
||||
// -> Validated as the rule will be, not as it was sent: changing the mode alone has to hold up
|
||||
// against the stored path, and emptying one group list has to be caught even though the other
|
||||
// was not touched
|
||||
const invalid = validateRule({
|
||||
name: req.body.name ?? current.name,
|
||||
match: req.body.match ?? current.match,
|
||||
path: req.body.path ?? current.path,
|
||||
submitterGroups: req.body.submitterGroups ?? current.submitterGroups,
|
||||
reviewerGroups: req.body.reviewerGroups ?? current.reviewerGroups
|
||||
})
|
||||
if (invalid) {
|
||||
throw invalid
|
||||
}
|
||||
if (await rejectUnknownGroups(reply, [req.body.submitterGroups, req.body.reviewerGroups])) {
|
||||
return reply
|
||||
}
|
||||
|
||||
const rule = await WIKI.models.approvals.updateRule(
|
||||
req.params.siteId,
|
||||
req.params.ruleId,
|
||||
req.body
|
||||
)
|
||||
if (!rule) {
|
||||
return reply.notFound('Approval rule does not exist.')
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
rule
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE AN APPROVAL RULE
|
||||
*/
|
||||
app.delete<{ Params: { siteId: string; ruleId: string } }>(
|
||||
'/sites/:siteId/approvals/rules/:ruleId',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Delete an approval rule',
|
||||
description:
|
||||
'The pages it covered stop accepting edit suggestions, unless another rule also matches them.',
|
||||
tags: ['Approvals'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
ruleId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId', 'ruleId']
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Rule deleted successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!(await WIKI.models.approvals.deleteRule(req.params.siteId, req.params.ruleId))) {
|
||||
return reply.notFound('Approval rule does not exist.')
|
||||
}
|
||||
return reply.code(204).send()
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET OWN SUGGESTION STATE FOR A PAGE
|
||||
*
|
||||
* Deliberately not permission-gated: whether somebody may suggest an edit is decided by the site's
|
||||
* approval rules and the groups they are in, and for an anonymous reader those are the guests
|
||||
* group's. A route permission would answer 401 before any of that could be considered.
|
||||
*/
|
||||
app.get<{
|
||||
Params: { siteId: string; pageId: string }
|
||||
Querystring: { withContent?: boolean }
|
||||
}>(
|
||||
'/sites/:siteId/pages/:pageId/suggestions/self',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Whether the caller may suggest edits to a page, and what they already suggested',
|
||||
description:
|
||||
"Answers `canSubmit: false` for a page no enabled rule opens to this reader, which is what hides the button. With `withContent`, also returns the source the editor should open with: the caller's own pending suggestion when they have one, so that they carry on where they left off, otherwise the page as it stands. The source is only ever included when `canSubmit` holds.",
|
||||
tags: ['Approvals'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
pageId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId', 'pageId']
|
||||
},
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withContent: { type: 'boolean', default: false }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Suggestion state for the caller',
|
||||
type: 'object',
|
||||
properties: {
|
||||
canSubmit: { type: 'boolean' },
|
||||
isGuest: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'True when nobody is logged in, in which case submitting has to carry a name and an email address.'
|
||||
},
|
||||
submission: {
|
||||
type: ['object', 'null'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Only present with `withContent`, and only when `canSubmit` holds.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
reply.preventCache()
|
||||
const page = await loadSuggestablePage(req, req.params.siteId, req.params.pageId)
|
||||
if (!page) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
|
||||
const actor = actorFrom(req)
|
||||
const groupIds = WIKI.models.approvals.getActorGroupIds(req)
|
||||
const pageRef: ApprovalPageRef = { id: page.id, path: page.path, tags: page.tags ?? [] }
|
||||
const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds)
|
||||
if (!rule) {
|
||||
return { canSubmit: false, isGuest: !actor, submission: null }
|
||||
}
|
||||
|
||||
const submission = await WIKI.models.approvals.getOwnSubmission(page.id, actor?.id ?? null)
|
||||
return {
|
||||
canSubmit: true,
|
||||
isGuest: !actor,
|
||||
submission: submission ? { id: submission.id, updatedAt: submission.updatedAt } : null,
|
||||
...(req.query.withContent
|
||||
? { content: submission ? submission.content : (page.content ?? '') }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* SUBMIT AN EDIT SUGGESTION FOR A PAGE
|
||||
*/
|
||||
app.put<{
|
||||
Params: { siteId: string; pageId: string }
|
||||
Body: { content: string; guestName?: string; guestEmail?: string }
|
||||
}>(
|
||||
'/sites/:siteId/pages/:pageId/suggestions/self',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Submit an edit suggestion for a page',
|
||||
description:
|
||||
'Stores the suggested source together with a patch against the page as it stands, so that suggestions to different parts of a page can each be accepted later. A logged in author has one open suggestion per page and submitting again replaces it. An anonymous submitter has no account to attribute it to and has to give a name and an email address instead.',
|
||||
tags: ['Approvals'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
pageId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId', 'pageId']
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['content'],
|
||||
properties: {
|
||||
content: { type: 'string' },
|
||||
guestName: { type: 'string', maxLength: 255 },
|
||||
guestEmail: { type: 'string', maxLength: 255 }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Suggestion submitted successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
submission: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const page = await loadSuggestablePage(req, req.params.siteId, req.params.pageId)
|
||||
if (!page) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
|
||||
const actor = actorFrom(req)
|
||||
const groupIds = WIKI.models.approvals.getActorGroupIds(req)
|
||||
const pageRef: ApprovalPageRef = { id: page.id, path: page.path, tags: page.tags ?? [] }
|
||||
const rule = await WIKI.models.approvals.findSubmitRule(req.params.siteId, pageRef, groupIds)
|
||||
if (!rule) {
|
||||
return reply.forbidden('This page does not accept edit suggestions from you.')
|
||||
}
|
||||
|
||||
const guestName = (req.body.guestName ?? '').trim()
|
||||
const guestEmail = (req.body.guestEmail ?? '').trim()
|
||||
if (!actor) {
|
||||
// -> Nothing else records who this came from, and a reviewer has to be able to answer whoever
|
||||
// sent it
|
||||
if (guestName.length < 1) {
|
||||
throw new CustomError('suggestionGuestNameMissing', 'A name is required.')
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail)) {
|
||||
throw new CustomError('suggestionGuestEmailInvalid', 'A valid email address is required.')
|
||||
}
|
||||
}
|
||||
|
||||
const submission = await WIKI.models.approvals.saveSubmission({
|
||||
siteId: req.params.siteId,
|
||||
page: pageRef,
|
||||
baseContent: page.content ?? '',
|
||||
content: req.body.content,
|
||||
authorId: actor?.id ?? null,
|
||||
guestName,
|
||||
guestEmail
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
submission: { id: submission.id, updatedAt: submission.updatedAt }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,104 @@
|
||||
import { approvalMatchModes } from '../../models/approvals.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* APPROVAL RULE - Which pages accept edit suggestions, from whom, and who reviews them
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'ApprovalRule',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'What the rule is called in the admin list.'
|
||||
},
|
||||
isEnabled: {
|
||||
type: 'boolean',
|
||||
description: 'A disabled rule keeps its configuration but covers nothing.'
|
||||
},
|
||||
match: {
|
||||
type: 'string',
|
||||
enum: [...approvalMatchModes],
|
||||
description:
|
||||
'How `path` is compared: the same modes group page rules use. `TAG` matches a page carrying any of the listed tags, `TAGALL` one carrying all of them.'
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The pattern, without a leading slash. A comma-separated list of tags for the tag modes.'
|
||||
},
|
||||
submitterGroups: {
|
||||
type: 'array',
|
||||
description: 'IDs of the groups whose members may submit edit suggestions.',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
reviewerGroups: {
|
||||
type: 'array',
|
||||
description:
|
||||
'IDs of the groups that review those submissions, and are notified when one comes in.',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'RFC 3339 Date Time'
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'RFC 3339 Date Time'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* APPROVAL RULE INPUT - The fields a rule is written with
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'ApprovalRuleInput',
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 255
|
||||
},
|
||||
isEnabled: {
|
||||
type: 'boolean'
|
||||
},
|
||||
match: {
|
||||
type: 'string',
|
||||
enum: [...approvalMatchModes]
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
maxLength: 2048
|
||||
},
|
||||
submitterGroups: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
reviewerGroups: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
CREATE TABLE "approvalRules" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match" varchar(16) DEFAULT 'START' NOT NULL,
|
||||
"path" varchar(2048) DEFAULT '' NOT NULL,
|
||||
"submitterGroups" jsonb DEFAULT '[]' NOT NULL,
|
||||
"reviewerGroups" jsonb DEFAULT '[]' NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"siteId" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "approvalRules_siteId_idx" ON "approvalRules" ("siteId");--> statement-breakpoint
|
||||
ALTER TABLE "approvalRules" ADD CONSTRAINT "approvalRules_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "approvalRules" ADD COLUMN "name" varchar(255) DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "approvalRules" ADD COLUMN "isEnabled" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,21 @@
|
||||
CREATE TABLE "pageEditSubmissions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"content" text NOT NULL,
|
||||
"patch" text NOT NULL,
|
||||
"baseHash" varchar(64) NOT NULL,
|
||||
"guestName" varchar(255),
|
||||
"guestEmail" varchar(255),
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"pageId" uuid NOT NULL,
|
||||
"siteId" uuid NOT NULL,
|
||||
"authorId" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pageEditSubmissions_pageId_idx" ON "pageEditSubmissions" ("pageId");--> statement-breakpoint
|
||||
CREATE INDEX "pageEditSubmissions_siteId_idx" ON "pageEditSubmissions" ("siteId");--> statement-breakpoint
|
||||
CREATE INDEX "pageEditSubmissions_authorId_idx" ON "pageEditSubmissions" ("authorId");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "pageEditSubmissions_page_author_idx" ON "pageEditSubmissions" ("pageId","authorId") WHERE "authorId" IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
|
||||
ALTER TABLE "pageEditSubmissions" ADD CONSTRAINT "pageEditSubmissions_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id");
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,168 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Time-based one-time passwords (RFC 6238), as every authenticator app implements them: HMAC-SHA1
|
||||
* over a 30-second counter, truncated to 6 digits, keyed by a base32 secret.
|
||||
*
|
||||
* Written here rather than pulled from a package because that is the whole of it — the algorithm is
|
||||
* a dozen lines, and the base32 codec it needs is another twenty. The parameters below are not
|
||||
* configurable on purpose: they are what an `otpauth://` URI means when it omits them, and an
|
||||
* authenticator app that reads a QR code has no way to be told anything else.
|
||||
*/
|
||||
|
||||
/** Digits in a generated code. */
|
||||
const codeDigits = 6
|
||||
|
||||
/** Seconds each code is valid for, before drift is taken into account. */
|
||||
const periodSeconds = 30
|
||||
|
||||
/**
|
||||
* How many periods either side of the current one are accepted, i.e. a code stays usable for ±30s
|
||||
* around its own window. Clocks drift, and a user typing six digits routinely crosses a boundary.
|
||||
*/
|
||||
const allowedDrift = 1
|
||||
|
||||
/**
|
||||
* Bytes of entropy in a generated secret. 20 bytes is the SHA-1 block size and encodes to exactly 32
|
||||
* base32 characters with no padding, which is what authenticator apps expect to be handed.
|
||||
*/
|
||||
const secretBytes = 20
|
||||
|
||||
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
|
||||
/**
|
||||
* Encode bytes as unpadded base32 (RFC 4648), the encoding `otpauth://` URIs use for secrets.
|
||||
*/
|
||||
function base32Encode(bytes: Buffer): string {
|
||||
let out = ''
|
||||
let bits = 0
|
||||
let value = 0
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte
|
||||
bits += 8
|
||||
while (bits >= 5) {
|
||||
out += base32Alphabet[(value >>> (bits - 5)) & 31]
|
||||
bits -= 5
|
||||
}
|
||||
}
|
||||
// -> A trailing group of fewer than 5 bits still carries data; pad it with zeroes on the right
|
||||
if (bits > 0) {
|
||||
out += base32Alphabet[(value << (5 - bits)) & 31]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an unpadded or padded base32 string. Case-insensitive, and separators a user may have typed
|
||||
* are ignored — the secret is also displayed for manual entry, not only scanned.
|
||||
*
|
||||
* @throws If the value contains a character that is not base32
|
||||
*/
|
||||
function base32Decode(value: string): Buffer {
|
||||
const normalized = value.toUpperCase().replaceAll(/[\s-]/g, '').replaceAll('=', '')
|
||||
const bytes: number[] = []
|
||||
let bits = 0
|
||||
let acc = 0
|
||||
for (const char of normalized) {
|
||||
const index = base32Alphabet.indexOf(char)
|
||||
if (index < 0) {
|
||||
throw new Error(`Not a base32 character: ${char}`)
|
||||
}
|
||||
acc = (acc << 5) | index
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
bytes.push((acc >>> (bits - 8)) & 255)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The code a given secret produces for a given counter value.
|
||||
*/
|
||||
function codeAt(secret: Buffer, counter: number): string {
|
||||
const counterBytes = Buffer.alloc(8)
|
||||
counterBytes.writeBigUInt64BE(BigInt(counter))
|
||||
const digest = createHmac('sha1', secret).update(counterBytes).digest()
|
||||
// -> Dynamic truncation: the low nibble of the last byte picks where in the digest to read from
|
||||
const offset = digest[digest.length - 1]! & 0x0f
|
||||
const binary = digest.readUInt32BE(offset) & 0x7fffffff
|
||||
return String(binary % 10 ** codeDigits).padStart(codeDigits, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh TOTP secret, base32-encoded.
|
||||
*/
|
||||
export function generateTotpSecret(): string {
|
||||
return base32Encode(randomBytes(secretBytes))
|
||||
}
|
||||
|
||||
/**
|
||||
* The `otpauth://` URI an authenticator app reads from the QR code.
|
||||
*
|
||||
* The label is `issuer:account` and the issuer is repeated as a parameter, which is what apps
|
||||
* actually key their entries on. Both are URI-encoded; a wiki title containing a `:` or a `?` would
|
||||
* otherwise produce a URI that parses as something else.
|
||||
*
|
||||
* @param secret Base32 secret, as returned by `generateTotpSecret()`
|
||||
* @param account Who the code belongs to, i.e. the user's email
|
||||
* @param issuer What it logs into, i.e. the site title
|
||||
*/
|
||||
export function buildTotpUri({
|
||||
secret,
|
||||
account,
|
||||
issuer
|
||||
}: {
|
||||
secret: string
|
||||
account: string
|
||||
issuer: string
|
||||
}): string {
|
||||
const label = encodeURIComponent(`${issuer}:${account}`)
|
||||
const params = new URLSearchParams({
|
||||
secret,
|
||||
issuer,
|
||||
algorithm: 'SHA1',
|
||||
digits: String(codeDigits),
|
||||
period: String(periodSeconds)
|
||||
})
|
||||
return `otpauth://totp/${label}?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a code is one the secret currently produces, allowing for clock drift.
|
||||
*
|
||||
* Compared byte-wise in constant time. That matters less here than for a password — a wrong code is
|
||||
* one of a million and expires in seconds — but the comparison is free to get right.
|
||||
*
|
||||
* @param secret Base32 secret stored for the user
|
||||
* @param code The six digits the user typed
|
||||
* @returns False for anything that is not six digits, or for a secret that will not decode
|
||||
*/
|
||||
export function verifyTotpCode(secret: string, code: string): boolean {
|
||||
if (!secret || !/^[0-9]{6}$/.test(code)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let secretKey: Buffer
|
||||
try {
|
||||
secretKey = base32Decode(secret)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (secretKey.length < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expected = Buffer.from(code, 'utf8')
|
||||
const counter = Math.floor(Date.now() / 1000 / periodSeconds)
|
||||
let matched = false
|
||||
for (let drift = -allowedDrift; drift <= allowedDrift; drift++) {
|
||||
// -> Every candidate is compared, rather than returning on the first hit, so that the work done
|
||||
// does not depend on which window the code came from
|
||||
if (timingSafeEqual(Buffer.from(codeAt(secretKey, counter + drift), 'utf8'), expected)) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
@ -0,0 +1,384 @@
|
||||
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
|
||||
} 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. */
|
||||
export interface ApprovalPageRef {
|
||||
id: string
|
||||
path: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async getRules(siteId: string): Promise<ApprovalRule[]> {
|
||||
return WIKI.db
|
||||
.select(ruleSelection)
|
||||
.from(approvalRulesTable)
|
||||
.where(eq(approvalRulesTable.siteId, siteId))
|
||||
.orderBy(
|
||||
asc(sql`lower(${approvalRulesTable.name})`),
|
||||
asc(approvalRulesTable.createdAt)
|
||||
) as Promise<ApprovalRule[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
path: patch.path ?? '',
|
||||
submitterGroups: patch.submitterGroups ?? [],
|
||||
reviewerGroups: patch.reviewerGroups ?? []
|
||||
})
|
||||
.returning(ruleSelection)
|
||||
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) {
|
||||
values[key] = patch[key]
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await WIKI.db
|
||||
.update(approvalRulesTable)
|
||||
.set(values)
|
||||
.where(and(eq(approvalRulesTable.siteId, siteId), eq(approvalRulesTable.id, id)))
|
||||
.returning(ruleSelection)
|
||||
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: ApprovalPageRef): 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.
|
||||
*
|
||||
* @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) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)))
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
export const approvals = new Approvals()
|
||||
@ -0,0 +1,467 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { users as usersTable } from '../db/schema.ts'
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON
|
||||
} from '@simplewebauthn/server'
|
||||
import type { AfterLoginResult } from './users.ts'
|
||||
|
||||
/**
|
||||
* One registered authenticator, as stored in the user's `passkeys` blob. Every binary value is held
|
||||
* base64url-encoded, since this lives in a JSONB column.
|
||||
*/
|
||||
interface StoredPasskey {
|
||||
/** The credential ID, which is also what the browser sends back to identify it. */
|
||||
id: string
|
||||
name: string
|
||||
/** COSE public key, base64url-encoded. */
|
||||
publicKey: string
|
||||
/** Signature counter last reported by the authenticator, for replay detection. */
|
||||
counter: number
|
||||
transports?: AuthenticatorTransportFuture[]
|
||||
createdAt: string
|
||||
siteId: string
|
||||
/** The hostname the credential is bound to. A passkey only works on the site it was created on. */
|
||||
rpId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A ceremony waiting to be answered. Held on the session between the two requests a ceremony takes —
|
||||
* see the note on `Session.passkeyLogin` in `types/fastify.d.ts` for why it cannot live anywhere else.
|
||||
*/
|
||||
export interface PasskeyChallenge {
|
||||
challenge: string
|
||||
rpId: string
|
||||
origin: string
|
||||
siteId: string
|
||||
}
|
||||
|
||||
/** What a user's `passkeys` column holds: the credentials themselves, and nothing transient. */
|
||||
interface PasskeyStore {
|
||||
authenticators?: StoredPasskey[]
|
||||
}
|
||||
|
||||
/** A passkey as the profile page lists it — never the key material. */
|
||||
export interface PasskeyInfo {
|
||||
id: string
|
||||
name: string
|
||||
siteHostname: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostnames a browser treats as a secure context without TLS, so that `http://localhost:3001` — the
|
||||
* dev server — is a usable origin. Anything else has to be https, which is a WebAuthn requirement
|
||||
* rather than a choice made here.
|
||||
*/
|
||||
const insecureOriginExceptions = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
||||
|
||||
/**
|
||||
* The origin a passkey ceremony must be performed on.
|
||||
*
|
||||
* Taken from the request's own `Origin` header rather than assembled from the hostname, because the
|
||||
* port is part of an origin and this instance does not know which one the browser reached it on. That
|
||||
* is safe because the header is only trusted as far as it agrees with the host the request was
|
||||
* addressed to: a page on another origin posting here would disagree, and is rejected. What the
|
||||
* header cannot establish is that the connection was secure, so that is checked separately.
|
||||
*
|
||||
* @param origin The `Origin` header, if the client sent one
|
||||
* @param hostname The host the request was addressed to, i.e. the RP ID
|
||||
* @throws `ERR_PK_INSECURE_ORIGIN` for an origin that does not match, or that is not a secure context
|
||||
*/
|
||||
function resolveOrigin(origin: string | undefined, hostname: string): string {
|
||||
// -> A client that sends no Origin at all is not a browser doing a WebAuthn ceremony, but it may
|
||||
// still be a legitimate API client driving one, so the canonical https origin is assumed
|
||||
if (!origin) {
|
||||
return `https://${hostname}`
|
||||
}
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(origin)
|
||||
} catch {
|
||||
throw new Error('ERR_PK_INSECURE_ORIGIN')
|
||||
}
|
||||
if (parsed.hostname !== hostname) {
|
||||
throw new Error('ERR_PK_INSECURE_ORIGIN')
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && !insecureOriginExceptions.has(parsed.hostname)) {
|
||||
throw new Error('ERR_PK_INSECURE_ORIGIN')
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
/**
|
||||
* Passkeys (WebAuthn) model
|
||||
*
|
||||
* Credentials are stored in the user's `passkeys` JSONB column rather than a table of their own: they
|
||||
* are only ever read for one user at a time, and they die with the account.
|
||||
*/
|
||||
class Passkeys {
|
||||
/**
|
||||
* The passkeys registered by a user, as the profile page lists them.
|
||||
*/
|
||||
async list(userId: string): Promise<PasskeyInfo[]> {
|
||||
const store = await this.getStore(userId)
|
||||
return (store.authenticators ?? []).map((pk) => ({
|
||||
id: pk.id,
|
||||
name: pk.name,
|
||||
// -> The hostname it was registered against, not the site's current one: that is what the
|
||||
// credential is actually bound to, and renaming a site does not move it
|
||||
siteHostname: pk.rpId,
|
||||
createdAt: pk.createdAt
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering a new passkey.
|
||||
*
|
||||
* @param userId The user registering it, who must be logged in
|
||||
* @param hostname The host being browsed, which becomes the RP ID the credential is bound to
|
||||
* @param origin The request's `Origin` header
|
||||
* @returns The options to hand the browser, and the challenge to remember for
|
||||
* `finalizeRegistration()`
|
||||
* @throws `ERR_INVALID_USER`, `ERR_PK_HOSTNAME_MISSING` or `ERR_PK_INSECURE_ORIGIN`
|
||||
*/
|
||||
async startRegistration({
|
||||
userId,
|
||||
hostname,
|
||||
origin
|
||||
}: {
|
||||
userId: string
|
||||
hostname: string
|
||||
origin?: string
|
||||
}): Promise<{
|
||||
registrationOptions: PublicKeyCredentialCreationOptionsJSON
|
||||
pending: PasskeyChallenge
|
||||
}> {
|
||||
const user = await WIKI.models.users.getById(userId)
|
||||
if (!user) {
|
||||
throw new Error('ERR_INVALID_USER')
|
||||
}
|
||||
if (!hostname || hostname === '*') {
|
||||
throw new Error('ERR_PK_HOSTNAME_MISSING')
|
||||
}
|
||||
const expectedOrigin = resolveOrigin(origin, hostname)
|
||||
|
||||
const site = await WIKI.models.sites.getSiteByHostname({ hostname })
|
||||
const store = (user.passkeys ?? {}) as PasskeyStore
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: site?.config?.title || 'Wiki',
|
||||
rpID: hostname,
|
||||
// -> The user handle comes back on login as the only clue to who is signing in, so it is the
|
||||
// user ID itself rather than a random value that would need a second lookup table
|
||||
userID: new TextEncoder().encode(user.id),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
// -> Every credential the user already has, so the authenticator can refuse to enroll twice and
|
||||
// the browser can say so before anything is stored
|
||||
excludeCredentials: (store.authenticators ?? []).map((pk) => ({
|
||||
id: pk.id,
|
||||
transports: pk.transports
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
registrationOptions: options,
|
||||
pending: {
|
||||
challenge: options.challenge,
|
||||
rpId: hostname,
|
||||
origin: expectedOrigin,
|
||||
siteId: site?.id ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify what the authenticator produced and store the credential under the given name.
|
||||
*
|
||||
* @param pending The challenge `startRegistration()` handed out, off the session
|
||||
* @throws `ERR_INVALID_USER`, `ERR_PASSKEY_NOT_SETUP`, `ERR_PK_NAME_MISSING_OR_INVALID`,
|
||||
* `ERR_PK_ALREADY_REGISTERED` or `ERR_PK_VERIFICATION_FAILED`
|
||||
*/
|
||||
async finalizeRegistration({
|
||||
userId,
|
||||
name,
|
||||
registrationResponse,
|
||||
pending
|
||||
}: {
|
||||
userId: string
|
||||
name: string
|
||||
registrationResponse: RegistrationResponseJSON
|
||||
pending?: PasskeyChallenge
|
||||
}): Promise<PasskeyInfo> {
|
||||
const user = await WIKI.models.users.getById(userId)
|
||||
if (!user) {
|
||||
throw new Error('ERR_INVALID_USER')
|
||||
}
|
||||
if (!pending) {
|
||||
throw new Error('ERR_PASSKEY_NOT_SETUP')
|
||||
}
|
||||
const store = (user.passkeys ?? {}) as PasskeyStore
|
||||
const trimmedName = (name ?? '').trim()
|
||||
if (trimmedName.length < 1 || trimmedName.length > 255) {
|
||||
throw new Error('ERR_PK_NAME_MISSING_OR_INVALID')
|
||||
}
|
||||
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: pending.challenge,
|
||||
expectedOrigin: pending.origin,
|
||||
expectedRPID: pending.rpId,
|
||||
// -> Matches the `preferred` asked for above: an authenticator that has no way to verify the
|
||||
// user is still worth registering, and requiring it here would reject exactly those
|
||||
requireUserVerification: false
|
||||
})
|
||||
} catch (err: any) {
|
||||
WIKI.models.flags.authDebug(
|
||||
`Passkey registration for user ${user.id} failed verification: ${err.message}`
|
||||
)
|
||||
throw new Error('ERR_PK_VERIFICATION_FAILED')
|
||||
}
|
||||
if (!verification.verified) {
|
||||
throw new Error('ERR_PK_VERIFICATION_FAILED')
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo
|
||||
const authenticators = store.authenticators ?? []
|
||||
if (authenticators.some((pk) => pk.id === credential.id)) {
|
||||
throw new Error('ERR_PK_ALREADY_REGISTERED')
|
||||
}
|
||||
|
||||
const passkey: StoredPasskey = {
|
||||
id: credential.id,
|
||||
name: trimmedName,
|
||||
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: registrationResponse.response.transports,
|
||||
createdAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }),
|
||||
siteId: pending.siteId,
|
||||
rpId: pending.rpId
|
||||
}
|
||||
|
||||
await this.saveStore(user.id, { authenticators: [...authenticators, passkey] })
|
||||
|
||||
WIKI.models.flags.authDebug(
|
||||
`User ${user.id} <${user.email}> registered passkey "${trimmedName}" on ${pending.rpId}`
|
||||
)
|
||||
|
||||
return {
|
||||
id: passkey.id,
|
||||
name: passkey.name,
|
||||
siteHostname: passkey.rpId,
|
||||
createdAt: passkey.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a passkey. The credential itself lives on the user's device and has to be removed there
|
||||
* too, which is what the client says when this succeeds.
|
||||
*
|
||||
* @returns False if the user has no such passkey
|
||||
*/
|
||||
async remove(userId: string, passkeyId: string): Promise<boolean> {
|
||||
const store = await this.getStore(userId)
|
||||
const authenticators = store.authenticators ?? []
|
||||
const remaining = authenticators.filter((pk) => pk.id !== passkeyId)
|
||||
if (remaining.length === authenticators.length) {
|
||||
return false
|
||||
}
|
||||
await this.saveStore(userId, { ...store, authenticators: remaining })
|
||||
WIKI.models.flags.authDebug(`User ${userId} removed a passkey`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for logging in with a passkey.
|
||||
*
|
||||
* Nobody is named here, and no `allowCredentials` list is sent: every passkey is registered as a
|
||||
* discoverable credential, so the authenticator offers whichever ones it holds for this host and the
|
||||
* assertion says who signed. That is what makes a passkey login one gesture — there is nothing to ask
|
||||
* the user first, and no lookup that could reveal whether an address has an account.
|
||||
*
|
||||
* @returns The options to hand the browser, and the challenge to remember for `verifyLogin()`
|
||||
* @throws `ERR_PK_HOSTNAME_MISSING` or `ERR_PK_INSECURE_ORIGIN`
|
||||
*/
|
||||
async startLogin({ hostname, origin }: { hostname: string; origin?: string }): Promise<{
|
||||
authOptions: PublicKeyCredentialRequestOptionsJSON
|
||||
pending: PasskeyChallenge
|
||||
}> {
|
||||
if (!hostname || hostname === '*') {
|
||||
throw new Error('ERR_PK_HOSTNAME_MISSING')
|
||||
}
|
||||
const expectedOrigin = resolveOrigin(origin, hostname)
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: hostname,
|
||||
userVerification: 'preferred'
|
||||
})
|
||||
|
||||
return {
|
||||
authOptions: options,
|
||||
pending: {
|
||||
challenge: options.challenge,
|
||||
rpId: hostname,
|
||||
origin: expectedOrigin,
|
||||
siteId: (await WIKI.models.sites.getSiteByHostname({ hostname }))?.id ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a passkey login and, if it holds up, log the user in.
|
||||
*
|
||||
* A passkey establishes both who the user is and that they were present, so this does not go on to
|
||||
* ask for a password or a 2FA code. The account checks the password strategy performs still apply —
|
||||
* a deactivated account cannot be signed into with a key either.
|
||||
*
|
||||
* Who signed comes out of the assertion's user handle, which is the only way this can work: the
|
||||
* challenge was handed out before anyone was named.
|
||||
*
|
||||
* @param pending The challenge `startLogin()` handed out, off the session
|
||||
* @returns The same shape a password login returns, so the client handles both the same way
|
||||
* @throws `ERR_LOGIN_FAILED`, `ERR_INACTIVE_USER` or `ERR_USER_NOT_VERIFIED`
|
||||
*/
|
||||
async verifyLogin(
|
||||
{
|
||||
authResponse,
|
||||
pending,
|
||||
ip
|
||||
}: {
|
||||
authResponse: AuthenticationResponseJSON
|
||||
pending?: PasskeyChallenge
|
||||
ip?: string
|
||||
},
|
||||
req: any
|
||||
): Promise<AfterLoginResult> {
|
||||
if (!pending) {
|
||||
WIKI.models.flags.authDebug(
|
||||
'Passkey login rejected: no challenge outstanding on this session'
|
||||
)
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
|
||||
const userHandle = authResponse.response?.userHandle
|
||||
if (!userHandle) {
|
||||
WIKI.models.flags.authDebug('Passkey login rejected: the response carried no user handle')
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
|
||||
// -> The handle is the user ID this server encoded at registration, so anything else is not a
|
||||
// credential of ours
|
||||
let userId: string
|
||||
try {
|
||||
userId = isoBase64URL.toUTF8String(userHandle)
|
||||
} catch {
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
|
||||
const user = await WIKI.models.users.getById(userId)
|
||||
if (!user) {
|
||||
WIKI.models.flags.authDebug(`Passkey login rejected: no user ${userId}`)
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
const store = (user.passkeys ?? {}) as PasskeyStore
|
||||
const passkey = (store.authenticators ?? []).find((pk) => pk.id === authResponse.id)
|
||||
if (!passkey) {
|
||||
WIKI.models.flags.authDebug(
|
||||
`Passkey login rejected: credential ${authResponse.id} is not registered for user ${userId}`
|
||||
)
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: authResponse,
|
||||
expectedChallenge: pending.challenge,
|
||||
expectedOrigin: pending.origin,
|
||||
expectedRPID: pending.rpId,
|
||||
// -> As at registration: the ceremony asked for `preferred`, so requiring it here would turn
|
||||
// an authenticator that cannot verify into a login that never succeeds
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: passkey.id,
|
||||
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
WIKI.models.flags.authDebug(
|
||||
`Passkey login for user ${userId} failed to verify: ${err.message}`
|
||||
)
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
if (!verification.verified) {
|
||||
throw new Error('ERR_LOGIN_FAILED')
|
||||
}
|
||||
|
||||
// -> The counter has to be stored for the replay check to mean anything next time
|
||||
await this.saveStore(user.id, {
|
||||
authenticators: (store.authenticators ?? []).map((pk) =>
|
||||
pk.id === passkey.id ? { ...pk, counter: verification.authenticationInfo.newCounter } : pk
|
||||
)
|
||||
})
|
||||
|
||||
// -> Checks the password strategy would have made, which a passkey login would otherwise skip
|
||||
if (!user.isActive) {
|
||||
throw new Error('ERR_INACTIVE_USER')
|
||||
}
|
||||
if (!user.isVerified) {
|
||||
throw new Error('ERR_USER_NOT_VERIFIED')
|
||||
}
|
||||
|
||||
WIKI.models.flags.authDebug(
|
||||
`User ${user.id} <${user.email}> authenticated with passkey "${passkey.name}"`
|
||||
)
|
||||
|
||||
// -> Attributed to the local strategy, which is where an account's own credentials belong. Neither
|
||||
// a password change nor a 2FA code is asked for on top of a passkey.
|
||||
return WIKI.models.users.afterLoginChecks(
|
||||
user,
|
||||
WIKI.data.systemIds.localAuthId,
|
||||
{ ip, siteId: pending.siteId },
|
||||
{ skipTFA: true, skipChangePwd: true },
|
||||
req
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored blob for a user, or an empty one for a user who has never registered a passkey.
|
||||
*/
|
||||
async getStore(userId: string): Promise<PasskeyStore> {
|
||||
const user = await WIKI.models.users.getById(userId)
|
||||
return (user?.passkeys ?? {}) as PasskeyStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a user's stored passkey blob.
|
||||
*/
|
||||
async saveStore(userId: string, store: PasskeyStore): Promise<void> {
|
||||
await WIKI.db
|
||||
.update(usersTable)
|
||||
.set({ passkeys: { authenticators: store.authenticators ?? [] }, updatedAt: sql`now()` })
|
||||
.where(eq(usersTable.id, userId))
|
||||
}
|
||||
}
|
||||
|
||||
export const passkeys = new Passkeys()
|
||||
@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card style="min-width: 650px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-inspection.svg" size="sm" class="mr-2" />
|
||||
<span>{{ isEdit ? t('admin.approval.editRule') : t('admin.approval.newRule') }}</span>
|
||||
</w-card-section>
|
||||
<w-form ref="ruleForm" class="py-2" @submit="save">
|
||||
<w-item>
|
||||
<blueprint-icon icon="rename" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-input
|
||||
ref="iptName"
|
||||
v-model="state.name"
|
||||
outlined
|
||||
dense
|
||||
:rules="nameValidation"
|
||||
hide-bottom-space
|
||||
:label="t(`admin.approval.name`)"
|
||||
:hint="t(`admin.approval.nameHint`)"
|
||||
lazy-rules="ondemand"
|
||||
autofocus />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<blueprint-icon icon="filtration" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-select
|
||||
v-model="state.match"
|
||||
outlined
|
||||
dense
|
||||
:options="matchOptions"
|
||||
map-options
|
||||
emit-value
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
options-dense
|
||||
hide-bottom-space
|
||||
:label="t(`admin.approval.match`)"
|
||||
:hint="t(`admin.approval.matchHint`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<blueprint-icon :icon="isTagMatch ? `flag-filled` : `link`" class="self-start" />
|
||||
<w-item-section>
|
||||
<!--
|
||||
One field for both kinds of pattern: a tag mode takes a list of tags rather than a path,
|
||||
so only its label, hint and rule change.
|
||||
-->
|
||||
<w-input
|
||||
v-model="state.path"
|
||||
outlined
|
||||
dense
|
||||
:prefix="isTagMatch ? null : `/`"
|
||||
:suffix="state.match === `REGEX` ? `/` : null"
|
||||
:rules="pathValidation"
|
||||
hide-bottom-space
|
||||
:label="isTagMatch ? t(`admin.approval.tags`) : t(`admin.approval.path`)"
|
||||
:hint="isTagMatch ? t(`admin.approval.tagsHint`) : t(`admin.approval.pathHint`)"
|
||||
lazy-rules="ondemand" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="pen" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-select
|
||||
v-model="state.submitterGroups"
|
||||
outlined
|
||||
dense
|
||||
:options="props.groups"
|
||||
multiple
|
||||
map-options
|
||||
emit-value
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
options-dense
|
||||
:rules="groupsValidation(t(`admin.approval.submittersRequired`))"
|
||||
hide-bottom-space
|
||||
:label="t(`admin.approval.submitters`)"
|
||||
:hint="t(`admin.approval.submittersHint`)"
|
||||
lazy-rules="ondemand" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<blueprint-icon icon="validation" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-select
|
||||
v-model="state.reviewerGroups"
|
||||
outlined
|
||||
dense
|
||||
:options="props.groups"
|
||||
multiple
|
||||
map-options
|
||||
emit-value
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
options-dense
|
||||
:rules="groupsValidation(t(`admin.approval.reviewersRequired`))"
|
||||
hide-bottom-space
|
||||
:label="t(`admin.approval.reviewers`)"
|
||||
:hint="t(`admin.approval.reviewersHint`)"
|
||||
lazy-rules="ondemand" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-form>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="isEdit ? t(`common.actions.save`) : t(`common.actions.create`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:loading="state.isLoading"
|
||||
@click="save" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
siteId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** The rule being edited, or null to create one. */
|
||||
rule: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
/** The groups to choose from, loaded once by the page rather than per dialog. */
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
|
||||
autofocus: () => iptName.value
|
||||
})
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
name: props.rule?.name ?? '',
|
||||
match: props.rule?.match ?? 'START',
|
||||
path: props.rule?.path ?? '',
|
||||
submitterGroups: [...(props.rule?.submitterGroups ?? [])],
|
||||
reviewerGroups: [...(props.rule?.reviewerGroups ?? [])],
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
// REFS
|
||||
|
||||
const ruleForm = ref(null)
|
||||
const iptName = ref(null)
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const isEdit = computed(() => Boolean(props.rule))
|
||||
|
||||
const isTagMatch = computed(() => ['TAG', 'TAGALL'].includes(state.match))
|
||||
|
||||
const matchOptions = computed(() => [
|
||||
{ label: t('admin.approval.matchStart'), value: 'START' },
|
||||
{ label: t('admin.approval.matchExact'), value: 'EXACT' },
|
||||
{ label: t('admin.approval.matchEnd'), value: 'END' },
|
||||
{ label: t('admin.approval.matchRegex'), value: 'REGEX' },
|
||||
{ label: t('admin.approval.matchTag'), value: 'TAG' },
|
||||
{ label: t('admin.approval.matchTagAll'), value: 'TAGALL' }
|
||||
])
|
||||
|
||||
// VALIDATION RULES
|
||||
|
||||
const nameValidation = [(val) => (val ?? '').trim().length > 0 || t('admin.approval.nameRequired')]
|
||||
|
||||
const pathValidation = [
|
||||
(val) =>
|
||||
(val ?? '').trim().length > 0 ||
|
||||
(isTagMatch.value ? t('admin.approval.tagsRequired') : t('admin.approval.pathRequired')),
|
||||
// -> Caught here as well as by the server: a pattern that cannot compile is a rule that silently
|
||||
// covers nothing, and the message is far more useful next to the field
|
||||
(val) => {
|
||||
if (state.match !== 'REGEX') {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
new RegExp(val)
|
||||
return true
|
||||
} catch (err) {
|
||||
return t('admin.approval.pathInvalidRegex', { reason: err.message })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const groupsValidation = (message) => [(val) => (val ?? []).length > 0 || message]
|
||||
|
||||
// METHODS
|
||||
|
||||
async function save() {
|
||||
state.isLoading = true
|
||||
try {
|
||||
const isFormValid = await ruleForm.value.validate(true)
|
||||
if (!isFormValid) {
|
||||
throw new Error(t('admin.approval.formInvalid'))
|
||||
}
|
||||
|
||||
// -> `isEnabled` is deliberately absent: the list row owns that switch, so a rule saved here keeps
|
||||
// whatever state it already had, and a new one starts enabled
|
||||
const payload = {
|
||||
name: state.name.trim(),
|
||||
match: state.match,
|
||||
path: state.path.trim(),
|
||||
submitterGroups: state.submitterGroups,
|
||||
reviewerGroups: state.reviewerGroups
|
||||
}
|
||||
const resp = isEdit.value
|
||||
? await API_CLIENT.put(`sites/${props.siteId}/approvals/rules/${props.rule.id}`, {
|
||||
json: payload
|
||||
}).json()
|
||||
: await API_CLIENT.post(`sites/${props.siteId}/approvals/rules`, { json: payload }).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: isEdit.value ? t('admin.approval.updateSuccess') : t('admin.approval.createSuccess')
|
||||
})
|
||||
onDialogOK(resp.rule)
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message:
|
||||
(await err.response
|
||||
?.json()
|
||||
.then((b) => b?.message)
|
||||
.catch(() => null)) ?? err.message
|
||||
})
|
||||
}
|
||||
state.isLoading = false
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card style="min-width: 550px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-inspection.svg" size="sm" class="mr-2" />
|
||||
<span>{{ t(`common.page.suggestIdentifyTitle`) }}</span>
|
||||
</w-card-section>
|
||||
<w-card-section>
|
||||
<div class="text-body2">{{ t('common.page.suggestIdentifyHint') }}</div>
|
||||
</w-card-section>
|
||||
<w-form ref="guestForm" class="py-2" @submit="submit">
|
||||
<w-item>
|
||||
<blueprint-icon icon="contact" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-input
|
||||
ref="iptName"
|
||||
v-model="state.name"
|
||||
outlined
|
||||
dense
|
||||
:rules="nameValidation"
|
||||
hide-bottom-space
|
||||
:label="t(`common.page.suggestName`)"
|
||||
autocomplete="name"
|
||||
lazy-rules="ondemand"
|
||||
autofocus />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<blueprint-icon icon="envelope" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-input
|
||||
v-model="state.email"
|
||||
outlined
|
||||
dense
|
||||
type="email"
|
||||
:rules="emailValidation"
|
||||
hide-bottom-space
|
||||
:label="t(`common.page.suggestEmail`)"
|
||||
:hint="t(`common.page.suggestEmailHint`)"
|
||||
autocomplete="email"
|
||||
lazy-rules="ondemand" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-form>
|
||||
<w-card-actions class="card-actions">
|
||||
<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.submitEdits`)"
|
||||
color="positive"
|
||||
padding="xs md"
|
||||
@click="submit" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
|
||||
/**
|
||||
* Who is suggesting this edit, asked of a reader with no account.
|
||||
*
|
||||
* A logged in author is already known, so this never opens for one. For everybody else it is the only
|
||||
* record of where the suggestion came from, which is what lets a reviewer answer them.
|
||||
*/
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
|
||||
autofocus: () => iptName.value
|
||||
})
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
// REFS
|
||||
|
||||
const guestForm = ref(null)
|
||||
const iptName = ref(null)
|
||||
|
||||
// VALIDATION RULES
|
||||
|
||||
const nameValidation = [(val) => (val ?? '').trim().length > 0 || t('auth.errors.missingName')]
|
||||
|
||||
const emailValidation = [
|
||||
(val) => (val ?? '').trim().length > 0 || t('auth.errors.missingEmail'),
|
||||
(val) => /^.+@.+\..+$/.test(val) || t('auth.errors.invalidEmail')
|
||||
]
|
||||
|
||||
// METHODS
|
||||
|
||||
async function submit() {
|
||||
if (!(await guestForm.value.validate(true))) {
|
||||
return
|
||||
}
|
||||
onDialogOK({ guestName: state.name.trim(), guestEmail: state.email.trim() })
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<w-page>
|
||||
<div class="flex flex-wrap items-center p-4">
|
||||
<div class="flex-none">
|
||||
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-inspection.svg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pl-4">
|
||||
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.approval.title') }}</div>
|
||||
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
|
||||
{{ t('admin.approval.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-none">
|
||||
<w-btn
|
||||
class="mr-2 acrylic-btn"
|
||||
icon="la:question-circle"
|
||||
flat
|
||||
color="grey"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin/approvals`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="acrylic-btn mr-2"
|
||||
icon="la:redo-alt"
|
||||
flat
|
||||
color="secondary"
|
||||
:loading="state.loading > 0"
|
||||
:aria-label="t(`common.actions.refresh`)"
|
||||
@click="load">
|
||||
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
unelevated
|
||||
icon="la:plus"
|
||||
:label="t(`admin.approval.newRule`)"
|
||||
color="primary"
|
||||
@click="createRule" />
|
||||
</div>
|
||||
</div>
|
||||
<w-separator inset />
|
||||
<div class="p-4">
|
||||
<!--
|
||||
An empty list is the normal starting state rather than an error, and it is worth saying what
|
||||
it means: with no rule matching a page, that page takes no suggestions at all.
|
||||
-->
|
||||
<w-banner
|
||||
v-if="state.rules.length < 1 && state.loading < 1"
|
||||
rounded
|
||||
:class="dark.isActive ? `bg-dark-3 text-grey-4` : `bg-grey-2 text-grey-8`">
|
||||
{{ t('admin.approval.noRules') }}
|
||||
</w-banner>
|
||||
<w-card v-else>
|
||||
<w-list separator>
|
||||
<w-item v-for="rule of state.rules" :key="rule.id">
|
||||
<blueprint-icon icon="rules" />
|
||||
<!--
|
||||
A disabled rule keeps everything it says but covers nothing, so it is dimmed rather than
|
||||
hidden or moved: it is still part of the configuration being read.
|
||||
-->
|
||||
<w-item-section :class="rule.isEnabled ? `` : `opacity-60`">
|
||||
<w-item-label>
|
||||
<strong>{{ rule.name }}</strong>
|
||||
</w-item-label>
|
||||
<w-item-label caption>
|
||||
{{ matchLabel(rule.match) }}
|
||||
<span class="font-mono">{{ patternLabel(rule) }}</span>
|
||||
</w-item-label>
|
||||
<w-item-label caption>
|
||||
<span class="text-grey">{{ t('admin.approval.submitters') }}:</span>
|
||||
{{ groupNames(rule.submitterGroups) }}
|
||||
</w-item-label>
|
||||
<w-item-label caption>
|
||||
<span class="text-grey">{{ t('admin.approval.reviewers') }}:</span>
|
||||
{{ groupNames(rule.reviewerGroups) }}
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
:model-value="rule.isEnabled"
|
||||
:label="t(`admin.approval.enabled`)"
|
||||
:aria-label="t(`admin.approval.enabled`)"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
setEnabled(rule, val)
|
||||
}
|
||||
" />
|
||||
</w-item-section>
|
||||
<w-separator class="ml-4" vertical />
|
||||
<w-item-section side style="flex-direction: row; align-items: center">
|
||||
<w-btn
|
||||
class="acrylic-btn mr-2"
|
||||
flat
|
||||
@click="editRule(rule)"
|
||||
icon="la:pen"
|
||||
:color="dark.isActive ? `indigo-4` : `indigo`"
|
||||
:label="t(`common.actions.edit`)"
|
||||
no-caps />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
icon="la:trash"
|
||||
color="negative"
|
||||
@click="deleteRule(rule)"
|
||||
:aria-label="t(`common.actions.delete`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</w-card>
|
||||
</div>
|
||||
<w-inner-loading :showing="state.loading > 0" />
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, reactive, watch } from 'vue'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { confirm, dialog } from '@/composables/dialog'
|
||||
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
import ApprovalRuleDialog from '@/components/ApprovalRuleDialog.vue'
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// STORES
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
title: t('admin.approval.title')
|
||||
})
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
loading: 0,
|
||||
rules: [],
|
||||
groups: []
|
||||
})
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(() => adminStore.currentSiteId, load)
|
||||
|
||||
// METHODS
|
||||
|
||||
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
|
||||
async function apiMessage(err) {
|
||||
return (
|
||||
(await err.response
|
||||
?.json()
|
||||
.then((b) => b?.message)
|
||||
.catch(() => null)) ?? err.message
|
||||
)
|
||||
}
|
||||
|
||||
function matchLabel(match) {
|
||||
return (
|
||||
{
|
||||
START: t('admin.approval.matchStart'),
|
||||
EXACT: t('admin.approval.matchExact'),
|
||||
END: t('admin.approval.matchEnd'),
|
||||
REGEX: t('admin.approval.matchRegex'),
|
||||
TAG: t('admin.approval.matchTag'),
|
||||
TAGALL: t('admin.approval.matchTagAll')
|
||||
}[match] ?? match
|
||||
)
|
||||
}
|
||||
|
||||
/** The pattern as it is written in the rule: a path with its slash, or a plain list of tags. */
|
||||
function patternLabel(rule) {
|
||||
if (['TAG', 'TAGALL'].includes(rule.match)) {
|
||||
return rule.path
|
||||
}
|
||||
return rule.match === 'REGEX' ? `/${rule.path}/` : `/${rule.path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Group names for a list of IDs.
|
||||
*
|
||||
* An ID with no group left to name is shown as-is rather than dropped: a rule pointing at a deleted
|
||||
* group grants nothing, and hiding that would make the row look correct.
|
||||
*/
|
||||
function groupNames(groupIds) {
|
||||
return (groupIds ?? []).map((id) => state.groups.find((g) => g.id === id)?.name ?? id).join(', ')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!adminStore.currentSiteId) {
|
||||
return
|
||||
}
|
||||
state.loading++
|
||||
try {
|
||||
// -> The groups are what turn the stored IDs into names, so both are needed before the list means
|
||||
// anything; fetched together rather than in sequence
|
||||
const [rules, groups] = await Promise.all([
|
||||
API_CLIENT.get(`sites/${adminStore.currentSiteId}/approvals/rules`).json(),
|
||||
API_CLIENT.get('groups').json()
|
||||
])
|
||||
state.rules = rules ?? []
|
||||
state.groups = groups ?? []
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.approval.loadFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a rule on or off, saved as soon as the switch moves.
|
||||
*
|
||||
* The row is updated from the response rather than optimistically: a refused change has to leave the
|
||||
* switch showing what the server actually holds.
|
||||
*/
|
||||
async function setEnabled(rule, isEnabled) {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.put(
|
||||
`sites/${adminStore.currentSiteId}/approvals/rules/${rule.id}`,
|
||||
{ json: { isEnabled } }
|
||||
).json()
|
||||
if (!resp?.ok) {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
Object.assign(rule, resp.rule)
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: isEnabled ? t('admin.approval.enableSuccess') : t('admin.approval.disableSuccess')
|
||||
})
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.approval.saveFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
await load()
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
function createRule() {
|
||||
dialog({
|
||||
component: ApprovalRuleDialog,
|
||||
componentProps: {
|
||||
siteId: adminStore.currentSiteId,
|
||||
groups: state.groups
|
||||
}
|
||||
}).onOk(load)
|
||||
}
|
||||
|
||||
function editRule(rule) {
|
||||
dialog({
|
||||
component: ApprovalRuleDialog,
|
||||
componentProps: {
|
||||
siteId: adminStore.currentSiteId,
|
||||
groups: state.groups,
|
||||
rule
|
||||
}
|
||||
}).onOk(load)
|
||||
}
|
||||
|
||||
function deleteRule(rule) {
|
||||
confirm({
|
||||
title: t('admin.approval.deleteRule'),
|
||||
message: t('admin.approval.deleteRuleConfirm', {
|
||||
pattern: `${matchLabel(rule.match)} ${patternLabel(rule)}`
|
||||
}),
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
okLabel: t('common.actions.delete')
|
||||
}).onOk(async () => {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.delete(
|
||||
`sites/${adminStore.currentSiteId}/approvals/rules/${rule.id}`
|
||||
)
|
||||
if (!resp?.ok) {
|
||||
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.approval.deleteSuccess')
|
||||
})
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.approval.deleteFailed'),
|
||||
caption: await apiMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
await load()
|
||||
})
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Loading…
Reference in new issue