refactor: renderPages job + various fixes

scarlett
NGPixel 1 month ago
parent ff4a5bc6e6
commit 1c6e71eebf
No known key found for this signature in database

@ -24,6 +24,21 @@ RUN apt-get update && apt-get install -qy \
unzip \ unzip \
wget wget
# Chromium, for the Puppeteer extension. Rendering a page on the server means driving a headless
# browser, and this is the copy Puppeteer is pointed at rather than one it downloads for itself:
# the distro keeps it patched, and it exists for arm64 as well as amd64.
#
# Its own RUN, and without recommends, because Chromium recommends a desktop stack -- an X server, a
# terminal emulator, usbmuxd -- that takes the package count from 61 to 177 and that nothing headless
# ever touches. The apt lists from the install above are still around, so no second update is needed.
RUN apt-get install -qy --no-install-recommends \
chromium \
fonts-liberation
# Where Puppeteer looks for a browser, and that it must not fetch a second one on install
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# avoid million NPM install messages # avoid million NPM install messages
ENV npm_config_loglevel=warn ENV npm_config_loglevel=warn
# disable NPM funding messages # disable NPM funding messages

@ -13,6 +13,18 @@ echo "Waiting for DB container to come online..."
echo "Installing dependencies..." echo "Installing dependencies..."
cd backend cd backend
npm install npm install
# The Puppeteer extension, which server-side page rendering needs. Installed here rather than in the
# Dockerfile because node_modules lives in the bind-mounted workspace, and anything the image put there
# would disappear under the mount. Kept out of package.json on purpose: it is an optional extension,
# and a plain source checkout should not have to fetch it to install the backend.
#
# `--no-save` leaves package.json alone, which also means a later reinstall can prune it and turn
# server-side rendering back off -- run this line again if the admin area says it is missing. The
# browser itself is in the image, so this fetches no Chromium (see PUPPETEER_* in the Dockerfile).
echo "Installing the Puppeteer extension..."
npm install --no-save puppeteer@25.4.0
cd ../frontend cd ../frontend
npm install npm install
cd ../blocks cd ../blocks

