feat: inbox view + approval review + page history (wip)

scarlett
NGPixel 1 month ago
parent 957efebecb
commit c36eab6729
No known key found for this signature in database

@ -24,6 +24,17 @@ async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId:
})
}
/**
* Who is reviewing, as the rules see them: the groups on their session, plus whether they hold
* `manage:system` which sees every queue, here as everywhere else.
*/
function reviewerFor(req: FastifyRequest): { groupIds: string[]; isAdmin: boolean } {
return {
groupIds: WIKI.models.approvals.getActorGroupIds(req),
isAdmin: Boolean(req.session?.permissions?.includes('manage:system'))
}
}
/**
* Everything a rule has to satisfy beyond what the JSON Schema already enforces.
*
@ -340,6 +351,203 @@ async function routes(app: FastifyInstance) {
}
)
/**
* LIST SUGGESTIONS WAITING ON THIS REVIEWER
*/
app.get<{ Params: { siteId: string } }>(
'/sites/:siteId/approvals/submissions',
{
schema: {
summary: 'List the edit suggestions waiting for the caller to review',
description:
'Scoped by the approval rules: a suggestion appears here when an enabled rule covers its page and names a group the caller is in. Oldest first, which is the order a queue is worked through. `manage:system` sees the whole sites queue.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' }
},
required: ['siteId']
},
response: {
200: {
description: 'Suggestions awaiting review',
type: 'array',
items: { $ref: 'PageEditSubmission#' }
}
}
}
},
async (req, reply) => {
reply.preventCache()
return WIKI.models.approvals.getReviewableSubmissions(req.params.siteId, reviewerFor(req))
}
)
/**
* GET ONE SUGGESTION TO REVIEW
*/
app.get<{ Params: { siteId: string; submissionId: string } }>(
'/sites/:siteId/approvals/submissions/:submissionId',
{
schema: {
summary: 'Get an edit suggestion, with both sides of the diff',
description:
'The suggested source and the page as it currently stands, which is what the review screen compares. Answers 404 for a suggestion that is not the callers to review, so that an ID cannot be probed for.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
submissionId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'submissionId']
},
response: {
200: { $ref: 'PageEditSubmissionDetail#' }
}
}
},
async (req, reply) => {
reply.preventCache()
const submission = await WIKI.models.approvals.getSubmissionForReview(
req.params.siteId,
req.params.submissionId,
reviewerFor(req)
)
if (!submission) {
return reply.notFound('This edit suggestion does not exist.')
}
return submission
}
)
/**
* APPROVE A SUGGESTION
*/
app.post<{
Params: { siteId: string; submissionId: string }
Body: { content?: string; render?: string }
}>(
'/sites/:siteId/approvals/submissions/:submissionId/approve',
{
schema: {
summary: 'Approve an edit suggestion and write it to the page',
description:
'Applies `content` when given — the reviewer may have adjusted the suggestion before accepting it — and what was submitted otherwise. Send `render` alongside it, as the editor does on any other save: the markdown pipeline lives in the client. Without it the server renders the page itself, which needs the Puppeteer extension. The page is re-indexed as it would be for any other edit, with the reviewer recorded as the author, and the suggestion is closed out.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
submissionId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'submissionId']
},
body: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'What to write to the page. Defaults to the suggestion as submitted.'
},
render: {
type: 'string',
description:
'The HTML for that content. Omitting it makes the server render the page, which needs the Puppeteer extension.'
}
}
},
response: {
200: {
description: 'Suggestion approved',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized()
}
const submission = await WIKI.models.approvals.getSubmissionForReview(
req.params.siteId,
req.params.submissionId,
reviewerFor(req)
)
if (!submission) {
return reply.notFound('This edit suggestion does not exist.')
}
const applied = await WIKI.models.approvals.approveSubmission({
siteId: req.params.siteId,
submissionId: req.params.submissionId,
content: req.body.content ?? submission.content,
render: req.body.render,
actor
})
if (!applied) {
return reply.notFound('This edit suggestion does not exist.')
}
return {
ok: true,
message: 'Edit suggestion approved.'
}
}
)
/**
* REJECT A SUGGESTION
*/
app.post<{ Params: { siteId: string; submissionId: string } }>(
'/sites/:siteId/approvals/submissions/:submissionId/reject',
{
schema: {
summary: 'Decline an edit suggestion',
description: 'Discards the suggestion. The page is left exactly as it is.',
tags: ['Approvals'],
params: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
submissionId: { type: 'string', format: 'uuid' }
},
required: ['siteId', 'submissionId']
},
response: {
200: {
description: 'Suggestion declined',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
const submission = await WIKI.models.approvals.getSubmissionForReview(
req.params.siteId,
req.params.submissionId,
reviewerFor(req)
)
if (!submission) {
return reply.notFound('This edit suggestion does not exist.')
}
await WIKI.models.approvals.rejectSubmission(req.params.siteId, req.params.submissionId)
return {
ok: true,
message: 'Edit suggestion declined.'
}
}
)
/**
* GET OWN SUGGESTION STATE FOR A PAGE
*

@ -62,6 +62,83 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* PAGE EDIT SUBMISSION - An edit somebody suggested, as its reviewer sees it
*/
app.addSchema({
$id: 'PageEditSubmission',
type: 'object',
properties: {
id: {
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'
},
isStale: {
type: 'boolean',
description:
'The page has changed since this was written against it, so accepting it wholesale would undo whatever changed in between.'
},
page: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
path: { type: 'string' },
title: { type: 'string' },
locale: { type: 'string' }
}
},
author: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
description: 'Null for a guest, who has no account.'
},
name: { type: 'string' },
email: { type: 'string' },
isGuest: { type: 'boolean' }
}
}
}
})
/**
* PAGE EDIT SUBMISSION DETAIL - The same, with both sides of the diff
*/
app.addSchema({
$id: 'PageEditSubmissionDetail',
allOf: [
{ $ref: 'PageEditSubmission#' },
{
type: 'object',
properties: {
content: {
type: 'string',
description: 'What the suggestion proposes the page should say.'
},
pageContent: {
type: 'string',
description: 'What it currently says.'
},
patch: {
type: 'string',
description: 'Unified diff against the page as it stood when the suggestion was made.'
}
}
}
]
})
/**
* APPROVAL RULE INPUT - The fields a rule is written with
*/

@ -14,6 +14,16 @@ import { createDeferred } from '../helpers/common.ts'
// import migrationSource from '../db/migrator-source.js'
// const migrateFromLegacy = require('../db/legacy')
/**
* Postgres extensions the schema depends on, installed before the migrations run.
*
* `ltree` types the folder paths of the page tree and answers the ancestor queries the navigation is
* built from; `pg_trgm` backs fuzzy text matching. `pgcrypto` used to be here for `gen_random_uuid()`,
* which every primary key defaults to that has been core since Postgres 13, and 16 is the minimum
* this runs on, so nothing needs it any more.
*/
const REQUIRED_EXTENSIONS = ['ltree', 'pg_trgm']
/**
* Query logger, consulted by Drizzle on every query.
*
@ -263,6 +273,23 @@ export default {
async syncSchemas(db: WikiDb) {
WIKI.logger.info('Ensuring DB schema exists...')
await db.execute(`CREATE SCHEMA IF NOT EXISTS ${WIKI.config.db.schema}`)
/*
Here rather than at the top of the first migration, for the same reason the schema itself is:
the migrations need these to exist and cannot express them.
`drizzle-kit generate` builds a migration by diffing the schema definition against the previous
snapshot, and an extension is part of neither so a hand-written `CREATE EXTENSION` preamble
survives only until somebody regenerates, at which point the very first migration fails on the
`ltree` column it can no longer create. Stated here, that cannot happen.
Idempotent, so a database whose extensions an administrator installed by hand is untouched.
*/
WIKI.logger.info('Ensuring required DB extensions are installed...')
for (const extension of REQUIRED_EXTENSIONS) {
await db.execute(`CREATE EXTENSION IF NOT EXISTS ${extension}`)
}
WIKI.logger.info('Ensuring DB migrations have been applied...')
return migrate(db, {
migrationsFolder: path.join(WIKI.SERVERPATH, 'db/migrations'),

@ -1,5 +0,0 @@
-- Create PG Extensions --
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

@ -1,10 +0,0 @@
{
"id": "061e8c84-e05e-40b0-a074-7a56bd794fc7",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"version": "8",
"dialect": "postgres",
"ddl": [],
"renames": []
}

File diff suppressed because it is too large Load Diff

@ -1 +0,0 @@
ALTER TABLE "apiKeys" DROP COLUMN "key";

File diff suppressed because it is too large Load Diff

@ -1,2 +0,0 @@
ALTER TABLE "apiKeys" ADD COLUMN "keyShort" varchar(8) NOT NULL;--> statement-breakpoint
ALTER TABLE "apiKeys" ADD COLUMN "groups" jsonb DEFAULT '[]' NOT NULL;

File diff suppressed because it is too large Load Diff

@ -1,15 +0,0 @@
CREATE TYPE "hookState" AS ENUM('pending', 'success', 'error');--> statement-breakpoint
CREATE TABLE "hooks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"events" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"url" text NOT NULL,
"includeMetadata" boolean DEFAULT true NOT NULL,
"includeContent" boolean DEFAULT false NOT NULL,
"acceptUntrusted" boolean DEFAULT false NOT NULL,
"authHeader" text,
"state" "hookState" DEFAULT 'pending'::"hookState" NOT NULL,
"lastErrorMessage" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);

File diff suppressed because it is too large Load Diff

@ -1,14 +0,0 @@
CREATE TABLE "storage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"module" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT false NOT NULL,
"contentTypes" jsonb DEFAULT '{}' NOT NULL,
"assetDelivery" jsonb DEFAULT '{}' NOT NULL,
"versioning" jsonb DEFAULT '{}' NOT NULL,
"config" jsonb DEFAULT '{}' NOT NULL,
"state" jsonb DEFAULT '{}' NOT NULL,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint
ALTER TABLE "storage" ADD CONSTRAINT "storage_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");