@ -3,7 +3,7 @@ name: Build + Publish
on: on:
push: push:
branches: branches:
- vega - scarlett
jobs: jobs:
build: build:
@ -14,66 +14,45 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Setup Node.js environment - name: Setup Node.js environment
uses: actions/setup-node@v4 uses: actions/setup-node@v7
with: with:
node-version: 20.x node-version: 26.x
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@latest --activate
- name: Set Build Variables - name: Set Build Variables
run: | run: |
echo "REL_VERSION=3.0.0-alpha.$GITHUB_RUN_NUMBER" >> $GITHUB_ENV echo "REL_VERSION=3.0.0-alpha.$GITHUB_RUN_NUMBER" >> $GITHUB_ENV
- name: Disable DEV Flag + Set Version - name: Set Version
working-directory: server
run: | run: |
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" yq -iP '.version = strenv(REL_VERSION)' backend/package.json -o json
(echo; echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"') >> /home/runner/.bashrc yq -iP '.version = strenv(REL_VERSION)' frontend/package.json -o json
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
brew install jq
mv package.json pkg-temp.json
jq --arg vs "$REL_VERSION_STRICT" -r '. + {dev:false, version:$vs}' pkg-temp.json > package.json
rm pkg-temp.json
cat package.json
- name: Fetch Latest Locales
uses: localazy/download@v1
with:
read_key: ${{ secrets.LOCALAZY_KEY_READ }}
- name: Build Assets - name: Build Assets
working-directory: ux working-directory: frontend
run: | run: |
pnpm install --frozen-lockfile --shamefully-hoist npm ci
NODE_OPTIONS=--max-old-space-size=8192 pnpm build npm run build
- name: Build Blocks - name: Build Blocks
working-directory: blocks working-directory: blocks
run: | run: |
pnpm install --frozen-lockfile npm ci
pnpm build npm run build
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@v5 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: dev/build/Dockerfile file: dev/build/Dockerfile
@ -85,60 +64,20 @@ jobs:
- name: Prepare build archive - name: Prepare build archive
run: | run: |
mkdir -p _dist mkdir -p _dist/blocks
cp -R assets _dist/assets cp -R assets _dist/assets
cp -R server _dist/server cp -R blocks/compiled _dist/blocks/compiled
cp -R backend _dist/backend
cp LICENSE _dist/LICENSE cp LICENSE _dist/LICENSE
cp config.sample.yml _dist/config.sample.yml cp config.sample.yml _dist/config.sample.yml
cd _dist/server cd _dist/server
pnpm install --prod --frozen-lockfile npm ci --omit=dev
cd - cd -
find ./_dist/ -printf "%P\n" | tar -czf wiki-js.tar.gz --no-recursion -C ./_dist/ -T - find ./_dist/ -printf "%P\n" | tar -czf wiki-js.tar.gz --no-recursion -C ./_dist/ -T -
- name: Upload Build Artifact - name: Upload Build Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: build name: build
path: wiki-js.tar.gz path: wiki-js.tar.gz
windows:
name: Windows Build
runs-on: windows-latest
needs: [build]
steps:
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@latest --activate
- name: Download Build Artifact
uses: actions/download-artifact@v4
with:
name: build
path: build
- name: Extract Build
run: |
mkdir -p win
tar -xzf $env:GITHUB_WORKSPACE\build\wiki-js.tar.gz -C $env:GITHUB_WORKSPACE\win --exclude=server/node_modules
- name: Install Dependencies
run: pnpm install --prod --frozen-lockfile
working-directory: win\server
- name: Create Bundle
shell: pwsh
run: Compress-Archive -Path $env:GITHUB_WORKSPACE\win\* -DestinationPath wiki-js-windows.zip
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: build-win
path: wiki-js-windows.zip

@ -478,7 +478,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Approve an edit suggestion and write it to the page', summary: 'Approve an edit suggestion and write it to the page',
description: 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.', '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 queues the page for rendering, which needs the Puppeteer extension and answers 503 without it; the page then serves its previous HTML until the queue reaches it. 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'], tags: ['Approvals'],
params: { params: {
type: 'object', type: 'object',

@ -3,7 +3,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts' import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts' import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
import { generatePathHash } from '../helpers/common.ts' import { generatePathHash } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts' import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */ /** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
function splitList(value?: string): string[] { function splitList(value?: string): string[] {
@ -810,20 +810,21 @@ async function routes(app: FastifyInstance) {
granted by a group's RULES. Checked against the page in question below instead which is granted by a group's RULES. Checked against the page in question below instead which is
also what lets a rule open one branch to somebody the group as a whole cannot write to. also what lets a rule open one branch to somebody the group as a whole cannot write to.
*/ */
// -> Bounds how fast one client can fill the queue; see `helpers/rateLimit.ts`
preHandler: limitRenders,
schema: { schema: {
summary: 'Render a page again from its source', summary: 'Queue a page to be rendered again from its source',
description: description:
'For when a stored render has gone stale and nobody has the page open to re-save it. The markdown pipeline lives in the frontend, so the server drives it in a headless browser and the result matches what the editor would produce — which means this needs the Puppeteer extension, and answers 503 without it.', 'For when a stored render has gone stale and nobody has the page open to re-save it. The markdown pipeline lives in the frontend, so the server drives it in a headless browser and the result matches what the editor would produce — which means this needs the Puppeteer extension, and answers 503 without it.\n\nAnswers 202: a browser is far too heavy to hold a request open for, so the page joins a queue that is drained one page at a time and its render is replaced when its turn comes. Asking twice for the same page is one render of whatever the content has become by then. Rate limited, to bound how fast the queue can be filled.',
tags: ['Pages'], tags: ['Pages'],
params: pageIdParam, params: pageIdParam,
response: { response: {
200: { 202: {
description: 'Page rendered successfully', description: 'Page queued for rendering',
type: 'object', type: 'object',
properties: { properties: {
ok: { type: 'boolean' }, ok: { type: 'boolean' },
message: { type: 'string' }, message: { type: 'string' }
page: { $ref: 'Page#' }
} }
} }
} }
@ -845,15 +846,18 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'write:pages', target)) { if (!mayOnPage(req, 'write:pages', target)) {
return reply.forbidden('You are not allowed to edit this page.') return reply.forbidden('You are not allowed to edit this page.')
} }
const page = await WIKI.models.pages.rerenderPage(req.params.siteId, req.params.pageId, actor) const queued = await WIKI.models.pages.queueRerender(
if (!page) { req.params.siteId,
req.params.pageId,
actor
)
if (!queued) {
return reply.notFound('This page does not exist.') return reply.notFound('This page does not exist.')
} }
return { return reply.code(202).send({
ok: true, ok: true,
message: 'Page rendered successfully.', message: 'Page queued for rendering.'
page })
}
} }
) )

@ -5,6 +5,8 @@ import * as awarenessProtocol from 'y-protocols/awareness'
import * as syncProtocol from 'y-protocols/sync' import * as syncProtocol from 'y-protocols/sync'
import * as Y from 'yjs' import * as Y from 'yjs'
import { createNotifier } from '../helpers/pubsub.ts'
import type { PoolClient } from 'pg' import type { PoolClient } from 'pg'
import type { WebSocket } from 'ws' import type { WebSocket } from 'ws'
@ -180,6 +182,14 @@ function buildSeed(page: {
return update return update
} }
/**
* Sends this instance's relay messages, one at a time.
*
* Every one of them starts in a Yjs handler that cannot wait for postgres, and a single edit can
* produce several see `publish`.
*/
const notifier = createNotifier(() => WIKI.collab.listenClient, 'collaboration relay')
export default { export default {
rooms: new Map<string, CollabRoom>(), rooms: new Map<string, CollabRoom>(),
listenClient: null as PoolClient | null, listenClient: null as PoolClient | null,
@ -250,6 +260,9 @@ export default {
} }
this.rooms.clear() this.rooms.clear()
if (this.listenClient) { if (this.listenClient) {
// -> Whatever is still on its way out goes out first: releasing the client from under a
// notification in flight would fail that one for no reason
await notifier.drained()
this.listenClient.release(true) this.listenClient.release(true)
this.listenClient = null this.listenClient = null
} }
@ -613,12 +626,15 @@ export default {
} }
}, },
/**
* Send one envelope to the other instances, behind whatever is already going out.
*
* Never awaited every caller is a Yjs handler reacting to an edit or a cursor moving, and a
* keystroke cannot wait for a round trip to postgres. `helpers/pubsub.ts` is what makes that safe on
* a single client, which a burst of updates or one chunked message would otherwise breach.
*/
publish(envelope: RelayEnvelope): void { publish(envelope: RelayEnvelope): void {
this.listenClient notifier.send(NOTIFY_CHANNEL, JSON.stringify(envelope))
?.query('SELECT pg_notify($1, $2)', [NOTIFY_CHANNEL, JSON.stringify(envelope)])
.catch((err: any) => {
WIKI.logger.warn(`Failed to relay a collaboration message: ${err.message}`)
})
}, },
receiveRelay(envelope: RelayEnvelope): void { receiveRelay(envelope: RelayEnvelope): void {

@ -11,9 +11,19 @@ import semver from 'semver'
import { relations } from '../db/relations.ts' import { relations } from '../db/relations.ts'
import { flags } from '../models/flags.ts' import { flags } from '../models/flags.ts'
import { createDeferred } from '../helpers/common.ts' import { createDeferred } from '../helpers/common.ts'
import { createNotifier } from '../helpers/pubsub.ts'
// import migrationSource from '../db/migrator-source.js' // import migrationSource from '../db/migrator-source.js'
// const migrateFromLegacy = require('../db/legacy') // const migrateFromLegacy = require('../db/legacy')
/**
* Sends the event bus's cross-instance notifications, one at a time.
*
* Built here rather than on the object below because `notifyViaDB` is handed to Emittery as a bare
* listener and so has no `this` to reach it through. The client is read per send for the same reason
* it is elsewhere: it does not exist until `subscribeToNotifications`.
*/
const notifier = createNotifier(() => WIKI.dbManager.pubsubClient, 'event bus')
/** /**
* Postgres extensions the schema depends on, installed before the migrations run. * Postgres extensions the schema depends on, installed before the migrations run.
* *
@ -184,7 +194,7 @@ export default {
// -> Outbound events handling // -> Outbound events handling
this.pubsubClient.query('LISTEN wiki') await this.pubsubClient.query('LISTEN wiki')
this.pubsubClient.on('notification', (msg) => { this.pubsubClient.on('notification', (msg) => {
if (msg.channel !== 'wiki') { if (msg.channel !== 'wiki') {
return return
@ -220,6 +230,9 @@ export default {
if (this.pubsubClient) { if (this.pubsubClient) {
WIKI.events.outbound.offAny(this.notifyViaDB as any) WIKI.events.outbound.offAny(this.notifyViaDB as any)
WIKI.events.inbound.clearListeners() WIKI.events.inbound.clearListeners()
// -> Whatever the last events queued goes out before the client goes: releasing it from under a
// notification in flight would fail that one for no reason
await notifier.drained()
this.pubsubClient.release(true) this.pubsubClient.release(true)
} }
}, },
@ -230,18 +243,14 @@ export default {
* @param value Payload of the event * @param value Payload of the event
*/ */
notifyViaDB({ name, data }: { name?: string; data?: unknown }): void { notifyViaDB({ name, data }: { name?: string; data?: unknown }): void {
try { notifier.send(
WIKI.dbManager.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [ 'wiki',
'wiki', JSON.stringify({
JSON.stringify({ source: WIKI.INSTANCE_ID,
source: WIKI.INSTANCE_ID, event: name,
event: name, value: data ?? null
value: data ?? null })
}) )
])
} catch (err: any) {
WIKI.logger.warn(err)
}
}, },
/** /**
* Attempt initial connection * Attempt initial connection

@ -5,6 +5,7 @@ import path from 'node:path'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { createDeferred, type Deferred } from '../helpers/common.ts' import { createDeferred, type Deferred } from '../helpers/common.ts'
import { createNotifier } from '../helpers/pubsub.ts'
import { camelCase } from 'es-toolkit/string' import { camelCase } from 'es-toolkit/string'
import { remove } from 'es-toolkit/array' import { remove } from 'es-toolkit/array'
import { import {
@ -19,6 +20,14 @@ import type { PoolClient } from 'pg'
/** An in-process task, loaded from `tasks/simple/`. */ /** An in-process task, loaded from `tasks/simple/`. */
export type SimpleTask = (payload?: any) => Promise<void> | void export type SimpleTask = (payload?: any) => Promise<void> | void
/**
* Sends the scheduler's cross-instance notifications, one at a time.
*
* Nothing here awaits a notification: a job being added or finishing should not wait on a round trip,
* and `processJob` runs concurrently with itself, so two notifications easily meet on the one client.
*/
const notifier = createNotifier(() => WIKI.scheduler.pubsubClient, 'scheduler')
/** A pending `addJob({ promise: true })` caller, waiting on the `jobCompleted` event. */ /** A pending `addJob({ promise: true })` caller, waiting on the `jobCompleted` event. */
interface CompletionPromise { interface CompletionPromise {
id: string id: string
@ -88,7 +97,7 @@ export default {
// -> Outbound events handling // -> Outbound events handling
this.pubsubClient!.query('LISTEN scheduler') await this.pubsubClient!.query('LISTEN scheduler')
this.pubsubClient!.on('notification', async (msg) => { this.pubsubClient!.on('notification', async (msg) => {
if (msg.channel !== 'scheduler') { if (msg.channel !== 'scheduler') {
return return
@ -171,14 +180,14 @@ export default {
createdBy: WIKI.INSTANCE_ID createdBy: WIKI.INSTANCE_ID
}) })
if (notify) { if (notify) {
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [ notifier.send(
'scheduler', 'scheduler',
JSON.stringify({ JSON.stringify({
source: WIKI.INSTANCE_ID, source: WIKI.INSTANCE_ID,
event: 'newJob', event: 'newJob',
id: jobId id: jobId
}) })
]) )
} }
return { return {
id: jobId, id: jobId,
@ -250,7 +259,7 @@ export default {
}) })
.where(eq(jobHistoryTable.id, job.id)) .where(eq(jobHistoryTable.id, job.id))
WIKI.logger.info(`Completed job ${job.id}: ${job.task}`) WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [ notifier.send(
'scheduler', 'scheduler',
JSON.stringify({ JSON.stringify({
source: WIKI.INSTANCE_ID, source: WIKI.INSTANCE_ID,
@ -258,7 +267,7 @@ export default {
state: 'success', state: 'success',
id: job.id id: job.id
}) })
]) )
} catch (err: any) { } catch (err: any) {
WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`) WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
WIKI.logger.warn(err) WIKI.logger.warn(err)
@ -271,7 +280,7 @@ export default {
lastErrorMessage: err.message lastErrorMessage: err.message
}) })
.where(eq(jobHistoryTable.id, job.id)) .where(eq(jobHistoryTable.id, job.id))
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [ notifier.send(
'scheduler', 'scheduler',
JSON.stringify({ JSON.stringify({
source: WIKI.INSTANCE_ID, source: WIKI.INSTANCE_ID,
@ -280,7 +289,7 @@ export default {
id: job.id, id: job.id,
errorMessage: err.message errorMessage: err.message
}) })
]) )
// -> Reschedule for retry // -> Reschedule for retry
if (job.retries < job.maxRetries) { if (job.retries < job.maxRetries) {
const backoffDelay = 2 ** job.retries * WIKI.config.scheduler.retryBackoff const backoffDelay = 2 ** job.retries * WIKI.config.scheduler.retryBackoff

@ -0,0 +1,15 @@
CREATE TABLE "pageRenderQueue" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"allowScripts" boolean DEFAULT false NOT NULL,
"allowStyles" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"pageId" uuid NOT NULL UNIQUE,
"siteId" uuid NOT NULL,
"requestedById" uuid
);
--> statement-breakpoint
CREATE INDEX "pageRenderQueue_createdAt_idx" ON "pageRenderQueue" ("createdAt");--> statement-breakpoint
ALTER TABLE "pageRenderQueue" ADD CONSTRAINT "pageRenderQueue_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "pageRenderQueue" ADD CONSTRAINT "pageRenderQueue_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "pageRenderQueue" ADD CONSTRAINT "pageRenderQueue_requestedById_users_id_fkey" FOREIGN KEY ("requestedById") REFERENCES "users"("id") ON DELETE SET NULL;

File diff suppressed because it is too large Load Diff

@ -531,6 +531,46 @@ export const pageWatching = pgTable(
] ]
) )
// PAGE RENDER QUEUE -------------------
/**
* A page waiting for the server to render it, one row per page.
*
* The markdown pipeline lives in the frontend, so rendering a page here means driving a headless
* browser too heavy to hold a request open for, and ruinous to do several times at once. A row is a
* request for a render, and the `renderPages` task drains the table one page at a time through a
* single browser (`models/rendering.ts`).
*
* A row IS the request, so asking twice for the same page updates the row instead of adding a second:
* what gets rendered is the content as it stands when the browser reaches it, and rendering it twice
* would produce the same HTML. `createdAt` keeps its place in the queue across those repeats.
*
* The two permissions travel with the row because a render is sanitized against what the person who
* asked for it may embed, and by the time the job runs there is no session left to ask.
*/
export const pageRenderQueue = pgTable(
'pageRenderQueue',
{
id: uuid().primaryKey().defaultRandom(),
/** `write:scripts` — whether this render may keep `<script>` and inline handlers. */
allowScripts: boolean().notNull().default(false),
/** `write:styles` — whether this render may keep `<style>` and inline `style` attributes. */
allowStyles: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
pageId: uuid()
.notNull()
.unique()
.references(() => pages.id, { onDelete: 'cascade' }),
siteId: uuid()
.notNull()
.references(() => sites.id),
// -> Only ever logged, and a deleted account is no reason to drop a render somebody is waiting for
requestedById: uuid().references(() => users.id, { onDelete: 'set null' })
},
// -> How the drain picks what to render next
(table) => [index('pageRenderQueue_createdAt_idx').on(table.createdAt)]
)
// RATE LIMITS ------------------------- // RATE LIMITS -------------------------
/** /**
* One counter per rate-limited client, and the ban it has earned itself. * One counter per rate-limited client, and the ban it has earned itself.

@ -0,0 +1,53 @@
import type { PoolClient } from 'pg'
/**
* A `pg_notify` sender for one LISTEN/NOTIFY client.
*
* Sending is fire-and-forget by design see `createNotifier` for why that has to be arranged rather
* than simply left unawaited.
*/
export interface Notifier {
/** Queue a notification behind whatever is already going out. Never throws. */
send(channel: string, payload: string): void
/** Resolves once everything queued so far has gone out, for an orderly shutdown. */
drained(): Promise<void>
}
/**
* Serialize the notifications sent on a dedicated LISTEN/NOTIFY client.
*
* Three modules hold such a client the event bus (`core/db.ts`), the scheduler and collaborative
* editing and all three publish from places that cannot wait for a round trip to postgres: an
* Emittery listener, a job being picked up, a Yjs handler reacting to a keystroke. So none of them
* awaits the `pg_notify`.
*
* Handing an unawaited query to a client that is already running one is exactly what `pg` deprecated
* in 8.x and removes in 9.0. It queues them internally today, which is why this went unnoticed: the
* only symptom is a `DeprecationWarning`, and `util.deprecate` emits it once per process however often
* it happens. Queueing them here instead costs nothing the round trips were already serialized, only
* silently and on the way out.
*
* Every notification carries its own `catch`, rather than one at the end of the chain: a failure to
* publish belongs to the message that failed, and must not stop the ones behind it from going out.
*
* @param client Read on each send, since the client is opened after this is built and dropped at
* shutdown. A notification sent while there is none is discarded.
* @param label What these notifications are, for the log line when one cannot be sent
*/
export function createNotifier(client: () => PoolClient | null, label: string): Notifier {
let tail: Promise<void> = Promise.resolve()
return {
send(channel: string, payload: string): void {
tail = tail.then(async () => {
try {
await client()?.query('SELECT pg_notify($1, $2)', [channel, payload])
} catch (err: any) {
WIKI.logger.warn(`Failed to publish a ${label} notification: ${err.message}`)
}
})
},
drained(): Promise<void> {
return tail
}
}
}

@ -13,6 +13,20 @@ const AUTH_DEFAULTS: RateLimitPolicy = {
banSeconds: 900 banSeconds: 900
} }
/**
* The limit on asking for a page to be rendered.
*
* Not configurable, unlike the authentication limit above: what this protects is the host rather than
* a secret, and no deployment has a reason to raise it. How many browsers run at once is settled by
* the render queue rather than here this only keeps one client from filling that queue faster than
* anything could drain it. Ten in five minutes is far more than re-rendering a stale page takes.
*/
const RENDER_LIMIT: RateLimitPolicy = {
max: 10,
windowSeconds: 300,
banSeconds: 300
}
/** /**
* The configured policy. * The configured policy.
* *
@ -69,3 +83,35 @@ export async function limitAuthAttempts(req: FastifyRequest, reply: FastifyReply
`Too many attempts. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).` `Too many attempts. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).`
) )
} }
/**
* Refuse a request to render a page once a client has made too many.
*
* Written as a per-route `preHandler` hook `{ preHandler: limitRenders, schema: … }` so that the
* route it guards says so where it is declared. It runs after the session is decoded, which is what
* lets it count per user rather than per address: the endpoint needs a session, and unlike a password
* guess the cost is the caller's own, so an office behind a single address should not share a limit the
* way password guessers are made to.
*
* `manage:system` is exempt, as it is everywhere. Re-rendering every page after a markdown config
* change is an operator's job, and a root admin who wants the server busy has easier ways.
*/
export async function limitRenders(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (req.session?.permissions?.includes('manage:system')) {
return
}
const verdict = await WIKI.models.rateLimits.consume(
`render:${req.session?.user?.id ?? req.ip}`,
RENDER_LIMIT
)
if (verdict.allowed) {
return
}
WIKI.logger.debug(
`Rate limit: refused ${req.method} ${req.url} from ${req.ip}, ${verdict.retryAfter}s left of its ban.`
)
reply.header('Retry-After', String(verdict.retryAfter))
return reply.tooManyRequests(
`Too many render requests. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).`
)
}

@ -167,6 +167,10 @@ async function postBoot() {
// handshake reads the per-site feature toggle from. // handshake reads the per-site feature toggle from.
await WIKI.collab.init() await WIKI.collab.init()
await WIKI.scheduler.start() await WIKI.scheduler.start()
// -> A page queued for rendering when this instance went down is still queued, and nothing looks at
// that table until somebody asks for another render. Costs one query when there is nothing to do.
await WIKI.scheduler.addJob({ task: 'renderPages', maxRetries: 0 })
} }
// ---------------------------------------- // ----------------------------------------

@ -2261,8 +2261,8 @@
"profile.title": "Profile", "profile.title": "Profile",
"profile.uploadNewAvatar": "Upload New Image", "profile.uploadNewAvatar": "Upload New Image",
"profile.viewPublicProfile": "View Public Profile", "profile.viewPublicProfile": "View Public Profile",
"renderPageDialog.loading": "Rendering page...", "renderPageDialog.loading": "Queueing page render...",
"renderPageDialog.success": "Page rerendered successfully.", "renderPageDialog.queued": "Page queued for rendering. Reload in a moment to see the result.",
"search.editorAny": "Any editor", "search.editorAny": "Any editor",
"search.emptyQuery": "Enter a query in the search field above and press Enter.", "search.emptyQuery": "Enter a query in the search field above and press Enter.",
"search.failed": "Failed to perform search query.", "search.failed": "Failed to perform search query.",

@ -725,17 +725,24 @@ class Approvals {
the same way the editor does on any other save, and it arrives with the approval. 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. 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: That one needs the Puppeteer extension and says so before the content is written if it is
the alternative is quietly leaving a stale render on a page somebody just changed. missing, rather than leaving a stale render on a page somebody just changed with no prospect of
it being corrected.
*/ */
const config = WIKI.sites[siteId]?.config?.editors?.[page.editor]?.config ?? {} if (!render) {
const html = await WIKI.models.rendering.ensureCanRender(page.editor)
render ?? }
(await WIKI.models.rendering.renderContent(content, { await WIKI.models.pages.updatePage(
editor: page.editor, siteId,
config page.id,
})) { content, ...(render && { render }) },
await WIKI.models.pages.updatePage(siteId, page.id, { content, render: html }, actor) actor
)
if (!render) {
// -> Briefly stale rather than wrong: the browser is a queue away, and a suggestion approved
// while it is busy waits its turn instead of starting a second one
await WIKI.models.pages.queueRerender(siteId, page.id, actor)
}
await WIKI.db.delete(submissionsTable).where(eq(submissionsTable.id, submissionId)) await WIKI.db.delete(submissionsTable).where(eq(submissionsTable.id, submissionId))
WIKI.logger.debug(`Approved edit suggestion ${submissionId} onto page ${page.id}`) WIKI.logger.debug(`Approved edit suggestion ${submissionId} onto page ${page.id}`)

@ -1,7 +1,7 @@
import { and, eq, ne, sql } from 'drizzle-orm' import { and, eq, ne, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts' import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import { CustomError, generatePathHash, timingSafeCompare } from '../helpers/common.ts' import { CustomError, generatePathHash, timingSafeCompare } from '../helpers/common.ts'
import type { TocNode } from './rendering.ts' import type { RenderPermissions, TocNode } from './rendering.ts'
/** What each editor produces, which is what the content column holds. */ /** What each editor produces, which is what the content column holds. */
const EDITOR_CONTENT_TYPES: Record<string, string> = { const EDITOR_CONTENT_TYPES: Record<string, string> = {
@ -739,39 +739,65 @@ class Pages {
} }
/** /**
* Render a page again from its source, without going through an editor. * Ask for a page to be rendered again from its source, without going through an editor.
* *
* Needed when a stored render has gone stale the markdown config changed, or the renderer itself * Needed when a stored render has gone stale the markdown config changed, or the renderer itself
* did and there is nobody with the page open to re-save it. The rendering goes through the very * did and there is nobody with the page open to re-save it. The rendering goes through the very
* same frontend pipeline, driven in a headless browser, so the result is what the editor would have * same frontend pipeline, driven in a headless browser, so the result is what the editor would have
* produced. * produced; because that costs a browser it is queued rather than done here, one page at a time
* across the whole instance. See `models/rendering.ts`.
*
* What the render may carry is settled here, while there is still an actor to ask, and travels with
* the queued request.
*
* @returns False when there is no such page
* @throws `renderUnsupportedEditor` for a page the server cannot render, or
* `renderPuppeteerMissing` when nothing here could drain the queue
*/ */
async rerenderPage(siteId: string, id: string, actor: PageActor): Promise<Page | null> { async queueRerender(siteId: string, id: string, actor: PageActor): Promise<boolean> {
const page = await this.getPage({ siteId, id, withContent: true }) const page = await this.getPage({ siteId, id })
if (!page) { if (!page) {
return null return false
} }
await WIKI.models.rendering.ensureCanRender(page.editor)
const config = WIKI.sites[siteId]?.config?.editors?.[page.editor]?.config ?? {} await WIKI.models.rendering.queuePage({
const html = await WIKI.models.rendering.renderContent(page.content ?? '', { siteId,
editor: page.editor, pageId: page.id,
config permissions: {
}) scripts: hasPermission(actor, 'write:scripts'),
// -> Post-processed like any other render: it came from a browser either way, and the author's styles: hasPermission(actor, 'write:styles')
// permissions are still what decides what a page may carry },
const { render, toc, text } = WIKI.models.rendering.postProcess(html, { requestedById: actor.id
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
}) })
return true
}
await WIKI.db /**
* Store HTML the renderer produced for a page, and re-index it.
*
* The counterpart to `queueRerender`: the drain calls this once the browser has been through the
* content. Post-processed like any other render it came from a browser either way against the
* permissions the person who asked for it had.
*/
async storeRender(
siteId: string,
id: string,
html: string,
permissions: RenderPermissions
): Promise<void> {
const { render, toc, text } = WIKI.models.rendering.postProcess(html, permissions)
const updated = await WIKI.db
.update(pagesTable) .update(pagesTable)
.set({ render, toc, searchContent: text, updatedAt: sql`now()` }) .set({ render, toc, searchContent: text, updatedAt: sql`now()` })
.where(eq(pagesTable.id, id)) .where(and(eq(pagesTable.id, id), eq(pagesTable.siteId, siteId)))
.returning({ locale: pagesTable.locale })
await WIKI.models.search.indexPage(id, page.locale)
return this.getPage({ siteId, id }) // -> Nothing was updated when the page went while it sat in the queue
if (updated[0]) {
await WIKI.models.search.indexPage(id, updated[0].locale)
}
} }
/** /**

@ -1,5 +1,7 @@
import * as cheerio from 'cheerio' import * as cheerio from 'cheerio'
import sanitizeHtml from 'sanitize-html' import sanitizeHtml from 'sanitize-html'
import { eq, inArray, sql } from 'drizzle-orm'
import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts' import { CustomError } from '../helpers/common.ts'
/** /**
@ -22,9 +24,18 @@ import { CustomError } from '../helpers/common.ts'
* *
* Re-rendering an existing page from its source which the server needs when the content is there * Re-rendering an existing page from its source which the server needs when the content is there
* but the render is stale goes back through the very same frontend pipeline, driven in a headless * but the render is stale goes back through the very same frontend pipeline, driven in a headless
* browser. See `renderContent`. * browser. That is a job rather than part of a request: see `queuePage` and `drainQueue`.
*/ */
/** How long the renderer bundle gets to load itself in the headless browser, in milliseconds. */
const RENDER_READY_TIMEOUT = 30000
/** How long a single render gets once the bundle is up, in milliseconds. */
const RENDER_TIMEOUT = 30000
/** The task that drains the render queue. One browser, one page at a time. */
const DRAIN_TASK = 'renderPages'
/** A heading in the table of contents, shaped for the Quasar tree the page sidebar draws. */ /** A heading in the table of contents, shaped for the Quasar tree the page sidebar draws. */
export interface TocNode { export interface TocNode {
key: string key: string
@ -49,6 +60,18 @@ export interface PostProcessResult {
text: string text: string
} }
/**
* A headless browser standing by on the renderer bundle, good for any number of pages.
*
* Opening one is the expensive part of rendering, so it is handed out as a handle to be reused and
* closed by whoever asked for it rather than opened per page.
*/
interface PageRenderer {
/** Markdown in, the editor's own HTML out — before `postProcess` gets to it. */
render(content: string, config: Record<string, any>): Promise<string>
close(): Promise<void>
}
/** What the author is allowed to put in a page, beyond ordinary content. */ /** What the author is allowed to put in a page, beyond ordinary content. */
export interface RenderPermissions { export interface RenderPermissions {
/** `write:scripts` — may embed `<script>` and inline event handlers. */ /** `write:scripts` — may embed `<script>` and inline event handlers. */
@ -452,36 +475,242 @@ class Rendering {
} }
/** /**
* Render content to HTML the way the editor would, in a headless browser. * Whether this instance can render a page at all.
* *
* The markdown pipeline lives in the frontend and stays there this drives it rather than * Puppeteer is an extension, and one that is not installed by default: rendering server-side is the
* reimplementing it, so a page re-rendered by the server comes out identical to one saved from the * only thing that needs it, and everything else keeps working without it.
* editor. That costs a browser, which is why it is reserved for an explicit re-render rather than */
* used on every save. async isAvailable(): Promise<boolean> {
const definition = WIKI.models.extensions.getDefinition('puppeteer')
return Boolean(definition) && (await WIKI.models.extensions.isInstalled(definition!))
}
/**
* Refuse the caller when a page like this one cannot be rendered here.
* *
* Puppeteer is an extension, and one that is not installed by default. When it is missing this says * Asked before anything is queued or written rather than left to the job: a request that joins a
* so plainly: re-rendering is the only thing that needs it, and everything else keeps working. * queue nothing will ever drain looks like it worked, and an approval that cannot produce a matching
* render would leave a page's HTML lying about its content.
*/ */
async renderContent( async ensureCanRender(editor: string): Promise<void> {
content: string,
{ editor, config }: { editor: string; config: Record<string, any> }
): Promise<string> {
if (editor !== 'markdown') { if (editor !== 'markdown') {
throw new CustomError( throw new CustomError(
'renderUnsupportedEditor', 'renderUnsupportedEditor',
`Server-side rendering is not implemented for the ${editor} editor.` `Server-side rendering is not implemented for the ${editor} editor.`
) )
} }
if (!(await this.isAvailable())) {
const definition = WIKI.models.extensions.getDefinition('puppeteer')
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
throw new CustomError( throw new CustomError(
'renderPuppeteerMissing', 'renderPuppeteerMissing',
'Re-rendering a page on the server needs the Puppeteer extension, which is not installed.', 'Rendering a page on the server needs the Puppeteer extension, which is not installed.',
503 503
) )
} }
}
/**
* Ask for a page to be rendered, and make sure something will come along to do it.
*
* The row is the request and there is only ever one per page, so asking repeatedly a queue of
* suggestions being approved onto the same page, an impatient author collapses into one render of
* whatever the content has become. `createdAt` is left alone on that path, since a repeat request is
* not a new one and must not overtake pages that have been waiting longer.
*
* The drain job is only added when the queue has none pending, and a spare one is harmless anyway:
* it finds the table empty and returns without so much as launching a browser.
*/
async queuePage({
siteId,
pageId,
permissions,
requestedById
}: {
siteId: string
pageId: string
permissions: RenderPermissions
requestedById?: string | null
}): Promise<void> {
await WIKI.db
.insert(renderQueueTable)
.values({
siteId,
pageId,
allowScripts: permissions.scripts,
allowStyles: permissions.styles,
requestedById: requestedById ?? null
})
.onConflictDoUpdate({
target: renderQueueTable.pageId,
set: {
allowScripts: permissions.scripts,
allowStyles: permissions.styles,
requestedById: requestedById ?? null,
updatedAt: sql`now()`
}
})
const pending = await WIKI.db
.select({ id: jobsTable.id })
.from(jobsTable)
.where(eq(jobsTable.task, DRAIN_TASK))
.limit(1)
if (pending.length < 1) {
// -> No retries: a render nobody can produce is not worth attempting three times, and the row
// stays queued for the next drain either way
await WIKI.scheduler.addJob({ task: DRAIN_TASK, maxRetries: 0 })
}
}
/**
* Render every queued page, one at a time, through a single browser.
*
* This is the whole point of the queue: a browser costs hundreds of megabytes, so there is exactly
* one, it is opened when the first page is claimed and reused for the rest of the batch, and no two
* renders overlap. The scheduler cannot promise that on its own it runs up to
* `scheduler.workers` jobs at once so a second call while this is running does not start a second
* browser. It asks the one already going to look again before it stops, which is what stops a page
* queued in the moment between the last claim and the end of the drain from waiting for the next
* request to come along.
*/
async drainQueue(): Promise<void> {
if (this.draining) {
this.drainRequested = true
return
}
this.draining = true
try {
do {
this.drainRequested = false
await this.renderQueuedPages()
} while (this.drainRequested)
} finally {
this.draining = false
}
}
/** True while `drainQueue` is working, so that a second call joins it instead of duplicating it. */
private draining = false
/** Set when a drain is asked for during one, and re-checked before the running drain gives up. */
private drainRequested = false
/**
* The drain itself: claim a page, render it, store it, repeat until the queue is empty.
*
* Claiming is a delete, so an instance can never pick up a page another one is already rendering,
* and a render that fails is a render that was asked for and did not happen logged, with the page
* keeping the HTML it had. Re-queueing it here would be a loop, since whatever made it fail is still
* true.
*
* A failure also drops the browser rather than trusting it: the likeliest one is a render that ran
* out of time, which leaves a page wedged in whatever loop it was in, and the pages behind it in the
* queue have done nothing to deserve that.
*/
private async renderQueuedPages(): Promise<void> {
// -> Asked before anything else so that the common drain — a spare job for a batch already swept —
// costs one query and says nothing
const waiting = await WIKI.db
.select({ id: renderQueueTable.id })
.from(renderQueueTable)
.limit(1)
if (waiting.length < 1) {
return
}
if (!(await this.isAvailable())) {
WIKI.logger.warn(
'Pages are queued for rendering but the Puppeteer extension is not installed. Leaving them queued.'
)
return
}
let renderer: PageRenderer | null = null
try {
while (true) {
/*
Deliberately outside the per-page catch below, and ahead of the claim: a browser that will
not open is not this page's fault and will not be the next one's either. Letting that throw
ends the drain with the queue untouched, where treating it as a page failure would burn
through every row in it and claiming is a delete.
*/
renderer ??= await this.createRenderer()
const claimed = await WIKI.db
.delete(renderQueueTable)
.where(
inArray(
renderQueueTable.id,
sql`(SELECT id FROM "pageRenderQueue" ORDER BY "createdAt" FOR UPDATE SKIP LOCKED LIMIT 1)`
)
)
.returning()
const entry = claimed[0]
if (!entry) {
return
}
try {
const page = await WIKI.models.pages.getPage({
siteId: entry.siteId,
id: entry.pageId,
withContent: true
})
if (!page) {
// -> Deleted while it waited. The cascade takes the row with it, so this is only reachable
// for a page that went between the claim and here.
continue
}
if (page.editor !== 'markdown') {
WIKI.logger.warn(
`Cannot render page ${page.id}: server-side rendering is not implemented for the ${page.editor} editor.`
)
continue
}
const html = await renderer.render(
page.content ?? '',
WIKI.sites[entry.siteId]?.config?.editors?.[page.editor]?.config ?? {}
)
await WIKI.models.pages.storeRender(entry.siteId, page.id, html, {
scripts: entry.allowScripts,
styles: entry.allowStyles
})
WIKI.logger.debug(`Rendered page ${page.id} (${page.path}) from its source.`)
} catch (err: any) {
WIKI.logger.warn(`Failed to render page ${entry.pageId}: ${err.message}`)
await this.discardRenderer(renderer)
renderer = null
}
}
} finally {
await this.discardRenderer(renderer)
}
}
/**
* Close a renderer, and keep any trouble doing so to itself.
*
* Every close happens on a path that is already finished with the browser most of them right after
* a render failed, which is exactly when it is likeliest to be gone already. Letting that failure
* out would replace the real one, or fail a drain that had otherwise finished its work.
*/
private async discardRenderer(renderer: PageRenderer | null): Promise<void> {
try {
await renderer?.close()
} catch (err: any) {
WIKI.logger.debug(`Could not close the render browser cleanly: ${err.message}`)
}
}
/**
* Open a headless browser on the renderer bundle and hand back something that renders through it.
*
* The markdown pipeline lives in the frontend and stays there this drives it rather than
* reimplementing it, so a page rendered by the server comes out identical to one saved from the
* editor.
*
* One tab is enough for any number of pages: `__wikiRender` builds a fresh renderer per call and
* returns a string, so nothing carries over between them but the bundle's own warm caches.
*/
private async createRenderer(): Promise<PageRenderer> {
// -> Held in a variable because Puppeteer is not a declared dependency: it is an extension the // -> Held in a variable because Puppeteer is not a declared dependency: it is an extension the
// operator installs, so a literal import would not typecheck // operator installs, so a literal import would not typecheck
const specifier = 'puppeteer' const specifier = 'puppeteer'
@ -508,16 +737,57 @@ class Rendering {
await page.goto(`http://127.0.0.1:${WIKI.config.port}/_render`, { await page.goto(`http://127.0.0.1:${WIKI.config.port}/_render`, {
waitUntil: 'networkidle0' waitUntil: 'networkidle0'
}) })
await page.waitForFunction('window.__wikiRenderReady === true', { timeout: 30000 }) await page.waitForFunction('window.__wikiRenderReady === true', {
// -> This callback is serialized and runs in the browser, where `globalThis` is the window the timeout: RENDER_READY_TIMEOUT
// renderer bundle attached itself to })
return await page.evaluate(
(src: string, cfg: Record<string, any>) => (globalThis as any).__wikiRender(src, cfg), return {
content, async render(content: string, config: Record<string, any>): Promise<string> {
config /*
) `page.evaluate` has no timeout of its own, and what it calls is a synchronous pass over
} finally { content somebody else wrote: an input that sends one of the markdown plugins into
await browser.close() catastrophic backtracking would otherwise hold the browser open for as long as it runs, and
every page behind it in the queue with it. Losing the race throws, and the caller closes
this renderer rather than reusing a tab that is still busy.
*/
let timer: ReturnType<typeof setTimeout> | undefined
const expiry = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() =>
reject(
new CustomError(
'renderTimeout',
`Rendering did not finish within ${RENDER_TIMEOUT / 1000} seconds.`,
504
)
),
RENDER_TIMEOUT
)
})
try {
// -> This callback is serialized and runs in the browser, where `globalThis` is the window
// the renderer bundle attached itself to
const render = page.evaluate(
(src: string, cfg: Record<string, any>) => (globalThis as any).__wikiRender(src, cfg),
content,
config
)
return await Promise.race([render, expiry])
} finally {
clearTimeout(timer)
}
},
async close(): Promise<void> {
await browser.close()
}
}
} catch (err: any) {
// -> The browser is up but unusable, and nothing else holds a reference to it. Whatever went
// wrong loading the bundle is the failure worth reporting, not whatever closing says about it.
try {
await browser.close()
} catch {}
throw err
} }
} }
} }

@ -0,0 +1,12 @@
/**
* Render every page waiting in the render queue.
*
* Queued whenever a page is added to `pageRenderQueue` by an explicit re-render, or by an approved
* suggestion that arrived without its HTML. The work is deliberately not split across jobs: rendering
* means driving a headless browser, and the whole point of draining the queue in one task is that
* there is one browser and it renders one page at a time. A run that finds the queue empty (a second
* job for a batch this one already swept) returns without launching anything.
*/
export async function task(): Promise<void> {
await WIKI.models.rendering.drainQueue()
}

@ -4,7 +4,9 @@ LABEL maintainer="requarks.io"
RUN apt-get update && apt-get install -qy --no-install-recommends \ RUN apt-get update && apt-get install -qy --no-install-recommends \
bash \ bash \
build-essential \ build-essential \
chromium \
curl \ curl \
fonts-liberation \
git \ git \
gnupg \ gnupg \
openssh-client \ openssh-client \
@ -27,9 +29,22 @@ USER node
ENV NODE_ENV=production ENV NODE_ENV=production
# The browser the Puppeteer extension drives, installed above rather than downloaded by Puppeteer: the
# distro keeps it patched, it exists for arm64 as well as amd64, and the image does not carry two copies
# of Chromium. `PUPPETEER_SKIP_DOWNLOAD` has to be set before the install below for that to hold.
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /wiki/server WORKDIR /wiki/server
RUN pnpm install --prod --frozen-lockfile RUN pnpm install --prod --frozen-lockfile
# The Puppeteer extension, which server-side page rendering needs. Added here rather than declared in
# `backend/package.json` because it is an optional extension: an installation that renders its pages in
# the editor -- which is all of them, on any normal save -- has no use for a browser on the server, and
# a source checkout should not have to fetch one to install the backend. Pinned like every other
# dependency, so an image build is reproducible.
RUN pnpm add puppeteer@25.4.0
# Set extensions as installed # Set extensions as installed
RUN touch node_modules/sharp/wiki_installed.txt RUN touch node_modules/sharp/wiki_installed.txt