File diff suppressed because it is too large Load Diff

@ -1,25 +0,0 @@
CREATE TABLE "iconSets" (
"prefix" varchar(64) PRIMARY KEY,
"name" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"info" jsonb DEFAULT '{}' NOT NULL,
"refreshedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "icons" (
"prefix" varchar(64),
"name" varchar(255),
"body" text NOT NULL,
"width" integer DEFAULT 16 NOT NULL,
"height" integer DEFAULT 16 NOT NULL,
"left" integer DEFAULT 0 NOT NULL,
"top" integer DEFAULT 0 NOT NULL,
"rotate" integer DEFAULT 0 NOT NULL,
"hFlip" boolean DEFAULT false NOT NULL,
"vFlip" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "icons_pkey" PRIMARY KEY("prefix","name")
);
--> statement-breakpoint
ALTER TABLE "icons" ADD CONSTRAINT "icons_prefix_iconSets_prefix_fkey" FOREIGN KEY ("prefix") REFERENCES "iconSets"("prefix");

File diff suppressed because it is too large Load Diff

@ -1,13 +0,0 @@
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

@ -1,2 +0,0 @@
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

@ -1,21 +0,0 @@
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");

@ -1,4 +1,5 @@
CREATE TYPE "assetKind" AS ENUM('document', 'image', 'other');--> statement-breakpoint
CREATE TYPE "hookState" AS ENUM('pending', 'success', 'error');--> statement-breakpoint
CREATE TYPE "jobHistoryState" AS ENUM('active', 'completed', 'failed', 'interrupted');--> statement-breakpoint
CREATE TYPE "pagePublishState" AS ENUM('draft', 'published', 'scheduled');--> statement-breakpoint
CREATE TYPE "treeNavigationMode" AS ENUM('inherit', 'override', 'overrideExact', 'hide', 'hideExact');--> statement-breakpoint
@ -6,13 +7,27 @@ CREATE TYPE "treeType" AS ENUM('folder', 'page', 'asset');--> statement-breakpoi
CREATE TABLE "apiKeys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"key" text NOT NULL,
"keyShort" varchar(8) NOT NULL,
"groups" jsonb DEFAULT '[]' NOT NULL,
"expiration" timestamp DEFAULT now() NOT NULL,
"isRevoked" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "approvalRules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) DEFAULT '' NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"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 TABLE "assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"fileName" varchar(255) NOT NULL,
@ -67,6 +82,45 @@ CREATE TABLE "groups" (
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hooks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"events" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"url" text NOT NULL,
"includeMetadata" boolean DEFAULT true NOT NULL,
"includeContent" boolean DEFAULT false NOT NULL,
"acceptUntrusted" boolean DEFAULT false NOT NULL,
"authHeader" text,
"state" "hookState" DEFAULT 'pending'::"hookState" NOT NULL,
"lastErrorMessage" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "iconSets" (
"prefix" varchar(64) PRIMARY KEY,
"name" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"info" jsonb DEFAULT '{}' NOT NULL,
"refreshedAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "icons" (
"prefix" varchar(64),
"name" varchar(255),
"body" text NOT NULL,
"width" integer DEFAULT 16 NOT NULL,
"height" integer DEFAULT 16 NOT NULL,
"left" integer DEFAULT 0 NOT NULL,
"top" integer DEFAULT 0 NOT NULL,
"rotate" integer DEFAULT 0 NOT NULL,
"hFlip" boolean DEFAULT false NOT NULL,
"vFlip" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "icons_pkey" PRIMARY KEY("prefix","name")
);
--> statement-breakpoint
CREATE TABLE "jobHistory" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"task" varchar(255) NOT NULL,
@ -133,9 +187,38 @@ CREATE TABLE "navigation" (
"siteId" uuid NOT NULL
);
--> statement-breakpoint
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 TABLE "pageHistory" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"pageId" uuid NOT NULL,
"action" varchar(16) DEFAULT 'updated' NOT NULL,
"changedFields" text[] DEFAULT ARRAY[]::text[] NOT NULL,
"locale" varchar(255) NOT NULL,
"path" varchar(255) NOT NULL,
"title" varchar(255) NOT NULL,
"content" text,
"meta" jsonb DEFAULT '{}' NOT NULL,
"versionDate" timestamp DEFAULT now() NOT NULL,
"authorId" uuid,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"locale" ltree NOT NULL,
"locale" varchar(255) NOT NULL,
"path" varchar(255) NOT NULL,
"hash" varchar(255) NOT NULL,
"alias" varchar(255),
@ -192,6 +275,18 @@ CREATE TABLE "sites" (
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "storage" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"module" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT false NOT NULL,
"contentTypes" jsonb DEFAULT '{}' NOT NULL,
"assetDelivery" jsonb DEFAULT '{}' NOT NULL,
"versioning" jsonb DEFAULT '{}' NOT NULL,
"config" jsonb DEFAULT '{}' NOT NULL,
"state" jsonb DEFAULT '{}' NOT NULL,
"siteId" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"tag" varchar(255) NOT NULL,
@ -207,7 +302,7 @@ CREATE TABLE "tree" (
"fileName" varchar(255) NOT NULL,
"hash" varchar(255) NOT NULL,
"tree" "treeType" NOT NULL,
"locale" ltree NOT NULL,
"locale" varchar(255) NOT NULL,
"title" varchar(255) NOT NULL,
"navigationMode" "treeNavigationMode" DEFAULT 'inherit'::"treeNavigationMode" NOT NULL,
"navigationId" uuid,
@ -256,10 +351,18 @@ CREATE TABLE "users" (
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "approvalRules_siteId_idx" ON "approvalRules" ("siteId");--> statement-breakpoint
CREATE INDEX "assets_siteId_idx" ON "assets" ("siteId");--> statement-breakpoint
CREATE INDEX "blocks_siteId_idx" ON "blocks" ("siteId");--> statement-breakpoint
CREATE INDEX "locales_language_idx" ON "locales" ("language");--> statement-breakpoint
CREATE INDEX "navigation_siteId_idx" ON "navigation" ("siteId");--> 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
CREATE INDEX "pageHistory_pageId_idx" ON "pageHistory" ("pageId","versionDate");--> statement-breakpoint
CREATE INDEX "pageHistory_siteId_idx" ON "pageHistory" ("siteId","locale","path","versionDate");--> statement-breakpoint
CREATE INDEX "pageHistory_authorId_idx" ON "pageHistory" ("authorId");--> statement-breakpoint
CREATE INDEX "pages_authorId_idx" ON "pages" ("authorId");--> statement-breakpoint
CREATE INDEX "pages_creatorId_idx" ON "pages" ("creatorId");--> statement-breakpoint
CREATE INDEX "pages_ownerId_idx" ON "pages" ("ownerId");--> statement-breakpoint
@ -268,6 +371,7 @@ CREATE INDEX "pages_ts_idx" ON "pages" USING gin ("ts");--> statement-breakpoint
CREATE INDEX "pages_tags_idx" ON "pages" USING gin ("tags");--> statement-breakpoint
CREATE INDEX "pages_isSearchableComputed_idx" ON "pages" ("isSearchableComputed");--> statement-breakpoint
CREATE INDEX "sessions_userId_idx" ON "sessions" ("userId");--> statement-breakpoint
CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint
CREATE INDEX "tags_siteId_idx" ON "tags" ("siteId");--> statement-breakpoint
CREATE UNIQUE INDEX "tags_composite_idx" ON "tags" ("siteId","tag");--> statement-breakpoint
CREATE INDEX "tree_folderpath_idx" ON "tree" ("folderPath");--> statement-breakpoint
@ -275,7 +379,7 @@ CREATE INDEX "tree_folderpath_gist_idx" ON "tree" USING gist ("folderPath");-->
CREATE INDEX "tree_fileName_idx" ON "tree" ("fileName");--> statement-breakpoint
CREATE INDEX "tree_hash_idx" ON "tree" ("hash");--> statement-breakpoint
CREATE INDEX "tree_type_idx" ON "tree" ("tree");--> statement-breakpoint
CREATE INDEX "tree_locale_idx" ON "tree" USING gist ("locale");--> statement-breakpoint
CREATE INDEX "tree_locale_idx" ON "tree" ("locale");--> statement-breakpoint
CREATE INDEX "tree_navigationMode_idx" ON "tree" ("navigationMode");--> statement-breakpoint
CREATE INDEX "tree_navigationId_idx" ON "tree" ("navigationId");--> statement-breakpoint
CREATE INDEX "tree_tags_idx" ON "tree" USING gin ("tags");--> statement-breakpoint
@ -285,15 +389,23 @@ CREATE INDEX "userGroups_groupId_idx" ON "userGroups" ("groupId");--> statement-
CREATE INDEX "userGroups_composite_idx" ON "userGroups" ("userId","groupId");--> statement-breakpoint
CREATE INDEX "userKeys_userId_idx" ON "userKeys" ("userId");--> statement-breakpoint
CREATE INDEX "users_lastLoginAt_idx" ON "users" ("lastLoginAt");--> statement-breakpoint
ALTER TABLE "approvalRules" ADD CONSTRAINT "approvalRules_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id");--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "blocks" ADD CONSTRAINT "blocks_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "icons" ADD CONSTRAINT "icons_prefix_iconSets_prefix_fkey" FOREIGN KEY ("prefix") REFERENCES "iconSets"("prefix");--> statement-breakpoint
ALTER TABLE "navigation" ADD CONSTRAINT "navigation_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> 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");--> statement-breakpoint
ALTER TABLE "pageHistory" ADD CONSTRAINT "pageHistory_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "pageHistory" ADD CONSTRAINT "pageHistory_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "pages" ADD CONSTRAINT "pages_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id");--> statement-breakpoint
ALTER TABLE "pages" ADD CONSTRAINT "pages_creatorId_users_id_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id");--> statement-breakpoint
ALTER TABLE "pages" ADD CONSTRAINT "pages_ownerId_users_id_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id");--> statement-breakpoint
ALTER TABLE "pages" ADD CONSTRAINT "pages_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id");--> statement-breakpoint
ALTER TABLE "storage" ADD CONSTRAINT "storage_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "tags" ADD CONSTRAINT "tags_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "tree" ADD CONSTRAINT "tree_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint

@ -1,9 +1,9 @@
{
"version": "8",
"dialect": "postgres",
"id": "bb3998ec-7b27-4d34-b3ed-20c6f71a283b",
"id": "9f2b7fea-5303-4a92-802b-bf7fae047462",
"prevIds": [
"4e4a4dd7-9063-4670-be73-827568fc28e3"
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
@ -165,6 +165,12 @@
"entityType": "tables",
"schema": "public"
},
{
"isRlsEnabled": false,
"name": "pageHistory",
"entityType": "tables",
"schema": "public"
},
{
"isRlsEnabled": false,
"name": "pages",
@ -2155,6 +2161,162 @@
"schema": "public",
"table": "pageEditSubmissions"
},
{
"type": "uuid",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "gen_random_uuid()",
"generated": null,
"identity": null,
"name": "id",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "uuid",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "pageId",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "varchar(16)",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "'updated'",
"generated": null,
"identity": null,
"name": "action",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "text",
"typeSchema": null,
"notNull": true,
"dimensions": 1,
"default": "ARRAY[]",
"generated": null,
"identity": null,
"name": "changedFields",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "varchar(255)",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "locale",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "varchar(255)",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "path",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "varchar(255)",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "title",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "text",
"typeSchema": null,
"notNull": false,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "content",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "jsonb",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "'{}'",
"generated": null,
"identity": null,
"name": "meta",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "timestamp",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "now()",
"generated": null,
"identity": null,
"name": "versionDate",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "uuid",
"typeSchema": null,
"notNull": false,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "authorId",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "uuid",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "siteId",
"entityType": "columns",
"schema": "public",
"table": "pageHistory"
},
{
"type": "uuid",
"typeSchema": null,
@ -2169,7 +2331,7 @@
"table": "pages"
},
{
"type": "ltree",
"type": "varchar(255)",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
@ -3030,7 +3192,7 @@
"table": "tree"
},
{
"type": "ltree",
"type": "varchar(255)",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
@ -3667,6 +3829,97 @@
"schema": "public",
"table": "pageEditSubmissions"
},
{
"nameExplicit": true,
"columns": [
{
"value": "pageId",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
},
{
"value": "versionDate",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
}
],
"isUnique": false,
"where": null,
"with": "",
"method": "btree",
"concurrently": false,
"name": "pageHistory_pageId_idx",
"entityType": "indexes",
"schema": "public",
"table": "pageHistory"
},
{
"nameExplicit": true,
"columns": [
{
"value": "siteId",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
},
{
"value": "locale",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
},
{
"value": "path",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
},
{
"value": "versionDate",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
}
],
"isUnique": false,
"where": null,
"with": "",
"method": "btree",
"concurrently": false,
"name": "pageHistory_siteId_idx",
"entityType": "indexes",
"schema": "public",
"table": "pageHistory"
},
{
"nameExplicit": true,
"columns": [
{
"value": "authorId",
"isExpression": false,
"asc": true,
"nullsFirst": false,
"opclass": null
}
],
"isUnique": false,
"where": null,
"with": "",
"method": "btree",
"concurrently": false,
"name": "pageHistory_authorId_idx",
"entityType": "indexes",
"schema": "public",
"table": "pageHistory"
},
{
"nameExplicit": true,
"columns": [
@ -4031,7 +4284,7 @@
"isUnique": false,
"where": null,
"with": "",
"method": "gist",
"method": "btree",
"concurrently": false,
"name": "tree_locale_idx",
"entityType": "indexes",
@ -4387,6 +4640,40 @@
"schema": "public",
"table": "pageEditSubmissions"
},
{
"nameExplicit": false,
"columns": [
"authorId"
],
"schemaTo": "public",
"tableTo": "users",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"name": "pageHistory_authorId_users_id_fkey",
"entityType": "fks",
"schema": "public",
"table": "pageHistory"
},
{
"nameExplicit": false,
"columns": [
"siteId"
],
"schemaTo": "public",
"tableTo": "sites",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "NO ACTION",
"name": "pageHistory_siteId_sites_id_fkey",
"entityType": "fks",
"schema": "public",
"table": "pageHistory"
},
{
"nameExplicit": false,
"columns": [
@ -4746,6 +5033,16 @@
"table": "pageEditSubmissions",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "pageHistory_pkey",
"schema": "public",
"table": "pageHistory",
"entityType": "pks"
},
{
"columns": [
"id"

@ -314,7 +314,10 @@ export const pages = pgTable(
'pages',
{
id: uuid().primaryKey().defaultRandom(),
locale: ltree('locale').notNull(),
// -> A BCP-47 code, matched only ever for equality. Not `ltree`: a hyphenated code is a single
// label to it, so `'pt-BR'::ltree <@ 'pt'` is false and the type buys no locale-family
// matching -- see the note on `pageHistory.locale`.
locale: varchar({ length: 255 }).notNull(),
path: varchar({ length: 255 }).notNull(),
hash: varchar({ length: 255 }).notNull(),
alias: varchar({ length: 255 }),
@ -375,6 +378,75 @@ export const pages = pgTable(
]
)
// PAGE HISTORY ------------------------
/**
* One row per change to a page: what it looked like afterwards, who made it, and what kind of change
* it was.
*
* Every row is a complete version rather than a delta, which is what makes the three things this
* exists for straightforward: comparing any two versions, putting a page back to one of them, and
* recovering a page that was deleted. The deletion itself is recorded the same way, carrying the page
* as it stood when it went that row is the whole of what a recovery needs.
*
* The render is deliberately not kept. It is derived from the content by a pipeline that lives in the
* frontend, and storing a second copy of every page's HTML for every version is a great deal of space
* for something a restore can regenerate.
*/
export const pageHistory = pgTable(
'pageHistory',
{
id: uuid().primaryKey().defaultRandom(),
// -> Not a foreign key: the history of a deleted page is exactly what recovering it needs, so it
// has to outlive the row it points at
pageId: uuid().notNull(),
/**
* `created`, `updated`, `moved` or `deleted`. A varchar rather than an enum so that naming another
* kind of change later does not need a migration.
*/
action: varchar({ length: 16 }).notNull().default('updated'),
/** Which fields this change touched, so a history list can summarise it without diffing. */
changedFields: text()
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
/*
Columns rather than part of `meta` below: a history list shows these for every row, a page that
has moved needs the path it had at the time rather than the one it has now, and looking a history
up by where the page was the only way in once the page itself is gone means matching on the
locale and the path together.
A locale code is BCP-47 with hyphens (`pt-BR`), and every comparison anywhere is an equality
one. `locales.code`, which these values come from, is a varchar too.
*/
locale: varchar({ length: 255 }).notNull(),
path: varchar({ length: 255 }).notNull(),
title: varchar({ length: 255 }).notNull(),
content: text(),
/**
* The rest of the page as it stood: description, icon, tags, publish state and dates, relations,
* scripts, config, editor and content type. Kept whole rather than as columns of its own so that a
* field added to a page does not have to be added here too.
*/
meta: jsonb().notNull().default({}),
versionDate: timestamp().notNull().defaultNow(),
// -> Null once the account is gone, rather than holding the account hostage: a history row is a
// record of what happened to the page, and requiring its author to exist for ever would mean
// that editing a page once made an account undeletable — even after the page itself was gone.
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
siteId: uuid()
.notNull()
.references(() => sites.id)
},
(table) => [
index('pageHistory_pageId_idx').on(table.pageId, table.versionDate),
// -> "What happened to the page at this path, in this locale", which is how a deleted page is
// found again: there is no page row left to look its ID up from. Leading with `siteId` means
// this also serves the plain per-site queries.
index('pageHistory_siteId_idx').on(table.siteId, table.locale, table.path, table.versionDate),
index('pageHistory_authorId_idx').on(table.authorId)
]
)
// PAGE EDIT SUBMISSIONS ---------------
/**
* An edit suggested by somebody who may read a page but not change it, waiting to be reviewed.
@ -507,11 +579,13 @@ export const tree = pgTable(
'tree',
{
id: uuid().primaryKey().defaultRandom(),
// -> Genuinely hierarchical, and queried as such with `<@`, `@>` and lquery: this is what ltree is
// for. The locale beside it is not, and is a plain string.
folderPath: ltree('folderPath'),
fileName: varchar({ length: 255 }).notNull(),
hash: varchar({ length: 255 }).notNull(),
type: treeTypeEnum('tree').notNull(),
locale: ltree('locale').notNull(),
locale: varchar({ length: 255 }).notNull(),
title: varchar({ length: 255 }).notNull(),
navigationMode: treeNavigationModeEnum('navigationMode').notNull().default('inherit'),
navigationId: uuid(),
@ -532,7 +606,9 @@ export const tree = pgTable(
index('tree_fileName_idx').on(table.fileName),
index('tree_hash_idx').on(table.hash),
index('tree_type_idx').on(table.type),
index('tree_locale_idx').using('gist', table.locale),
// -> A plain btree: the locale is a string compared for equality, and GiST — which is what an
// ltree column wanted — has no operator class for varchar at all
index('tree_locale_idx').on(table.locale),
index('tree_navigationMode_idx').on(table.navigationMode),
index('tree_navigationId_idx').on(table.navigationId),
index('tree_tags_idx').using('gin', table.tags),

@ -1998,6 +1998,31 @@
"iconPicker.selection": "Selected icon",
"iconPicker.set": "Set",
"iconPicker.setsFailed": "Failed to load the icon sets.",
"inbox.inbox": "Inbox",
"inbox.inboxInfo": "Nothing here yet.",
"inbox.pendingReview": "Pending Review",
"inbox.pendingReviewInfo": "Edit suggestions waiting for your review, oldest first.",
"inbox.reviewApprove": "Approve",
"inbox.reviewApproveConfirm": "Apply these changes to {page}? The page will be updated right away.",
"inbox.reviewApproveFailed": "Failed to apply the suggestion.",
"inbox.reviewApproveSuccess": "The suggestion has been applied to the page.",
"inbox.reviewBack": "Back to the queue",
"inbox.reviewDecline": "Decline",
"inbox.reviewDeclineConfirm": "Discard this suggestion? The page will be left as it is, and the suggestion cannot be recovered.",
"inbox.reviewDeclineFailed": "Failed to decline the suggestion.",
"inbox.reviewDeclineSuccess": "The suggestion has been declined.",
"inbox.reviewDiffHint": "The page as it stands is on the left. The suggestion is on the right, and you can edit it before approving.",
"inbox.reviewGuest": "Guest",
"inbox.reviewLoadFailed": "Failed to load the review queue.",
"inbox.reviewNone": "Nothing is waiting for your review.",
"inbox.reviewStale": "Page changed",
"inbox.reviewStaleHint": "The page has changed since this was suggested. Check the differences below and adjust the result before approving.",
"inbox.reviewSubmittedBy": "Suggested by {author} on {date}",
"inbox.reviewUnknownAuthor": "Unknown",
"inbox.reviewViewPage": "View Page",
"inbox.title": "Inbox & Notifications",
"inbox.watching": "Watching",
"inbox.watchingInfo": "Nothing here yet.",
"linkPicker.emptyFolder": "There are no pages in this folder.",
"linkPicker.linkUrl": "Link URL",
"linkPicker.loadFailed": "Failed to load the page tree.",

@ -4,7 +4,9 @@ import { and, asc, eq, inArray, sql } from 'drizzle-orm'
import {
approvalRules as approvalRulesTable,
groups as groupsTable,
pageEditSubmissions as submissionsTable
pageEditSubmissions as submissionsTable,
pages as pagesTable,
users as usersTable
} from '../db/schema.ts'
/**
@ -31,6 +33,38 @@ export interface PageEditSubmission {
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
@ -368,6 +402,211 @@ class Approvals {
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, isAdmin = false }: { groupIds: string[]; isAdmin?: boolean }
): Promise<ReviewableSubmission[]> {
if (!isAdmin && groupIds.length < 1) {
return []
}
const rules = (await this.getRules(siteId)).filter(
(rule) =>
rule.isEnabled && (isAdmin || 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(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) =>
this.matchesPage(rule, { id: row.pageId, 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, isAdmin = false }: { groupIds: string[]; isAdmin?: boolean }
): 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, isAdmin })
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 if it is missing, which is the honest answer:
the alternative is quietly leaving a stale render on a page somebody just changed.
*/
const config = WIKI.sites[siteId]?.config?.editors?.[page.editor]?.config ?? {}
const html =
render ??
(await WIKI.models.rendering.renderContent(content, {
editor: page.editor,
config
}))
await WIKI.models.pages.updatePage(siteId, page.id, { content, render: html }, 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.
*

@ -11,6 +11,7 @@ import { icons } from './icons.ts'
import { jobs } from './jobs.ts'
import { locales } from './locales.ts'
import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts'
import { pages } from './pages.ts'
import { passkeys } from './passkeys.ts'
import { rendering } from './rendering.ts'
@ -38,6 +39,7 @@ export default {
jobs,
locales,
navigation,
pageHistory,
pages,
passkeys,
rendering,

@ -0,0 +1,171 @@
import { eq } from 'drizzle-orm'
import { pageHistory as pageHistoryTable, pages as pagesTable } from '../db/schema.ts'
/**
* The kinds of change a history row records.
*
* `created` and `deleted` are the two ends of a page's life; `moved` is a change of path or title,
* which is worth telling apart from an ordinary edit because it is what breaks links; `updated` is
* everything else, content and metadata alike.
*/
export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted'] as const
export type PageHistoryAction = (typeof pageHistoryActions)[number]
/**
* The page fields a version carries beyond the ones with columns of their own.
*
* Taken straight off the stored row, so a field added to a page is captured here without this list
* being touched. The exclusions are either derived from the content (`render`, `toc`, `searchContent`,
* `ts`), fixed for the page's whole life (`id`, `siteId`, `creatorId`, `createdAt`), or bookkeeping
* that says nothing about the version (`hash`, `updatedAt`, `authorId`, `ratingScore`, `ratingCount`,
* `historyData`, `isSearchableComputed`).
*/
const EXCLUDED_FROM_META = new Set([
'id',
'siteId',
'creatorId',
'createdAt',
'updatedAt',
'authorId',
'hash',
'render',
'toc',
'searchContent',
'ts',
'ratingScore',
'ratingCount',
'historyData',
'isSearchableComputed',
// -> Held in columns of their own
'locale',
'path',
'title',
'content'
])
/**
* Fields a change is never reported as having touched.
*
* Either derived from the content (a render moves whenever the source does, and saying so twice tells
* a reader nothing) or bookkeeping that moves on every save regardless.
*/
const NOT_REPORTED_AS_CHANGED = new Set([
'render',
'toc',
'searchContent',
'ts',
'hash',
'authorId',
'updatedAt',
'ratingScore',
'ratingCount',
'historyData',
'isSearchableComputed'
])
/**
* Page history model
*
* Records a version of a page every time one changes. Nothing reads it back yet displaying the
* history, comparing versions and restoring one are the next step so this is deliberately only the
* recording side.
*/
class PageHistory {
/**
* Record what a page looks like now, as a new version.
*
* The snapshot is read from the stored row rather than taken from the caller, so that what is
* recorded is what was actually saved not what the caller believed it was saving. For a deletion
* that means this has to be called BEFORE the row goes.
*
* A failure here is logged and swallowed: history is a record of what happened, and losing an entry
* is not a reason to fail the edit that was the point of the request.
*
* @param authorId Who made the change. Kept on the row until that account is deleted, at which
* point the version survives with no author rather than blocking the deletion.
* @param changedFields Which fields the change touched. Empty for a creation or a deletion, where
* the whole page is the change.
* @returns The version's ID, or null when nothing was recorded
*/
async record({
siteId,
pageId,
action,
authorId,
changedFields = []
}: {
siteId: string
pageId: string
action: PageHistoryAction
authorId: string
changedFields?: string[]
}): Promise<string | null> {
try {
const rows = await WIKI.db.select().from(pagesTable).where(eq(pagesTable.id, pageId)).limit(1)
const page = rows[0]
if (!page) {
WIKI.logger.warn(`Cannot record page history for ${pageId}: the page is not there.`)
return null
}
const meta: Record<string, any> = {}
for (const [key, value] of Object.entries(page)) {
if (!EXCLUDED_FROM_META.has(key)) {
meta[key] = value
}
}
const inserted = await WIKI.db
.insert(pageHistoryTable)
.values({
pageId,
siteId,
authorId,
action,
changedFields,
locale: page.locale,
path: page.path,
title: page.title,
content: page.content,
meta
})
.returning({ id: pageHistoryTable.id })
return inserted[0]?.id ?? null
} catch (err: any) {
WIKI.logger.warn(`Failed to record page history for ${pageId}: ${err.message}`)
return null
}
}
/**
* Which of a page's fields a patch actually changes.
*
* Compared against the stored row rather than taken from the patch keys: a client that sends every
* field on every save which is what the editor does would otherwise record every field as
* changed on every version, and the point of this is to say what was touched.
*
* Fields derived from the content, and the bookkeeping that moves on every save, are left out: a
* render changing alongside its source is not a second thing that happened.
*
* @param existing The page row as it stands
* @param patch The fields being written, keyed as the page stores them
*/
changedFields(existing: Record<string, any>, patch: Record<string, any>): string[] {
const changed: string[] = []
for (const [key, value] of Object.entries(patch)) {
if (value === undefined || !(key in existing) || NOT_REPORTED_AS_CHANGED.has(key)) {
continue
}
// -> JSON rather than `===`: tags, relations and the config blobs are arrays and objects, and
// comparing those by reference reports every save as a change to all of them
if (JSON.stringify(existing[key]) !== JSON.stringify(value)) {
changed.push(key)
}
}
return changed.sort()
}
}
export const pageHistory = new PageHistory()

@ -446,6 +446,13 @@ class Pages {
throw err
}
await WIKI.models.pageHistory.record({
siteId,
pageId: page.id,
action: 'created',
authorId: actor.id
})
await WIKI.models.search.indexPage(page.id, locale)
await WIKI.models.hooks.emit('page:create', {
id: page.id,
@ -561,10 +568,22 @@ class Pages {
// -> The author is whoever last changed it; the creator and owner do not move
values.authorId = actor.id
// -> Worked out before the write, against the row as it stands: the editor sends every field on
// every save, so the patch alone would report a change to all of them
const changedFields = WIKI.models.pageHistory.changedFields(existing, values)
await WIKI.db.update(pagesTable).set(values).where(eq(pagesTable.id, id))
const updated = (await this.getPage({ siteId, id })) as Page
await WIKI.models.pageHistory.record({
siteId,
pageId: id,
action: 'updated',
authorId: actor.id,
changedFields
})
if (treeTitle !== null || patch.tags !== undefined) {
await WIKI.db
.update(treeTable)
@ -653,6 +672,20 @@ class Pages {
})
const moved = (await this.getPage({ siteId, id })) as Page
// -> Recorded as its own kind of change rather than an edit: a move is what breaks inbound links,
// and a history list has to be able to say so
await WIKI.models.pageHistory.record({
siteId,
pageId: id,
action: 'moved',
authorId: actor.id,
changedFields: [
...(newPath !== page.path ? ['path'] : []),
...(title !== undefined && title.trim() !== page.title ? ['title'] : [])
]
})
await WIKI.models.hooks.emit('page:rename', {
id,
path: moved.path,
@ -674,6 +707,14 @@ class Pages {
if (!page) {
return false
}
// -> Before the row goes, and this version is what recovering the page would be built from
await WIKI.models.pageHistory.record({
siteId,
pageId: id,
action: 'deleted',
authorId: actor.id
})
await WIKI.db.delete(pagesTable).where(eq(pagesTable.id, id))
await WIKI.models.tree.deleteEntry(id)
// -> A page that overrode the sidebar owns a menu keyed by its own id, which nothing could reach

@ -6,6 +6,7 @@ import {
} from '@simplewebauthn/server'
import { isoBase64URL } from '@simplewebauthn/server/helpers'
import { eq, sql } from 'drizzle-orm'
import { validate as uuidValidate } from 'uuid'
import { users as usersTable } from '../db/schema.ts'
import type {
AuthenticationResponseJSON,
@ -366,13 +367,18 @@ class Passkeys {
}
// -> The handle is the user ID this server encoded at registration, so anything else is not a
// credential of ours
// credential of ours. Checked for shape before it is looked up: postgres rejects a malformed
// uuid with an error of its own, which would turn a rejected login into a logged fault.
let userId: string
try {
userId = isoBase64URL.toUTF8String(userHandle)
} catch {
throw new Error('ERR_LOGIN_FAILED')
}
if (!uuidValidate(userId)) {
WIKI.models.flags.authDebug('Passkey login rejected: the user handle is not one of ours')
throw new Error('ERR_LOGIN_FAILED')
}
const user = await WIKI.models.users.getById(userId)
if (!user) {

@ -193,7 +193,7 @@ class Search {
if (arms.length < 1) {
return sql`${sql.raw(`'${FALLBACK_DICTIONARY}'`)}::regconfig`
}
return sql`(CASE p.locale::text ${sql.join(arms, sql` `)} ELSE ${sql.raw(`'${FALLBACK_DICTIONARY}'`)} END)::regconfig`
return sql`(CASE p.locale ${sql.join(arms, sql` `)} ELSE ${sql.raw(`'${FALLBACK_DICTIONARY}'`)} END)::regconfig`
}
/**
@ -269,7 +269,7 @@ class Search {
if (locales.length > 0) {
// -> `sql.param`, because a bare array is expanded into a list of placeholders rather than
// bound as one array value
conditions.push(sql`p.locale::text = ANY(${sql.param(locales)}::text[])`)
conditions.push(sql`p.locale = ANY(${sql.param(locales)}::text[])`)
}
if (tags.length > 0) {
conditions.push(sql`p.tags @> ${sql.param(tags)}::text[]`)
@ -306,7 +306,7 @@ class Search {
SELECT
p.id,
p.path,
p.locale::text AS locale,
p.locale,
p.title,
p.description,
p.icon,
@ -357,9 +357,7 @@ class Search {
*/
async rebuildIndex(): Promise<RebuildResult> {
const available = await this.getAvailableDictionaries()
const localeRows = await WIKI.db.execute(
sql`SELECT DISTINCT locale::text AS locale FROM pages ORDER BY locale`
)
const localeRows = await WIKI.db.execute(sql`SELECT DISTINCT locale FROM pages ORDER BY locale`)
const locales = ((localeRows.rows ?? localeRows) as any[]).map((r) => r.locale as string)
WIKI.logger.info(`Rebuilding the search index for ${locales.length} locale(s)...`)
@ -374,7 +372,7 @@ class Search {
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce(title, '')), 'A') ||
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce(description, '')), 'B') ||
setweight(to_tsvector(${sql.raw(`'${dictionary}'`)}, coalesce("searchContent", '')), 'C')
WHERE locale::text = ${locale}
WHERE locale = ${locale}
`)
const pages = updated.rowCount ?? 0
result.pages += pages

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
249 icons.
252 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -35,6 +35,7 @@ export const BUNDLED_ICONS = {
"la:check-square": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm14.281 4.281L14 18.562l-3.281-3.28l-1.438 1.437l4 4l.719.687l.719-.687l8-8z\"/>","width":32,"height":32},
"la:circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6\"/>","width":32,"height":32},
"la:clipboard": {"body":"<path fill=\"currentColor\" d=\"M16 3c-1.258 0-2.152.89-2.594 2H6v23h20V5h-7.406C18.152 3.89 17.258 3 16 3m0 2c.555 0 1 .445 1 1v1h3v2h-8V7h3V6c0-.555.445-1 1-1M8 7h2v4h12V7h2v19H8z\"/>","width":32,"height":32},
"la:clipboard-check": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.258 0-2.152.89-2.594 2H5v25h22V4h-8.406C18.152 2.89 17.258 2 16 2m0 2c.555 0 1 .445 1 1v1h3v2h-8V6h3V5c0-.555.445-1 1-1M7 6h3v4h12V6h3v21H7zm14.281 7.281L15 19.562l-3.281-3.28l-1.438 1.437l4 4l.719.687l.719-.687l7-7z\"/>","width":32,"height":32},
"la:clipboard-list": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.26 0-2.15.89-2.59 2H5v25h22V4h-8.41c-.44-1.11-1.33-2-2.59-2m0 2c.55 0 1 .45 1 1v1h3v2h-8V6h3V5c0-.55.45-1 1-1M7 6h3v4h12V6h3v21H7zm2 7v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2z\"/>","width":32,"height":32},
"la:clock": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 2v9h7v-2h-5V8z\"/>","width":32,"height":32},
"la:cloud-upload-alt": {"body":"<path fill=\"currentColor\" d=\"M16 7c-2.648 0-4.95 1.238-6.594 3.063C9.27 10.046 9.148 10 9 10c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 14a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 9c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 9.574 19.926 7 16 7m0 8l-4 4h3v8h2v-8h3z\"/>","width":32,"height":32},
@ -52,6 +53,7 @@ export const BUNDLED_ICONS = {
"la:ellipsis-v": {"body":"<path fill=\"currentColor\" d=\"M16 6a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m0 8a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},
"la:envelope": {"body":"<path fill=\"currentColor\" d=\"M3 8v18h26V8zm4.313 2h17.375L16 15.781zM5 10.875l10.438 6.969l.562.343l.563-.343L27 10.875V24H5z\"/>","width":32,"height":32},
"la:exclamation-triangle": {"body":"<path fill=\"currentColor\" d=\"m16 3.219l-.875 1.5l-12 20.781l-.844 1.5H29.72l-.844-1.5l-12-20.781zm0 4L26.25 25H5.75zM15 14v6h2v-6zm0 7v2h2v-2z\"/>","width":32,"height":32},
"la:external-link-alt": {"body":"<path fill=\"currentColor\" d=\"M18 5v2h5.563L11.28 19.281l1.438 1.438L25 8.437V14h2V5zM5 9v18h18V14l-2 2v9H7V11h9l2-2z\"/>","width":32,"height":32},
"la:external-link-square-alt": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm6 3v2h5.563L9.28 21.281l1.438 1.438L20 13.437V19h2v-9z\"/>","width":32,"height":32},
"la:eye": {"body":"<path fill=\"currentColor\" d=\"M16 8C7.664 8 1.25 15.344 1.25 15.344L.656 16l.594.656s5.848 6.668 13.625 7.282c.371.046.742.062 1.125.062s.754-.016 1.125-.063c7.777-.613 13.625-7.28 13.625-7.28l.594-.657l-.594-.656S24.336 8 16 8m0 2c2.203 0 4.234.602 6 1.406A6.9 6.9 0 0 1 23 15a6.995 6.995 0 0 1-6.219 6.969c-.02.004-.043-.004-.062 0c-.239.011-.477.031-.719.031c-.266 0-.523-.016-.781-.031A6.995 6.995 0 0 1 9 15c0-1.305.352-2.52.969-3.563h-.031C11.717 10.617 13.773 10 16 10m0 2a3 3 0 1 0 .002 6.002A3 3 0 0 0 16 12m-8.75.938A9 9 0 0 0 7 15c0 1.754.5 3.395 1.375 4.781A23.2 23.2 0 0 1 3.531 16a24 24 0 0 1 3.719-3.063zm17.5 0A24 24 0 0 1 28.469 16a23.2 23.2 0 0 1-4.844 3.781A8.93 8.93 0 0 0 25 15c0-.715-.094-1.398-.25-2.063z\"/>","width":32,"height":32},
"la:file-alt": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM11 13v2h10v-2zm0 4v2h10v-2zm0 4v2h10v-2z\"/>","width":32,"height":32},
@ -214,6 +216,7 @@ export const BUNDLED_ICONS = {
"mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:image-sync-outline": {"body":"<path fill=\"currentColor\" d=\"M13.18 19c.17.72.46 1.39.85 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v6.18c-.5-.11-1-.18-1.5-.18c-.17 0-.33 0-.5.03V5H5v14zm-1.97-3.17l-1.96-2.36L6.5 17h6.53c.11-1.46.7-2.78 1.61-3.81l-.68-.9zM19 13.5V12l-2.25 2.25L19 16.5V15a2.5 2.5 0 0 1 2.5 2.5c0 .4-.09.78-.26 1.12l1.09 1.09c.42-.63.67-1.39.67-2.21c0-2.21-1.79-4-4-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5c0-.4.09-.78.26-1.12l-1.09-1.09c-.42.63-.67 1.39-.67 2.21c0 2.21 1.79 4 4 4V23l2.25-2.25L19 18.5z\"/>","width":24,"height":24},
"mdi:import": {"body":"<path fill=\"currentColor\" d=\"m14 12l-4-4v3H2v2h8v3m10 2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3h2V6h12v12H6v-3H4v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2\"/>","width":24,"height":24},
"mdi:inbox-full": {"body":"<path fill=\"currentColor\" d=\"M19 15V5H5v10h4c0 1.66 1.34 3 3 3s3-1.34 3-3zm0-12c1.1 0 2 .9 2 2v14c0 1.1-.9 2-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM7 13v-2h10v2zm0-4V7h10v2z\"/>","width":24,"height":24},
"mdi:information": {"body":"<path fill=\"currentColor\" d=\"M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
"mdi:information-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41c-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m8 6V7h-2v2zm0 8v-6h-2v6z\"/>","width":24,"height":24},
"mdi:keyboard-variant": {"body":"<path fill=\"currentColor\" d=\"M6 16h12v2H6zm0-3v2H2v-2zm1 2v-2h3v2zm4 0v-2h2v2zm3 0v-2h3v2zm4 0v-2h4v2zM2 10h3v2H2zm17 2v-2h3v2zm-1 0h-2v-2h2zM8 12H6v-2h2zm4 0H9v-2h3zm3 0h-2v-2h2zM2 9V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h5v2z\"/>","width":24,"height":24},

@ -42,6 +42,18 @@
@click="openFileManager">
<w-tooltip>File Manager</w-tooltip>
</w-btn>
<w-btn
v-if="userStore.authenticated"
class="ml-4"
flat
round
dense
icon="mdi:inbox-full"
color="amber"
to="/_inbox"
:aria-label="t(`inbox.title`)">
<w-tooltip>{{ t('inbox.title') }}</w-tooltip>
</w-btn>
<w-btn
v-if="userStore.can(`access:admin`)"
class="ml-4"

@ -170,3 +170,63 @@ export function enhanceRenderedContent(root) {
addCodeCopyButtons(root)
addHeadingAnchors(root)
}
/**
* Paths the server owns rather than the router: assets, the API, block bundles, per-site files,
* thumbnails and avatars. A link to one of these is a request for a file, not a page, and handing it
* to the router would render the catch-all page view over the top of nothing.
*/
const SERVER_PATHS = [
'/_assets/',
'/_api/',
'/_blocks/',
'/_icons/',
'/_site/',
'/_thumb/',
'/_user/'
]
/**
* Where a link inside rendered content should take the reader, if the router should handle it.
*
* A page's HTML arrives through `v-html`, so every link in it is a plain anchor: left alone, the
* browser tears the whole application down and builds it again to show a page the router could have
* swapped in. This decides which links are worth intercepting, and everything it declines stays
* exactly as the browser would have treated it.
*
* Declined, deliberately:
* - another origin, or a scheme that is not http(s) `mailto:`, `tel:`, a download link
* - anything asking for a new context: `target`, `download`, `rel="external"`
* - a path the server owns rather than the router
* - a bare fragment on the page already open, which the browser scrolls to and which fires the
* `hashchange` the page view already listens for
*
* @param {object} link The anchor's own properties: `href` is the resolved absolute URL.
* @param {Location|{origin: string, pathname: string}} current Where the reader is now.
* @returns {string|null} A path to push, or null to let the browser do what it would have done.
*/
export function routableHref({ href, target, download, rel } = {}, current) {
if (!href || (target && target !== '_self') || download || /\bexternal\b/.test(rel ?? '')) {
return null
}
let url
try {
url = new URL(href)
} catch {
return null
}
if (url.origin !== current.origin || !/^https?:$/.test(url.protocol)) {
return null
}
if (SERVER_PATHS.some((prefix) => url.pathname.startsWith(prefix))) {
return null
}
// -> Same page, different fragment: the browser scrolls and announces it, and the router would do
// neither
if (url.pathname === current.pathname && url.hash) {
return null
}
return `${url.pathname}${url.search}${url.hash}`
}

@ -0,0 +1,225 @@
<template>
<w-layout>
<w-header>
<header-nav />
</w-header>
<w-page-container class="layout-inbox">
<div class="layout-inbox-card">
<div class="layout-inbox-sd">
<w-list>
<w-item
v-for="navItem of sidenav"
:key="navItem.key"
clickable
:to="`/_inbox/` + navItem.key"
active-class="is-active">
<w-item-section side>
<w-icon :name="navItem.icon" />
</w-item-section>
<w-item-section>
<w-item-label>{{ navItem.label }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</div>
<router-view />
</div>
</w-page-container>
<main-overlay-dialog />
</w-layout>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { useUserStore } from '@/stores/user'
import HeaderNav from '@/components/HeaderNav.vue'
import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
/**
* The inbox: what has come in for this user, what they are following, and what is waiting on them.
*
* Same shape as the profile layout -- dark backdrop, one card, a rail down its left -- but the card
* fills the viewport rather than sitting in a column, since these sections are lists to work through
* rather than a form to read.
*/
// STORES
const userStore = useUserStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// META
useMeta({
titleTemplate: (title) => `${title} - ${t('inbox.title')} - Wiki.js`
})
// DATA
const sidenav = [
{
key: 'messages',
label: t('inbox.inbox'),
icon: 'mdi:inbox-full'
},
{
key: 'watching',
label: t('inbox.watching'),
icon: 'la:bell'
},
{
key: 'review',
label: t('inbox.pendingReview'),
icon: 'la:clipboard-check'
}
]
// WATCHERS
// -> There is nothing in here for somebody with no account, and every section is about them
watch(
() => route.path,
(newValue) => {
if (newValue.startsWith('/_inbox') && !userStore.authenticated) {
router.replace('/login')
}
},
{ immediate: true }
)
</script>
<style lang="scss">
/*
The backdrop and the rail are the profile layout's, deliberately: these are the two places in the
app a signed in person manages their own things, and they should read as the same place.
What differs is the card. The profile card is a centred column of forms; this one is a workspace of
lists, so it takes the whole viewport less a margin.
*/
.layout-inbox {
// -> Dark in both themes, unlike the profile layout: there is no light half here for a light theme
// to own, so the surface is the same either way
background-color: $dark-6;
/*
The profile layout's gradient, stretched over the whole viewport instead of a band across the top.
There it fades into a light page below it, which is what the 350px height and the border were for;
with nothing to fade into, both go.
*/
&:before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse at bottom, $dark-3, $dark-6);
}
&:after {
content: '';
height: 1px;
position: fixed;
top: 64px;
width: 100%;
background: linear-gradient(
to right,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
}
&-card {
position: relative;
margin: 16px;
box-shadow: $shadow-2;
border-radius: 7px;
display: flex;
align-items: stretch;
// -> The margin above, top and bottom, is what this subtracts: the card fills what is left
min-height: calc(100% - 32px);
@at-root .body--light & {
background-color: #fff;
color: var(--color-black);
}
@at-root .body--dark & {
background-color: $dark-3;
color: var(--color-white);
}
}
&-sd {
flex: 0 0 300px;
border-radius: 8px 0 0 8px;
overflow: hidden;
@at-root .body--light & {
background-color: $grey-1;
border-right: 1px solid rgba($dark-3, 0.1);
box-shadow: inset -1px 0 0 #fff;
}
@at-root .body--dark & {
background-color: $dark-4;
border-right: 1px solid rgba(#fff, 0.12);
box-shadow: inset -1px 0 0 rgba($dark-6, 0.5);
}
.w-list .w-item {
font-weight: 500;
color: $grey-9;
@at-root .body--dark & {
color: rgba(255, 255, 255, 0.75);
}
&.is-active {
background: linear-gradient(to bottom, rgba($primary, 0.25), rgba($primary, 0.1));
color: $primary;
// -> WIcon draws an Iconify reference as <iconify-icon> and anything else via q-icon
.w-icon,
iconify-icon {
color: $primary;
}
@at-root .body--dark & {
color: var(--color-primary-light);
.w-icon,
iconify-icon {
color: var(--color-primary-light);
}
}
}
}
}
.w-page {
flex: 1 1;
@at-root .body--light & {
border-left: 1px solid #fff;
}
@at-root .body--dark & {
border-left: 1px solid rgba($dark-6, 0.75);
}
}
}
body.body--dark {
background-color: $dark-6;
}
</style>

@ -0,0 +1,24 @@
<template>
<w-page class="py-4">
<div class="w-section-header">{{ t('inbox.inbox') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('inbox.inboxInfo') }}</div>
</div>
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('inbox.inbox')
})
</script>

@ -0,0 +1,432 @@
<template>
<w-page class="inbox-review flex flex-col">
<!-- ----------------------------------------------------- -->
<!-- QUEUE -->
<!-- ----------------------------------------------------- -->
<template v-if="!state.selected">
<!--
`pt-4` on the heading rather than `py-4` on the page, which is where the other sections get it
from: this page is a flex column whose diff view fills the rest of the card, and padding on the
container would sit under that too.
-->
<div class="w-section-header pt-4">{{ t('inbox.pendingReview') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('inbox.pendingReviewInfo') }}</div>
<w-banner
v-if="state.submissions.length < 1 && state.loading < 1"
class="mt-6"
rounded
:class="dark.isActive ? `bg-dark-4 text-grey-4` : `bg-grey-2 text-grey-8`">
{{ t('inbox.reviewNone') }}
</w-banner>
<w-list v-else class="mt-6" bordered separator>
<w-item
v-for="submission of state.submissions"
:key="submission.id"
clickable
@click="openSubmission(submission)">
<w-item-section avatar>
<w-avatar color="secondary" text-color="white" rounded>
<w-icon name="la:file-alt" />
</w-avatar>
</w-item-section>
<w-item-section>
<w-item-label>
<strong>{{ submission.page.title }}</strong>
</w-item-label>
<w-item-label caption>/{{ submission.page.path }}</w-item-label>
<w-item-label caption>
<i18n-t keypath="inbox.reviewSubmittedBy" scope="global">
<template #author>
<strong>{{ submission.author.name || t('inbox.reviewUnknownAuthor') }}</strong>
</template>
<template #date>{{ humanizeDate(submission.createdAt) }}</template>
</i18n-t>
</w-item-label>
</w-item-section>
<w-item-section side>
<div class="flex items-center gap-3">
<w-badge v-if="submission.author.isGuest" color="grey-7" rounded>
{{ t('inbox.reviewGuest') }}
</w-badge>
<!-- The page moved on after this was written; see `isStale` on the API side. -->
<w-badge v-if="submission.isStale" color="warning" rounded>
{{ t('inbox.reviewStale') }}
</w-badge>
<w-icon name="la:angle-right" color="grey" />
</div>
</w-item-section>
</w-item>
</w-list>
</div>
</template>
<!-- ----------------------------------------------------- -->
<!-- ONE SUBMISSION -->
<!-- ----------------------------------------------------- -->
<template v-else>
<div class="flex flex-none flex-wrap items-center gap-2 p-4">
<w-btn
class="acrylic-btn"
flat
dense
round
icon="la:arrow-left"
color="grey"
:aria-label="t(`inbox.reviewBack`)"
@click="closeSubmission">
<w-tooltip>{{ t(`inbox.reviewBack`) }}</w-tooltip>
</w-btn>
<div class="min-w-0 flex-1">
<div class="text-subtitle1">
<strong>{{ state.selected.page.title }}</strong>
</div>
<div class="text-caption text-grey">
<i18n-t keypath="inbox.reviewSubmittedBy" scope="global">
<template #author>
<strong>{{ state.selected.author.name || t('inbox.reviewUnknownAuthor') }}</strong>
</template>
<template #date>{{ humanizeDate(state.selected.createdAt) }}</template>
</i18n-t>
<template v-if="state.selected.author.email">
&middot; {{ state.selected.author.email }}
</template>
</div>
</div>
<w-btn
class="acrylic-btn"
flat
icon="la:external-link-alt"
color="grey"
:label="t(`inbox.reviewViewPage`)"
no-caps
:href="`/` + state.selected.page.path"
target="_blank" />
<w-btn
class="acrylic-btn"
flat
icon="la:times"
color="negative"
:label="t(`inbox.reviewDecline`)"
no-caps
@click="rejectSubmission" />
<w-btn
unelevated
icon="la:check"
color="positive"
:label="t(`inbox.reviewApprove`)"
no-caps
@click="approveSubmission" />
</div>
<!--
A warning rather than a block: the reviewer can see both sides in the diff below and edit the
result before accepting, which is exactly what a stale suggestion needs.
-->
<!-- Literal colour classes: WBanner has no `color` prop, so one would be silently dropped. -->
<w-banner
v-if="state.selected.isStale"
class="mx-4 mb-2 flex-none bg-warning text-black"
rounded>
{{ t('inbox.reviewStaleHint') }}
</w-banner>
<div class="flex-none px-4 pb-2 text-caption text-grey">
{{ t('inbox.reviewDiffHint') }}
</div>
<!-- The diff itself: current page on the left, the suggestion on the right and editable. -->
<div ref="diffEl" class="inbox-review-diff" />
</template>
<w-inner-loading :showing="state.loading > 0" />
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import * as monaco from 'monaco-editor'
import { MarkdownRenderer } from '@/renderers/markdown'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { confirm } from '@/composables/dialog'
import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site'
// COMPOSABLES
const dark = useDark()
// STORES
const editorStore = useEditorStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('inbox.pendingReview')
})
// DATA
const state = reactive({
loading: 0,
submissions: [],
/** The submission being reviewed, with both sides of the diff. Null while the queue is showing. */
selected: null
})
// REFS
const diffEl = ref(null)
/*
The Monaco instances, deliberately outside `state`: they are large objects with their own internals,
and making them reactive buys nothing and costs a lot.
*/
let diffEditor = null
let originalModel = null
let modifiedModel = null
// WATCHERS
// -> The container only exists once a submission is open, so the editor is built after that render
watch(
() => state.selected?.id,
async (id) => {
if (!id) {
disposeEditor()
return
}
await nextTick()
mountEditor()
}
)
// 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 humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})
}
async function load() {
state.loading++
try {
// -> The markdown renderer is configured per site (line breaks, typographer, and so on), and that
// configuration comes with the editor configs rather than on its own
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
state.submissions =
(await API_CLIENT.get(`sites/${siteStore.id}/approvals/submissions`).json()) ?? []
} catch (err) {
notify({
type: 'negative',
message: t('inbox.reviewLoadFailed'),
caption: await apiMessage(err)
})
}
state.loading--
}
async function openSubmission(submission) {
state.loading++
try {
state.selected = await API_CLIENT.get(
`sites/${siteStore.id}/approvals/submissions/${submission.id}`
).json()
} catch (err) {
notify({
type: 'negative',
message: t('inbox.reviewLoadFailed'),
caption: await apiMessage(err)
})
}
state.loading--
}
function closeSubmission() {
state.selected = null
}
/**
* The diff, as the reviewer works on it.
*
* Left is the page as it stands, read-only. Right is the suggestion, and is not: the reviewer can
* adjust it before accepting, which is what makes a stale or nearly-right suggestion usable. What
* ends up on the page is whatever the right-hand model says at that moment, which is why approving
* reads the model rather than the value that was loaded.
*/
function mountEditor() {
if (!diffEl.value || !state.selected) {
return
}
disposeEditor()
// -> The markdown editor's theme, defined again here because that component may never have mounted
monaco.editor.defineTheme('wikijs', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#070a0d',
'editor.lineHighlightBackground': '#0d1117',
'editorLineNumber.foreground': '#546e7a',
'editorGutter.background': '#0d1117'
}
})
originalModel = monaco.editor.createModel(state.selected.pageContent ?? '', 'markdown')
modifiedModel = monaco.editor.createModel(state.selected.content ?? '', 'markdown')
diffEditor = monaco.editor.createDiffEditor(diffEl.value, {
automaticLayout: true,
fontSize: 14,
// -> Side by side: this screen exists to compare the two, and an inline diff of prose reads as a
// jumble of half-lines
renderSideBySide: true,
originalEditable: false,
readOnly: false,
scrollBeyondLastLine: false,
theme: 'wikijs',
wordWrap: 'on'
})
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
}
function disposeEditor() {
diffEditor?.dispose()
originalModel?.dispose()
modifiedModel?.dispose()
diffEditor = null
originalModel = null
modifiedModel = null
}
/** What the reviewer settled on: the right-hand side of the diff as it stands now. */
function reviewedContent() {
return modifiedModel ? modifiedModel.getValue() : (state.selected?.content ?? '')
}
/**
* The HTML for what is being approved, produced here for the same reason the editor produces it on
* every save: the markdown pipeline is a frontend one. Without it the server would have to drive a
* headless browser, which is an extension most instances do not install.
*
* @throws When the source will not render, which is worth stopping for -- approving would otherwise
* publish a page whose HTML does not match its source.
*/
function renderReviewed(content) {
const md = new MarkdownRenderer(editorStore.editors.markdown ?? {})
return md.render(content)
}
function approveSubmission() {
confirm({
title: t('inbox.reviewApprove'),
message: t('inbox.reviewApproveConfirm', { page: state.selected.page.title }),
cancel: true,
okLabel: t('inbox.reviewApprove')
}).onOk(async () => {
state.loading++
try {
const content = reviewedContent()
const resp = await API_CLIENT.post(
`sites/${siteStore.id}/approvals/submissions/${state.selected.id}/approve`,
{ json: { content, render: renderReviewed(content) } }
).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('inbox.reviewApproveSuccess')
})
closeSubmission()
await load()
} catch (err) {
notify({
type: 'negative',
message: t('inbox.reviewApproveFailed'),
caption: await apiMessage(err)
})
}
state.loading--
})
}
function rejectSubmission() {
confirm({
title: t('inbox.reviewDecline'),
message: t('inbox.reviewDeclineConfirm'),
cancel: true,
color: 'negative',
okLabel: t('inbox.reviewDecline')
}).onOk(async () => {
state.loading++
try {
const resp = await API_CLIENT.post(
`sites/${siteStore.id}/approvals/submissions/${state.selected.id}/reject`
).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('inbox.reviewDeclineSuccess')
})
closeSubmission()
await load()
} catch (err) {
notify({
type: 'negative',
message: t('inbox.reviewDeclineFailed'),
caption: await apiMessage(err)
})
}
state.loading--
})
}
// MOUNTED
onMounted(load)
onBeforeUnmount(disposeEditor)
</script>
<style lang="scss">
.inbox-review {
/*
The diff takes whatever is left under the header rather than a fixed height: this page sits in a
card that already fills the viewport, so a height in pixels would either overflow it or leave a
gap under it.
*/
&-diff {
flex: 1 1 auto;
min-height: 400px;
border-top: 1px solid rgba(#fff, 0.1);
}
}
</style>

@ -0,0 +1,24 @@
<template>
<w-page class="py-4">
<div class="w-section-header">{{ t('inbox.watching') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('inbox.watchingInfo') }}</div>
</div>
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta'
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('inbox.watching')
})
</script>

@ -50,7 +50,15 @@
</div>
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<div class="p-4">
<div class="page-contents" ref="pageContents" v-html="pageStore.render" />
<!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render.
-->
<div
class="page-contents"
ref="pageContents"
v-html="pageStore.render"
@click="onContentClick" />
<template v-if="pageStore.relations && pageStore.relations.length > 0">
<w-separator class="my-6" />
<div class="flex flex-wrap">
@ -215,7 +223,7 @@ import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { scrollToAnchorWhenReady } from '@/helpers/anchors'
import { enhanceRenderedContent } from '@/helpers/renderedContent'
import { enhanceRenderedContent, routableHref } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc'
import { useCommonStore } from '@/stores/common'
@ -519,6 +527,37 @@ watch(
// METHODS
/**
* Follow a link inside the page's content without reloading the application.
*
* A rendered page is HTML, so its internal links are ordinary anchors: the browser would throw the
* whole SPA away and build it again to show a page the router can swap in. `routableHref` decides
* which ones are ours; anything it declines is left to the browser, including a click asking for a
* new tab.
*/
function onContentClick(ev) {
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.ctrlKey ||
ev.shiftKey ||
ev.altKey
) {
return
}
const anchor = ev.target?.closest?.('a[href]')
if (!anchor) {
return
}
const target = routableHref(anchor, window.location)
if (!target) {
return
}
ev.preventDefault()
router.push(target)
}
/** Asks for the page's password. Opened on arrival, and again from the lock screen's own button. */
function promptUnlock() {
dialog({ component: PageUnlockDialog })

@ -30,6 +30,16 @@ const routes = [
{ path: 'groups', component: () => import('@/pages/ProfileGroups.vue') }
]
},
{
path: '/_inbox',
component: () => import('@/layouts/InboxLayout.vue'),
children: [
{ path: '', redirect: '/_inbox/messages' },
{ path: 'messages', component: () => import('@/pages/InboxMessages.vue') },
{ path: 'watching', component: () => import('@/pages/InboxWatching.vue') },
{ path: 'review', component: () => import('@/pages/InboxReview.vue') }
]
},
{
path: '/_search',
component: () => import('@/pages/Search.vue')

Loading…
Cancel
Save