@ -114,6 +114,7 @@ import { notify } from '@/composables/notify'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import ApiKeyCopyDialog from './ApiKeyCopyDialog.vue' import ApiKeyCopyDialog from './ApiKeyCopyDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// EMITS // EMITS
@ -223,13 +224,9 @@ async function create() {
onDialogOK() onDialogOK()
}) })
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -41,6 +41,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { reactive } from 'vue' import { reactive } from 'vue'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -85,13 +86,9 @@ async function confirm() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a key revoked from another tab answers 409 // -> ky throws above 400 a key revoked from another tab answers 409
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -137,6 +137,7 @@ import { computed, reactive, ref } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -274,11 +275,7 @@ async function save() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: message: apiErrorMessage(err)
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
}) })
} }
state.isLoading = false state.isLoading = false

@ -46,6 +46,7 @@ import { notify } from '@/composables/notify'
import { reactive } from 'vue' import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -95,13 +96,9 @@ async function confirm() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 an asset deleted from another tab answers 404 // -> ky throws above 400 an asset deleted from another tab answers 404
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -52,6 +52,7 @@ import { notify } from '@/composables/notify'
import { onMounted, reactive } from 'vue' import { onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -109,13 +110,9 @@ async function rename() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a name already taken in this folder answers 409 // -> ky throws above 400 a name already taken in this folder answers 409
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -132,13 +129,9 @@ onMounted(async () => {
} }
state.path = asset.fileName state.path = asset.fileName
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
onDialogCancel() onDialogCancel()
} }

@ -354,6 +354,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { apiErrorMessage } from '@/helpers/apiError'
import { localizeError } from '@/helpers/localization' import { localizeError } from '@/helpers/localization'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -500,20 +501,6 @@ const userPasswordVerifyValidation = [
// METHODS // METHODS
/**
* The reason the API gave, untranslated: the `ERR_*` code out of a response ky threw on (anything
* above 400), or the error's own message when the request never got an answer. Kept as the raw code so
* that callers can both display it and act on it.
*/
async function apiError(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function switchTo(screen) { function switchTo(screen) {
switch (screen) { switch (screen) {
case 'login': { case 'login': {
@ -662,7 +649,7 @@ async function login() {
loading.hide() loading.hide()
notify({ notify({
type: 'negative', type: 'negative',
message: localizeError(await apiError(err), t) message: localizeError(apiErrorMessage(err), t)
}) })
} }
} }
@ -702,7 +689,7 @@ async function loginWithPasskey() {
} }
notify({ notify({
type: 'negative', type: 'negative',
message: localizeError(await apiError(err), t) message: localizeError(apiErrorMessage(err), t)
}) })
} }
} }
@ -814,7 +801,7 @@ async function changePwd() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: localizeError(await apiError(err), t) message: localizeError(apiErrorMessage(err), t)
}) })
} }
} }
@ -854,7 +841,7 @@ async function submitTFA(setup) {
* no way forward. * no way forward.
*/ */
async function handleTFAError(err) { async function handleTFAError(err) {
const code = await apiError(err) const code = apiErrorMessage(err)
loading.hide() loading.hide()
notify({ notify({
type: 'negative', type: 'negative',

@ -96,6 +96,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { localizeError } from '@/helpers/localization' import { localizeError } from '@/helpers/localization'
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
@ -219,11 +220,7 @@ async function save() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: message: localizeError(apiErrorMessage(err), t)
(await err.response
?.json()
.then((b) => localizeError(b?.message, t))
.catch(() => null)) ?? err.message
}) })
} }
state.isLoading = false state.isLoading = false

@ -100,6 +100,7 @@ import { notify } from '@/composables/notify'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -167,14 +168,10 @@ async function save() {
close() close()
} catch (err) { } catch (err) {
// -> ky throws above 400, with the reason in the body // -> ky throws above 400, with the reason in the body
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: 'Failed to save Markdown editor settings.', message: 'Failed to save Markdown editor settings.',
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -428,6 +428,7 @@ import { filesize } from 'filesize'
import Fuse from 'fuse.js/basic' import Fuse from 'fuse.js/basic'
import NewMenu from './PageNewMenu.vue' import NewMenu from './PageNewMenu.vue'
import Tree from './TreeNav.vue' import Tree from './TreeNav.vue'
import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes' import fileTypes from '@/helpers/fileTypes'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue' import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue' import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
@ -689,18 +690,6 @@ function close() {
siteStore.overlay = null siteStore.overlay = null
} }
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage(err, fallback) {
const message = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
return message || err.message || fallback
}
function formatDateTime(value) { function formatDateTime(value) {
if (!value) { if (!value) {
return '' return ''
@ -835,7 +824,7 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
notify({ notify({
type: 'negative', type: 'negative',
message: 'Failed to load folder tree.', message: 'Failed to load folder tree.',
caption: await apiErrorMessage(err, 'An unexpected error occured.') caption: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
if (parentId === state.currentFolderId) { if (parentId === state.currentFolderId) {
@ -1051,7 +1040,7 @@ async function uploadNewFiles() {
notify({ notify({
type: 'negative', type: 'negative',
message: 'Failed to upload file.', message: 'Failed to upload file.',
caption: await apiErrorMessage(err, 'An unexpected error occured.') caption: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
state.loading-- state.loading--
@ -1160,7 +1149,7 @@ async function downloadItem(item) {
notify({ notify({
type: 'negative', type: 'negative',
message: 'Failed to download file.', message: 'Failed to download file.',
caption: await apiErrorMessage(err, 'An unexpected error occured.') caption: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
} }

@ -69,6 +69,7 @@ import { reactive, ref, watch } from 'vue'
import slugify from 'slugify' import slugify from 'slugify'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -163,13 +164,9 @@ async function create() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a name already taken in this folder answers 409 // -> ky throws above 400 a name already taken in this folder answers 409
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -45,6 +45,7 @@ import { reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -94,13 +95,9 @@ async function confirm() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a folder deleted from another tab answers 404 // -> ky throws above 400 a folder deleted from another tab answers 404
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -69,6 +69,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
import slugify from 'slugify' import slugify from 'slugify'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -163,13 +164,9 @@ async function rename() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a name already taken alongside this folder answers 409 // -> ky throws above 400 a name already taken alongside this folder answers 409
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -190,13 +187,9 @@ onMounted(async () => {
state.title = folder.title state.title = folder.title
state.pathDirty = true state.pathDirty = true
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
onDialogCancel() onDialogCancel()
} }

@ -42,6 +42,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -80,13 +81,9 @@ async function confirm() {
} catch (err) { } catch (err) {
// -> ky throws for statuses above 400 (e.g. 409 for a system group), where the reason the API // -> ky throws for statuses above 400 (e.g. 409 for a system group), where the reason the API
// gave is in the response body rather than in the error message // gave is in the response body rather than in the error message
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
} }

@ -606,6 +606,7 @@ import { useSiteStore } from '@/stores/site'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { fileOpen, fileSave } from 'browser-fs-access' import { fileOpen, fileSave } from 'browser-fs-access'
import UserSearchDialog from '@/components/UserSearchDialog.vue' import UserSearchDialog from '@/components/UserSearchDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -1188,14 +1189,10 @@ function assignUser() {
assigned++ assigned++
} catch (err) { } catch (err) {
// -> ky throws above 400, with the reason in the body // -> ky throws above 400, with the reason in the body
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.groups.assignUserFailed', { userName: usr.name }), message: t('admin.groups.assignUserFailed', { userName: usr.name }),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
} }
@ -1229,13 +1226,9 @@ async function unassignUser(user) {
await refreshUsers() await refreshUsers()
} catch (err) { } catch (err) {
// -> ky throws above 400 (e.g. 409 for the last root admin), with the reason in the body // -> ky throws above 400 (e.g. 409 for the last root admin), with the reason in the body
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoadingUsers = false state.isLoadingUsers = false

@ -134,6 +134,7 @@ import { useDark } from '@/composables/dark'
import { debounce } from 'es-toolkit/function' import { debounce } from 'es-toolkit/function'
import { useClosePopup } from '@/composables/popup' import { useClosePopup } from '@/composables/popup'
import { apiErrorMessage } from '@/helpers/apiError'
// I18N // I18N
@ -206,18 +207,6 @@ watch(() => state.currentTab, focusCurrentTab)
// METHODS // METHODS
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage(err) {
return (
err.response
?.json()
.then((b) => b?.message)
.catch(() => null) ?? err.message
)
}
async function loadSets() { async function loadSets() {
try { try {
// -> Only enabled sets: a disabled one is not searchable, and its icons cannot be stored // -> Only enabled sets: a disabled one is not searchable, and its icons cannot be stored
@ -227,7 +216,7 @@ async function loadSets() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('iconPicker.setsFailed'), message: t('iconPicker.setsFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
} }
@ -252,7 +241,7 @@ async function search() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('iconPicker.searchFailed'), message: t('iconPicker.searchFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading = false state.loading = false
@ -283,7 +272,7 @@ async function apply() {
notify({ notify({
type: 'warning', type: 'warning',
message: t('iconPicker.materializeFailed', { icon: value }), message: t('iconPicker.materializeFailed', { icon: value }),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
} }

@ -126,6 +126,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes' import fileTypes from '@/helpers/fileTypes'
import Tree from '@/components/TreeNav.vue' import Tree from '@/components/TreeNav.vue'
@ -235,18 +236,6 @@ watch(
// METHODS // METHODS
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage(err, fallback) {
const message = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
return message || err.message || fallback
}
/** /**
* Loads one folder into the tree, and when that folder is the selected one into the list beside it. * Loads one folder into the tree, and when that folder is the selected one into the list beside it.
* *
@ -313,7 +302,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
notify({ notify({
type: 'negative', type: 'negative',
message: t('linkPicker.loadFailed'), message: t('linkPicker.loadFailed'),
caption: await apiErrorMessage(err, 'An unexpected error occured.') caption: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
if (parentId) { if (parentId) {

@ -99,6 +99,7 @@ import { notify } from '@/composables/notify'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -186,11 +187,7 @@ async function save() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: message: apiErrorMessage(err)
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) || err.message
}) })
} }
state.loading-- state.loading--

@ -454,6 +454,7 @@ import { v4 as uuid } from 'uuid'
import { pick } from 'es-toolkit/object' import { pick } from 'es-toolkit/object'
import { Sortable } from 'sortablejs-vue3' import { Sortable } from 'sortablejs-vue3'
import IconPickerDialog from '@/components/IconPickerDialog.vue' import IconPickerDialog from '@/components/IconPickerDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -579,18 +580,6 @@ function close() {
siteStore.$patch({ overlay: '' }) siteStore.$patch({ overlay: '' })
} }
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage(err) {
const message = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
return message || err.message || 'An unexpected error occured.'
}
async function loadGroups() { async function loadGroups() {
state.loading++ state.loading++
try { try {
@ -601,7 +590,7 @@ async function loadGroups() {
notify({ notify({
type: 'warning', type: 'warning',
message: t('navEdit.groupsFailed'), message: t('navEdit.groupsFailed'),
caption: await apiErrorMessage(err) caption: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
state.loading-- state.loading--
@ -648,7 +637,7 @@ async function loadMenuItems() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: await apiErrorMessage(err) message: apiErrorMessage(err, 'An unexpected error occured.')
}) })
close() close()
} }
@ -722,7 +711,7 @@ async function save() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: await apiErrorMessage(err) message: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
loading.hide() loading.hide()

@ -46,6 +46,7 @@ import { notify } from '@/composables/notify'
import { reactive } from 'vue' import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -95,13 +96,9 @@ async function confirm() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a page deleted from another tab answers 404 // -> ky throws above 400 a page deleted from another tab answers 404
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -330,6 +330,7 @@ import { useUserStore } from '@/stores/user'
import CollabPresence from '@/components/CollabPresence.vue' import CollabPresence from '@/components/CollabPresence.vue'
import IconPickerDialog from '@/components/IconPickerDialog.vue' import IconPickerDialog from '@/components/IconPickerDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
/** /**
* How long the bell swings for, in milliseconds. Matches the `w-bell-ring` animation below the class * How long the bell swings for, in milliseconds. Matches the `w-bell-ring` animation below the class
@ -696,11 +697,7 @@ async function submitSuggestionCommit(guest = {}) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('common.page.suggestSubmitFailed'), message: t('common.page.suggestSubmitFailed'),
caption: caption: apiErrorMessage(err)
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
}) })
} }
loading.hide() loading.hide()

@ -258,6 +258,7 @@ import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { apiErrorMessage } from '@/helpers/apiError'
/** /**
* Everything that ever happened to a page, and the difference between any two moments of it. * Everything that ever happened to a page, and the difference between any two moments of it.
@ -367,16 +368,6 @@ function close() {
siteStore.$patch({ overlay: '' }) siteStore.$patch({ overlay: '' })
} }
/** 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) { function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, { return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
@ -486,7 +477,7 @@ async function withVersion(version) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('history.loadFailed'), message: t('history.loadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
return null return null
} finally { } finally {
@ -598,7 +589,7 @@ function restoreVersion(version) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('history.restoreFailed'), message: t('history.restoreFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} finally { } finally {
state.loading-- state.loading--
@ -658,7 +649,7 @@ function branchFrom(version) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('history.branchFailed'), message: t('history.branchFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} finally { } finally {
state.loading-- state.loading--
@ -731,7 +722,7 @@ async function applyDiff() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('history.loadFailed'), message: t('history.loadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} finally { } finally {
state.loading-- state.loading--
@ -761,7 +752,7 @@ async function load() {
state.bId = state.versions[0].id state.bId = state.versions[0].id
state.aId = state.versions[1]?.id ?? null state.aId = state.versions[1]?.id ?? null
} catch (err) { } catch (err) {
const caption = await apiMessage(err) const caption = apiErrorMessage(err)
state.notice = caption state.notice = caption
notify({ notify({
type: 'negative', type: 'negative',

@ -52,6 +52,7 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { fileSave } from 'browser-fs-access' import { fileSave } from 'browser-fs-access'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -134,12 +135,7 @@ async function load() {
state.contentType = pageData.contentType || pageData.editor || '' state.contentType = pageData.contentType || pageData.editor || ''
} catch (err) { } catch (err) {
const message = const message =
err.response?.status === 404 err.response?.status === 404 ? t('pageSource.notFound') : apiErrorMessage(err)
? t('pageSource.notFound')
: (await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) || err.message
state.notice = message state.notice = message
notify({ notify({
type: 'negative', type: 'negative',

@ -54,6 +54,7 @@ import { notify } from '@/composables/notify'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -112,11 +113,7 @@ watch(
notify({ notify({
type: 'warning', type: 'warning',
message: t('editor.props.tagsFailed'), message: t('editor.props.tagsFailed'),
caption: caption: apiErrorMessage(err)
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) || err.message
}) })
} finally { } finally {
state.loading = false state.loading = false

@ -15,7 +15,7 @@ import { onMounted } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { usePageStore } from '@/stores/page' import { apiErrorMessage } from '@/helpers/apiError'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
// PROPS // PROPS
@ -37,7 +37,6 @@ const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogCom
// STORES // STORES
const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -49,30 +48,20 @@ const { t } = useI18n()
async function rerenderPage() { async function rerenderPage() {
await new Promise((resolve) => setTimeout(resolve, 1000)) // allow for dialog to show await new Promise((resolve) => setTimeout(resolve, 1000)) // allow for dialog to show
try { try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages/${props.id}/render`).json() // -> Answers 202: rendering means a headless browser on the server, so the page joins a queue that
// -> The page currently on screen is the one that was re-rendered, so show the new render rather // is drained one page at a time and there is no new render to show yet
// than leaving the stale one until the next navigation await API_CLIENT.post(`sites/${siteStore.id}/pages/${props.id}/render`)
if (resp?.page?.id === pageStore.id) {
pageStore.$patch({
render: resp.page.render,
toc: resp.page.toc
})
}
notify({ notify({
type: 'positive', type: 'positive',
message: t('renderPageDialog.success') message: t('renderPageDialog.queued')
}) })
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 without the Puppeteer extension the server answers 503, since it has // -> ky throws above 400 without the Puppeteer extension the server answers 503, since it has
// no way to run the renderer // no way to run the renderer, and saying so is the whole point of showing this
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
onDialogCancel() onDialogCancel()
} }

@ -76,6 +76,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { copyToClipboard } from '@/helpers/clipboard' import { copyToClipboard } from '@/helpers/clipboard'
import { localizeError } from '@/helpers/localization' import { localizeError } from '@/helpers/localization'
import { computed, onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
@ -121,19 +122,6 @@ const groupedSecret = computed(() => state.tfaSecret.replace(/.{4}(?=.)/g, '$& '
// METHODS // METHODS
/**
* The reason the API gave, out of a response ky threw on (anything above 400) or out of the error
* itself when the request never got an answer. An `ERR_*` code is translated on the way out.
*/
async function apiMessage(err) {
const message =
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
return localizeError(message, t)
}
async function copySecret() { async function copySecret() {
try { try {
// -> Without the display grouping: a space is harmless in most authenticator apps, but not all // -> Without the display grouping: a space is harmless in most authenticator apps, but not all
@ -169,7 +157,7 @@ async function load() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: await apiMessage(err) message: apiErrorMessage(err)
}) })
onDialogCancel() onDialogCancel()
} }
@ -202,7 +190,7 @@ async function save() {
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: await apiMessage(err) message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -154,6 +154,7 @@ import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import Tree from '@/components/TreeNav.vue' import Tree from '@/components/TreeNav.vue'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -302,18 +303,6 @@ async function save() {
}) })
} }
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage(err, fallback) {
const message = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
return message || err.message || fallback
}
async function treeLazyLoad(nodeId, isCurrent, { done }) { async function treeLazyLoad(nodeId, isCurrent, { done }) {
await loadTree({ parentId: nodeId }) await loadTree({ parentId: nodeId })
done() done()
@ -420,7 +409,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
notify({ notify({
type: 'negative', type: 'negative',
message: t('pageSaveDialog.loadFailed'), message: t('pageSaveDialog.loadFailed'),
caption: await apiErrorMessage(err, 'An unexpected error occured.') caption: apiErrorMessage(err, 'An unexpected error occured.')
}) })
} }
if (parentId) { if (parentId) {

@ -26,6 +26,7 @@ import { computed, onMounted, reactive } from 'vue'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { apiErrorMessage } from '@/helpers/apiError'
// EMITS // EMITS
@ -98,13 +99,9 @@ onMounted(async () => {
EVENT_BUS.emit('reloadEditorContent', { replacements }) EVENT_BUS.emit('reloadEditorContent', { replacements })
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
onDialogCancel() onDialogCancel()
} }

@ -50,6 +50,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -99,13 +100,9 @@ async function confirm() {
own. The reason is in the body, so the dialog stays open with it rather than closing on a own. The reason is in the body, so the dialog stays open with it rather than closing on a
failure it did not report. failure it did not report.
*/ */
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isDeleting = false state.isDeleting = false

@ -44,6 +44,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { reactive } from 'vue' import { reactive } from 'vue'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -88,13 +89,9 @@ async function confirm() {
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
// -> ky throws above 400 a webhook deleted from another tab answers 404 // -> ky throws above 400 a webhook deleted from another tab answers 404
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -208,6 +208,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS // PROPS
@ -369,13 +370,9 @@ async function fetchHook(id) {
lastErrorMessage: resp.lastErrorMessage ?? '' lastErrorMessage: resp.lastErrorMessage ?? ''
} }
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
onDialogHide() onDialogHide()
} }
@ -399,13 +396,9 @@ async function create() {
}) })
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false
@ -428,13 +421,9 @@ async function save() {
}) })
onDialogOK() onDialogOK()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.isLoading = false state.isLoading = false

@ -0,0 +1,24 @@
/**
* The message a failed API request should be reported with.
*
* The server's own, when it sent one every `/_api` failure comes back as
* `{ ok, error, statusCode, message }` and ky's description of the request otherwise, which is all
* there is for a failure that never reached a route.
*
* Read off `err.data`, never `err.response`: ky parses the body itself to fill `data` before it throws,
* and that consumes it, so `err.response.json()` fails with "Body has already been read". Caught and
* discarded as it was everywhere this replaces that failure is indistinguishable from a response
* with no message, and the server's explanation gets quietly replaced by ky's generic "Request failed
* with status code 503". Which is why a wrong password, a name already taken and a missing extension
* all used to read the same.
*
* Synchronous, unlike the per-file helpers it replaces: with the body already parsed there is nothing
* left to wait for, so callers read it straight out of the catch.
*
* @param {Error} err The thrown error ky's `HTTPError`, or anything else that reached the catch
* @param {string} [fallback] Shown when neither the server nor ky offered anything
* @returns {string|undefined} What to put in front of the user
*/
export function apiErrorMessage(err, fallback) {
return err?.data?.message || err?.message || fallback
}

@ -157,6 +157,7 @@ import { useSiteStore } from '@/stores/site'
import ApiKeyCreateDialog from '../components/ApiKeyCreateDialog.vue' import ApiKeyCreateDialog from '../components/ApiKeyCreateDialog.vue'
import ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue' import ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -273,14 +274,10 @@ async function globalSwitch() {
}) })
await load() await load()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.api.toggleStateFailed'), message: t('admin.api.toggleStateFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.isToggleLoading = false state.isToggleLoading = false

@ -126,6 +126,7 @@ import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import ApprovalRuleDialog from '@/components/ApprovalRuleDialog.vue' import ApprovalRuleDialog from '@/components/ApprovalRuleDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -160,16 +161,6 @@ watch(() => adminStore.currentSiteId, load)
// METHODS // 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) { function matchLabel(match) {
return ( return (
{ {
@ -219,7 +210,7 @@ async function load() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.approval.loadFailed'), message: t('admin.approval.loadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -250,7 +241,7 @@ async function setEnabled(rule, isEnabled) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.approval.saveFailed'), message: t('admin.approval.saveFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
await load() await load()
} }
@ -304,7 +295,7 @@ function deleteRule(rule) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.approval.deleteFailed'), message: t('admin.approval.deleteFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -414,6 +414,7 @@ import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -609,18 +610,6 @@ function payloadFor(str) {
} }
} }
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage(err) {
return (
err.response
?.json()
.then((b) => b?.message)
.catch(() => null) ?? err.message
)
}
async function save() { async function save() {
if (state.loading > 0) { if (state.loading > 0) {
return return
@ -650,7 +639,7 @@ async function save() {
state.selectedStrategy = resp.id state.selectedStrategy = resp.id
} }
} catch (err) { } catch (err) {
failures.push({ name: str.displayName, message: await apiMessage(err) }) failures.push({ name: str.displayName, message: apiErrorMessage(err) })
} }
} }
@ -746,7 +735,7 @@ function confirmDelete() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.auth.deleteFailed'), message: t('admin.auth.deleteFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -117,6 +117,7 @@ import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { pick } from 'es-toolkit/object' import { pick } from 'es-toolkit/object'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -231,13 +232,9 @@ function deleteBlock(id) {
await load() await load()
} catch (err) { } catch (err) {
// -> ky throws above 400 (e.g. 409 for a built-in block), with the reason in the body // -> ky throws above 400 (e.g. 409 for a built-in block), with the reason in the body
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: apiMessage || err.message message: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -142,6 +142,7 @@ const editors = reactive([
{ {
id: 'asciidoc', id: 'asciidoc',
icon: 'asciidoc', icon: 'asciidoc',
isDisabled: true,
hasConfig: true, hasConfig: true,
useRendering: true useRendering: true
}, },
@ -172,6 +173,7 @@ const editors = reactive([
{ {
id: 'wysiwyg', id: 'wysiwyg',
icon: 'google-presentation', icon: 'google-presentation',
isDisabled: true,
useRendering: true useRendering: true
} }
]) ])

@ -120,6 +120,7 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -183,14 +184,10 @@ async function install(ext) {
await load() await load()
} catch (err) { } catch (err) {
// -> ky throws above 400 an extension that must be installed by hand answers 409 saying so // -> ky throws above 400 an extension that must be installed by hand answers 409 saying so
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.extensions.installFailed'), message: t('admin.extensions.installFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
loading.hide() loading.hide()

@ -150,6 +150,7 @@ import { useSiteStore } from '@/stores/site'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { omit } from 'es-toolkit/object' import { omit } from 'es-toolkit/object'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -217,14 +218,10 @@ async function save() {
await load() await load()
} catch (err) { } catch (err) {
// -> ky doesn't throw on 400, so the API's own message is on the response // -> ky doesn't throw on 400, so the API's own message is on the response
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.flags.saveFailed'), message: t('admin.flags.saveFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -284,6 +284,7 @@ import { notify } from '@/composables/notify'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -334,18 +335,6 @@ const filteredAvailableSets = computed(() => {
// METHODS // METHODS
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage(err) {
return (
err.response
?.json()
.then((b) => b?.message)
.catch(() => null) ?? err.message
)
}
function prettyBytes(bytes) { function prettyBytes(bytes) {
if (bytes < 1024) { if (bytes < 1024) {
return `${bytes} B` return `${bytes} B`
@ -407,7 +396,7 @@ async function load() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.icons.loadFailed'), message: t('admin.icons.loadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -441,7 +430,7 @@ async function openAddSet() {
try { try {
state.availableSets = (await API_CLIENT.get('icons/available-sets').json()) ?? [] state.availableSets = (await API_CLIENT.get('icons/available-sets').json()) ?? []
} catch (err) { } catch (err) {
state.availableError = await apiMessage(err) state.availableError = apiErrorMessage(err)
} }
state.loadingAvailable = false state.loadingAvailable = false
} }
@ -466,7 +455,7 @@ async function addSet(set) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.icons.addFailed'), message: t('admin.icons.addFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -490,7 +479,7 @@ async function setSetState(set, isEnabled) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.icons.saveFailed'), message: t('admin.icons.saveFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -532,7 +521,7 @@ function confirmDeleteSet(set) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.icons.deleteFailed'), message: t('admin.icons.deleteFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -570,7 +559,7 @@ function purgeCache() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.icons.purgeCacheFailed'), message: t('admin.icons.purgeCacheFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -120,6 +120,7 @@ import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -195,14 +196,10 @@ async function globalSwitch() {
}) })
await load() await load()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.metrics.toggleStateFailed'), message: t('admin.metrics.toggleStateFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.isToggleLoading = false state.isToggleLoading = false

@ -403,6 +403,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { humanizeDuration, relativeDate } from '@/helpers/datetime' import { humanizeDuration, relativeDate } from '@/helpers/datetime'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -676,14 +677,10 @@ async function runNow(entry) {
message: t('admin.scheduler.runNowSuccess', { task: entry.task }) message: t('admin.scheduler.runNowSuccess', { task: entry.task })
}) })
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.scheduler.runNowFailed'), message: t('admin.scheduler.runNowFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -703,14 +700,10 @@ async function cancelJob(jobId) {
await load() await load()
} catch (err) { } catch (err) {
// -> ky throws above 400 a job picked up between the render and the click answers 404 // -> ky throws above 400 a job picked up between the render and the click answers 404
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.scheduler.cancelJobFailed'), message: t('admin.scheduler.cancelJobFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -729,14 +722,10 @@ async function retryJob(jobId) {
}) })
await load() await load()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.scheduler.retryJobFailed'), message: t('admin.scheduler.retryJobFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -107,6 +107,7 @@ import { loading } from '@/composables/loading'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import UtilCodeEditor from '@/components/UtilCodeEditor.vue' import UtilCodeEditor from '@/components/UtilCodeEditor.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -197,14 +198,10 @@ async function save() {
}) })
await load() await load()
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.search.saveFailed'), message: t('admin.search.saveFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -222,14 +219,10 @@ async function rebuild() {
message: t('admin.search.rebuildInitSuccess') message: t('admin.search.rebuildInitSuccess')
}) })
} catch (err) { } catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.search.rebuildFailed'), message: t('admin.search.rebuildFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.rebuildLoading = false state.rebuildLoading = false

@ -473,6 +473,7 @@ import { useSiteStore } from '@/stores/site'
import { filesize } from 'filesize' import { filesize } from 'filesize'
import filesizeParser from 'filesize-parser' import filesizeParser from 'filesize-parser'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -588,14 +589,10 @@ async function save() {
} catch (err) { } catch (err) {
// -> ky throws above 400 the server rejects combinations that would store a setting doing // -> ky throws above 400 the server rejects combinations that would store a setting doing
// nothing, e.g. enforcing a CSP with no directives // nothing, e.g. enforcing a CSP with no directives
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.security.saveFailed'), message: t('admin.security.saveFailed'),
caption: apiMessage || err.message caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -77,8 +77,14 @@
</w-card> </w-card>
</div> </div>
<div class="min-w-0 flex-1" v-if="state.target"> <div class="min-w-0 flex-1" v-if="state.target">
<div class="grid grid-cols-12 gap-4"> <!--
<div class="col-span-12"> The settings and the infobox beside them, the same shape as the list and this panel above:
the infobox is 300px wide and the settings take what is left, both dropping onto their own
row when there is no room. A 12-column grid could not say that -- `col-span-12` on the
settings took a whole row of it, which is what put the infobox underneath.
-->
<div class="flex flex-wrap gap-4">
<div class="min-w-0 flex-1">
<!-- ----------------------- --> <!-- ----------------------- -->
<!-- Setup --> <!-- Setup -->
<!-- ----------------------- --> <!-- ----------------------- -->
@ -516,7 +522,7 @@
</template> </template>
</w-card> </w-card>
</div> </div>
<div class="col-span-12 lg:col-auto"> <div class="flex-none">
<!-- ----------------------- --> <!-- ----------------------- -->
<!-- Infobox --> <!-- Infobox -->
<!-- ----------------------- --> <!-- ----------------------- -->
@ -751,6 +757,7 @@ import { useSiteStore } from '@/stores/site'
import * as VNG from 'v-network-graph' import * as VNG from 'v-network-graph'
import GithubSetupInstallDialog from '../components/GithubSetupInstallDialog.vue' import GithubSetupInstallDialog from '../components/GithubSetupInstallDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -956,18 +963,6 @@ function inputTypeFor(cfg) {
return cfg.type === 'number' ? 'number' : 'text' return cfg.type === 'number' ? 'number' : 'text'
} }
/**
* Read the API's own message off a failed request, since ky doesn't throw on 400
*/
async function apiMessage(err) {
return (
err.response
?.json()
.then((b) => b?.message)
.catch(() => null) ?? err.message
)
}
async function load() { async function load() {
state.loading++ state.loading++
loading.show() loading.show()
@ -981,7 +976,7 @@ async function load() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.storage.loadFailed'), message: t('admin.storage.loadFailed'),
caption: await apiMessage(err), caption: apiErrorMessage(err),
timeout: 20000 timeout: 20000
}) })
} }
@ -1057,7 +1052,7 @@ async function save({ silent = false } = {}) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.storage.saveFailed'), message: t('admin.storage.saveFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
if (!silent) { if (!silent) {
@ -1113,7 +1108,7 @@ async function executeAction(act) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.storage.actionFailed', { action: act.label }), message: t('admin.storage.actionFailed', { action: act.label }),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.runningAction = false state.runningAction = false
@ -1190,7 +1185,7 @@ async function setupDestroy() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.storage.githubSetupDestroyFailed'), message: t('admin.storage.githubSetupDestroyFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
}) })
@ -1321,7 +1316,7 @@ async function setupGitHubStep(step, code) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('admin.storage.githubSetupFailed'), message: t('admin.storage.githubSetupFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
} }

@ -156,6 +156,7 @@ import { confirm } from '@/composables/dialog'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -226,16 +227,6 @@ watch(
// METHODS // 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) { function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, { return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
@ -257,7 +248,7 @@ async function load() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('inbox.reviewLoadFailed'), message: t('inbox.reviewLoadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -283,7 +274,7 @@ async function loadSubmission(id) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('inbox.reviewLoadFailed'), message: t('inbox.reviewLoadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
/* /*
Reviewed by somebody else already, or never this reviewer's to see. Back to the queue, and with Reviewed by somebody else already, or never this reviewer's to see. Back to the queue, and with
@ -419,7 +410,7 @@ function approveSubmission() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('inbox.reviewApproveFailed'), message: t('inbox.reviewApproveFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -453,7 +444,7 @@ function rejectSubmission() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('inbox.reviewDeclineFailed'), message: t('inbox.reviewDeclineFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--

@ -72,6 +72,7 @@ import { notify } from '@/composables/notify'
import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page' import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES // COMPOSABLES
@ -111,16 +112,6 @@ onMounted(load)
// METHODS // 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) { function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, { return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
@ -136,7 +127,7 @@ async function load() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('inbox.watchingLoadFailed'), message: t('inbox.watchingLoadFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -172,7 +163,7 @@ async function unwatch(page) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('inbox.watchingUnwatchFailed'), message: t('inbox.watchingUnwatchFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.unwatching = null state.unwatching = null

@ -166,6 +166,7 @@ import { loading } from '@/composables/loading'
import { confirm, dialog } from '@/composables/dialog' import { confirm, dialog } from '@/composables/dialog'
import { onMounted, reactive } from 'vue' import { onMounted, reactive } from 'vue'
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser' import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import { apiErrorMessage } from '@/helpers/apiError'
import { localizeError } from '@/helpers/localization' import { localizeError } from '@/helpers/localization'
import ChangePwdDialog from '@/components/ChangePwdDialog.vue' import ChangePwdDialog from '@/components/ChangePwdDialog.vue'
@ -192,19 +193,6 @@ const state = reactive({
// METHODS // METHODS
/**
* The reason the API gave, out of a response ky threw on (anything above 400) or out of the error
* itself when the request never got an answer. An `ERR_*` code is translated on the way out.
*/
async function apiMessage(err) {
const message =
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
return localizeError(message, t)
}
function humanizeDate(val) { function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, { return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
@ -222,7 +210,7 @@ async function fetchAuthMethods() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('profile.authLoadingFailed'), message: t('profile.authLoadingFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
state.loading-- state.loading--
@ -260,7 +248,7 @@ function disableTfa(strategyId) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('profile.authDisableTfaFailed'), message: t('profile.authDisableTfaFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
await fetchAuthMethods() await fetchAuthMethods()
@ -306,7 +294,7 @@ async function setPasswordLogin(strategyId, isEnabled) {
message: isEnabled message: isEnabled
? t('profile.authEnablePasswordLoginFailed') ? t('profile.authEnablePasswordLoginFailed')
: t('profile.authDisablePasswordLoginFailed'), : t('profile.authDisablePasswordLoginFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
await fetchAuthMethods() await fetchAuthMethods()
@ -386,7 +374,7 @@ async function setupPasskey() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('profile.passkeysSetupFailed'), message: t('profile.passkeysSetupFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
await fetchAuthMethods() await fetchAuthMethods()
@ -415,7 +403,7 @@ async function deactivatePasskey(pkey) {
notify({ notify({
type: 'negative', type: 'negative',
message: t('profile.passkeysDeactivateFailed'), message: t('profile.passkeysDeactivateFailed'),
caption: await apiMessage(err) caption: apiErrorMessage(err)
}) })
} }
await fetchAuthMethods() await fetchAuthMethods()

@ -225,6 +225,7 @@ import { difference } from 'es-toolkit/array'
import HeaderNav from '@/components/HeaderNav.vue' import HeaderNav from '@/components/HeaderNav.vue'
import FooterNav from '@/components/FooterNav.vue' import FooterNav from '@/components/FooterNav.vue'
import MainOverlayDialog from '@/components/MainOverlayDialog.vue' import MainOverlayDialog from '@/components/MainOverlayDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g
@ -403,11 +404,7 @@ async function performSearch() {
notify({ notify({
type: 'negative', type: 'negative',
message: t('search.failed'), message: t('search.failed'),
caption: caption: apiErrorMessage(err)
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) || err.message
}) })
} finally { } finally {
state.loading-- state.loading--

Loading…
Cancel
Save