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 \
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
ENV npm_config_loglevel=warn
# disable NPM funding messages

@ -13,6 +13,18 @@ echo "Waiting for DB container to come online..."
echo "Installing dependencies..."
cd backend
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
npm install
cd ../blocks

@ -3,7 +3,7 @@ name: Build + Publish
on:
push:
branches:
- vega
- scarlett
jobs:
build:
@ -14,66 +14,45 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Setup Node.js environment
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: 20.x
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@latest --activate
node-version: 26.x
- name: Set Build Variables
run: |
echo "REL_VERSION=3.0.0-alpha.$GITHUB_RUN_NUMBER" >> $GITHUB_ENV
- name: Disable DEV Flag + Set Version
working-directory: server
- name: Set Version
run: |
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
(echo; echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"') >> /home/runner/.bashrc
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 }}
yq -iP '.version = strenv(REL_VERSION)' backend/package.json -o json
yq -iP '.version = strenv(REL_VERSION)' frontend/package.json -o json
- name: Build Assets
working-directory: ux
working-directory: frontend
run: |
pnpm install --frozen-lockfile --shamefully-hoist
NODE_OPTIONS=--max-old-space-size=8192 pnpm build
npm ci
npm run build
- name: Build Blocks
working-directory: blocks
run: |
pnpm install --frozen-lockfile
pnpm build
npm ci
npm run build
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build and push Docker images
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: dev/build/Dockerfile
@ -85,60 +64,20 @@ jobs:
- name: Prepare build archive
run: |
mkdir -p _dist
mkdir -p _dist/blocks
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 config.sample.yml _dist/config.sample.yml
cd _dist/server
pnpm install --prod --frozen-lockfile
npm ci --omit=dev
cd -
find ./_dist/ -printf "%P\n" | tar -czf wiki-js.tar.gz --no-recursion -C ./_dist/ -T -
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: build
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: {
summary: 'Approve an edit suggestion and write it to the page',
description:
'Applies `content` when given — the reviewer may have adjusted the suggestion before accepting it — and what was submitted otherwise. Send `render` alongside it, as the editor does on any other save: the markdown pipeline lives in the client. Without it the server renders the page itself, which needs the Puppeteer extension. The page is re-indexed as it would be for any other edit, with the reviewer recorded as the author, and the suggestion is closed out.',
'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'],
params: {
type: 'object',

@ -3,7 +3,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.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. */
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
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: {
summary: 'Render a page again from its source',
summary: 'Queue a page to be rendered again from its source',
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'],
params: pageIdParam,
response: {
200: {
description: 'Page rendered successfully',
202: {
description: 'Page queued for rendering',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
message: { type: 'string' }
}
}
}
@ -845,15 +846,18 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'write:pages', target)) {
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)
if (!page) {
const queued = await WIKI.models.pages.queueRerender(
req.params.siteId,
req.params.pageId,
actor
)
if (!queued) {
return reply.notFound('This page does not exist.')
}
return {
return reply.code(202).send({
ok: true,
message: 'Page rendered successfully.',
page
}
message: 'Page queued for rendering.'
})
}
)

@ -5,6 +5,8 @@ import * as awarenessProtocol from 'y-protocols/awareness'
import * as syncProtocol from 'y-protocols/sync'
import * as Y from 'yjs'
import { createNotifier } from '../helpers/pubsub.ts'
import type { PoolClient } from 'pg'
import type { WebSocket } from 'ws'
@ -180,6 +182,14 @@ function buildSeed(page: {
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 {
rooms: new Map<string, CollabRoom>(),
listenClient: null as PoolClient | null,
@ -250,6 +260,9 @@ export default {
}
this.rooms.clear()
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 = 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 {
this.listenClient
?.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}`)
})
notifier.send(NOTIFY_CHANNEL, JSON.stringify(envelope))
},
receiveRelay(envelope: RelayEnvelope): void {

@ -11,9 +11,19 @@ import semver from 'semver'
import { relations } from '../db/relations.ts'
import { flags } from '../models/flags.ts'
import { createDeferred } from '../helpers/common.ts'
import { createNotifier } from '../helpers/pubsub.ts'
// import migrationSource from '../db/migrator-source.js'
// 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.
*
@ -184,7 +194,7 @@ export default {
// -> Outbound events handling
this.pubsubClient.query('LISTEN wiki')
await this.pubsubClient.query('LISTEN wiki')
this.pubsubClient.on('notification', (msg) => {
if (msg.channel !== 'wiki') {
return
@ -220,6 +230,9 @@ export default {
if (this.pubsubClient) {
WIKI.events.outbound.offAny(this.notifyViaDB as any)
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)
}
},
@ -230,18 +243,14 @@ export default {
* @param value Payload of the event
*/
notifyViaDB({ name, data }: { name?: string; data?: unknown }): void {
try {
WIKI.dbManager.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
'wiki',
JSON.stringify({
source: WIKI.INSTANCE_ID,
event: name,
value: data ?? null
})
])
} catch (err: any) {
WIKI.logger.warn(err)
}
notifier.send(
'wiki',
JSON.stringify({
source: WIKI.INSTANCE_ID,
event: name,
value: data ?? null
})
)
},
/**
* Attempt initial connection

@ -5,6 +5,7 @@ import path from 'node:path'
import { CronExpressionParser } from 'cron-parser'
import { v4 as uuid } from 'uuid'
import { createDeferred, type Deferred } from '../helpers/common.ts'
import { createNotifier } from '../helpers/pubsub.ts'
import { camelCase } from 'es-toolkit/string'
import { remove } from 'es-toolkit/array'
import {
@ -19,6 +20,14 @@ import type { PoolClient } from 'pg'
/** An in-process task, loaded from `tasks/simple/`. */
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. */
interface CompletionPromise {
id: string
@ -88,7 +97,7 @@ export default {
// -> Outbound events handling
this.pubsubClient!.query('LISTEN scheduler')
await this.pubsubClient!.query('LISTEN scheduler')
this.pubsubClient!.on('notification', async (msg) => {
if (msg.channel !== 'scheduler') {
return
@ -171,14 +180,14 @@ export default {
createdBy: WIKI.INSTANCE_ID
})
if (notify) {
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
notifier.send(
'scheduler',
JSON.stringify({
source: WIKI.INSTANCE_ID,
event: 'newJob',
id: jobId
})
])
)
}
return {
id: jobId,
@ -250,7 +259,7 @@ export default {
})
.where(eq(jobHistoryTable.id, job.id))
WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
notifier.send(
'scheduler',
JSON.stringify({
source: WIKI.INSTANCE_ID,
@ -258,7 +267,7 @@ export default {
state: 'success',
id: job.id
})
])
)
} catch (err: any) {
WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
WIKI.logger.warn(err)
@ -271,7 +280,7 @@ export default {
lastErrorMessage: err.message
})
.where(eq(jobHistoryTable.id, job.id))
this.pubsubClient!.query(`SELECT pg_notify($1, $2)`, [
notifier.send(
'scheduler',
JSON.stringify({
source: WIKI.INSTANCE_ID,
@ -280,7 +289,7 @@ export default {
id: job.id,
errorMessage: err.message
})
])
)
// -> Reschedule for retry
if (job.retries < job.maxRetries) {
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 -------------------------
/**
* 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
}
/**
* 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.
*
@ -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).`
)
}
/**
* 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.
await WIKI.collab.init()
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.uploadNewAvatar": "Upload New Image",
"profile.viewPublicProfile": "View Public Profile",
"renderPageDialog.loading": "Rendering page...",
"renderPageDialog.success": "Page rerendered successfully.",
"renderPageDialog.loading": "Queueing page render...",
"renderPageDialog.queued": "Page queued for rendering. Reload in a moment to see the result.",
"search.editorAny": "Any editor",
"search.emptyQuery": "Enter a query in the search field above and press Enter.",
"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.
Falling back to the server-side renderer covers an API client that has no pipeline of its own.
That one needs the Puppeteer extension and says so if it is missing, which is the honest answer:
the alternative is quietly leaving a stale render on a page somebody just changed.
That one needs the Puppeteer extension and says so before the content is written if it is
missing, rather than leaving a stale render on a page somebody just changed with no prospect of
it being corrected.
*/
const config = WIKI.sites[siteId]?.config?.editors?.[page.editor]?.config ?? {}
const html =
render ??
(await WIKI.models.rendering.renderContent(content, {
editor: page.editor,
config
}))
await WIKI.models.pages.updatePage(siteId, page.id, { content, render: html }, actor)
if (!render) {
await WIKI.models.rendering.ensureCanRender(page.editor)
}
await WIKI.models.pages.updatePage(
siteId,
page.id,
{ content, ...(render && { render }) },
actor
)
if (!render) {
// -> Briefly stale rather than wrong: the browser is a queue away, and a suggestion approved
// while it is busy waits its turn instead of starting a second one
await WIKI.models.pages.queueRerender(siteId, page.id, actor)
}
await WIKI.db.delete(submissionsTable).where(eq(submissionsTable.id, submissionId))
WIKI.logger.debug(`Approved edit suggestion ${submissionId} onto page ${page.id}`)

@ -1,7 +1,7 @@
import { and, eq, ne, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.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. */
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
* 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
* 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> {
const page = await this.getPage({ siteId, id, withContent: true })
async queueRerender(siteId: string, id: string, actor: PageActor): Promise<boolean> {
const page = await this.getPage({ siteId, id })
if (!page) {
return null
return false
}
await WIKI.models.rendering.ensureCanRender(page.editor)
const config = WIKI.sites[siteId]?.config?.editors?.[page.editor]?.config ?? {}
const html = await WIKI.models.rendering.renderContent(page.content ?? '', {
editor: page.editor,
config
})
// -> Post-processed like any other render: it came from a browser either way, and the author's
// permissions are still what decides what a page may carry
const { render, toc, text } = WIKI.models.rendering.postProcess(html, {
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
await WIKI.models.rendering.queuePage({
siteId,
pageId: page.id,
permissions: {
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
},
requestedById: actor.id
})
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)
.set({ render, toc, searchContent: text, updatedAt: sql`now()` })
.where(eq(pagesTable.id, id))
await WIKI.models.search.indexPage(id, page.locale)
.where(and(eq(pagesTable.id, id), eq(pagesTable.siteId, siteId)))
.returning({ locale: pagesTable.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 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'
/**
@ -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
* 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. */
export interface TocNode {
key: string
@ -49,6 +60,18 @@ export interface PostProcessResult {
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. */
export interface RenderPermissions {
/** `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
* reimplementing it, so a page re-rendered by the server comes out identical to one saved from the
* editor. That costs a browser, which is why it is reserved for an explicit re-render rather than
* used on every save.
* Puppeteer is an extension, and one that is not installed by default: rendering server-side is the
* only thing that needs it, and everything else keeps working without it.
*/
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
* so plainly: re-rendering is the only thing that needs it, and everything else keeps working.
* Asked before anything is queued or written rather than left to the job: a request that joins a
* 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(
content: string,
{ editor, config }: { editor: string; config: Record<string, any> }
): Promise<string> {
async ensureCanRender(editor: string): Promise<void> {
if (editor !== 'markdown') {
throw new CustomError(
'renderUnsupportedEditor',
`Server-side rendering is not implemented for the ${editor} editor.`
)
}
const definition = WIKI.models.extensions.getDefinition('puppeteer')
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
if (!(await this.isAvailable())) {
throw new CustomError(
'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
)
}
}
/**
* 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
// operator installs, so a literal import would not typecheck
const specifier = 'puppeteer'
@ -508,16 +737,57 @@ class Rendering {
await page.goto(`http://127.0.0.1:${WIKI.config.port}/_render`, {
waitUntil: 'networkidle0'
})
await page.waitForFunction('window.__wikiRenderReady === true', { timeout: 30000 })
// -> This callback is serialized and runs in the browser, where `globalThis` is the window the
// renderer bundle attached itself to
return await page.evaluate(
(src: string, cfg: Record<string, any>) => (globalThis as any).__wikiRender(src, cfg),
content,
config
)
} finally {
await browser.close()
await page.waitForFunction('window.__wikiRenderReady === true', {
timeout: RENDER_READY_TIMEOUT
})
return {
async render(content: string, config: Record<string, any>): Promise<string> {
/*
`page.evaluate` has no timeout of its own, and what it calls is a synchronous pass over
content somebody else wrote: an input that sends one of the markdown plugins into
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 \
bash \
build-essential \
chromium \
curl \
fonts-liberation \
git \
gnupg \
openssh-client \
@ -27,9 +29,22 @@ USER node
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
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
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 ApiKeyCopyDialog from './ApiKeyCopyDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// EMITS
@ -223,13 +224,9 @@ async function create() {
onDialogOK()
})
} catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.loading--

@ -41,6 +41,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { reactive } from 'vue'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -85,13 +86,9 @@ async function confirm() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoading = false

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

@ -46,6 +46,7 @@ import { notify } from '@/composables/notify'
import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -95,13 +96,9 @@ async function confirm() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoading = false

@ -52,6 +52,7 @@ import { notify } from '@/composables/notify'
import { onMounted, reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -109,13 +110,9 @@ async function rename() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.loading--
@ -132,13 +129,9 @@ onMounted(async () => {
}
state.path = asset.fileName
} catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
onDialogCancel()
}

@ -354,6 +354,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
import { useDark } from '@/composables/dark'
import { apiErrorMessage } from '@/helpers/apiError'
import { localizeError } from '@/helpers/localization'
import { useSiteStore } from '@/stores/site'
@ -500,20 +501,6 @@ const userPasswordVerifyValidation = [
// 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) {
switch (screen) {
case 'login': {
@ -662,7 +649,7 @@ async function login() {
loading.hide()
notify({
type: 'negative',
message: localizeError(await apiError(err), t)
message: localizeError(apiErrorMessage(err), t)
})
}
}
@ -702,7 +689,7 @@ async function loginWithPasskey() {
}
notify({
type: 'negative',
message: localizeError(await apiError(err), t)
message: localizeError(apiErrorMessage(err), t)
})
}
}
@ -814,7 +801,7 @@ async function changePwd() {
} catch (err) {
notify({
type: 'negative',
message: localizeError(await apiError(err), t)
message: localizeError(apiErrorMessage(err), t)
})
}
}
@ -854,7 +841,7 @@ async function submitTFA(setup) {
* no way forward.
*/
async function handleTFAError(err) {
const code = await apiError(err)
const code = apiErrorMessage(err)
loading.hide()
notify({
type: 'negative',

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

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

@ -428,6 +428,7 @@ import { filesize } from 'filesize'
import Fuse from 'fuse.js/basic'
import NewMenu from './PageNewMenu.vue'
import Tree from './TreeNav.vue'
import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
@ -689,18 +690,6 @@ function close() {
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) {
if (!value) {
return ''
@ -835,7 +824,7 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
notify({
type: 'negative',
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) {
@ -1051,7 +1040,7 @@ async function uploadNewFiles() {
notify({
type: 'negative',
message: 'Failed to upload file.',
caption: await apiErrorMessage(err, 'An unexpected error occured.')
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
state.loading--
@ -1160,7 +1149,7 @@ async function downloadItem(item) {
notify({
type: 'negative',
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 { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -163,13 +164,9 @@ async function create() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.loading--

@ -45,6 +45,7 @@ import { reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -94,13 +95,9 @@ async function confirm() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoading = false

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

@ -42,6 +42,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -80,13 +81,9 @@ async function confirm() {
} catch (err) {
// -> 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
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
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 { fileOpen, fileSave } from 'browser-fs-access'
import UserSearchDialog from '@/components/UserSearchDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -1188,14 +1189,10 @@ function assignUser() {
assigned++
} catch (err) {
// -> ky throws above 400, with the reason in the body
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
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()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoadingUsers = false

@ -134,6 +134,7 @@ import { useDark } from '@/composables/dark'
import { debounce } from 'es-toolkit/function'
import { useClosePopup } from '@/composables/popup'
import { apiErrorMessage } from '@/helpers/apiError'
// I18N
@ -206,18 +207,6 @@ watch(() => state.currentTab, focusCurrentTab)
// 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() {
try {
// -> Only enabled sets: a disabled one is not searchable, and its icons cannot be stored
@ -227,7 +216,7 @@ async function loadSets() {
notify({
type: 'negative',
message: t('iconPicker.setsFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
}
@ -252,7 +241,7 @@ async function search() {
notify({
type: 'negative',
message: t('iconPicker.searchFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading = false
@ -283,7 +272,7 @@ async function apply() {
notify({
type: 'warning',
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 { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes'
import Tree from '@/components/TreeNav.vue'
@ -235,18 +236,6 @@ watch(
// 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.
*
@ -313,7 +302,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
notify({
type: 'negative',
message: t('linkPicker.loadFailed'),
caption: await apiErrorMessage(err, 'An unexpected error occured.')
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
if (parentId) {

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

@ -454,6 +454,7 @@ import { v4 as uuid } from 'uuid'
import { pick } from 'es-toolkit/object'
import { Sortable } from 'sortablejs-vue3'
import IconPickerDialog from '@/components/IconPickerDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES
@ -579,18 +580,6 @@ function close() {
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() {
state.loading++
try {
@ -601,7 +590,7 @@ async function loadGroups() {
notify({
type: 'warning',
message: t('navEdit.groupsFailed'),
caption: await apiErrorMessage(err)
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
state.loading--
@ -648,7 +637,7 @@ async function loadMenuItems() {
} catch (err) {
notify({
type: 'negative',
message: await apiErrorMessage(err)
message: apiErrorMessage(err, 'An unexpected error occured.')
})
close()
}
@ -722,7 +711,7 @@ async function save() {
} catch (err) {
notify({
type: 'negative',
message: await apiErrorMessage(err)
message: apiErrorMessage(err, 'An unexpected error occured.')
})
}
loading.hide()

@ -46,6 +46,7 @@ import { notify } from '@/composables/notify'
import { reactive } from 'vue'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -95,13 +96,9 @@ async function confirm() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoading = false

@ -330,6 +330,7 @@ import { useUserStore } from '@/stores/user'
import CollabPresence from '@/components/CollabPresence.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
@ -696,11 +697,7 @@ async function submitSuggestionCommit(guest = {}) {
notify({
type: 'negative',
message: t('common.page.suggestSubmitFailed'),
caption:
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
caption: apiErrorMessage(err)
})
}
loading.hide()

@ -258,6 +258,7 @@ import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
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.
@ -367,16 +368,6 @@ function close() {
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) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
@ -486,7 +477,7 @@ async function withVersion(version) {
notify({
type: 'negative',
message: t('history.loadFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
return null
} finally {
@ -598,7 +589,7 @@ function restoreVersion(version) {
notify({
type: 'negative',
message: t('history.restoreFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
} finally {
state.loading--
@ -658,7 +649,7 @@ function branchFrom(version) {
notify({
type: 'negative',
message: t('history.branchFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
} finally {
state.loading--
@ -731,7 +722,7 @@ async function applyDiff() {
notify({
type: 'negative',
message: t('history.loadFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
} finally {
state.loading--
@ -761,7 +752,7 @@ async function load() {
state.bId = state.versions[0].id
state.aId = state.versions[1]?.id ?? null
} catch (err) {
const caption = await apiMessage(err)
const caption = apiErrorMessage(err)
state.notice = caption
notify({
type: 'negative',

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

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

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

@ -76,6 +76,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { copyToClipboard } from '@/helpers/clipboard'
import { localizeError } from '@/helpers/localization'
import { computed, onMounted, reactive } from 'vue'
@ -121,19 +122,6 @@ const groupedSecret = computed(() => state.tfaSecret.replace(/.{4}(?=.)/g, '$& '
// 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() {
try {
// -> Without the display grouping: a space is harmless in most authenticator apps, but not all
@ -169,7 +157,7 @@ async function load() {
} catch (err) {
notify({
type: 'negative',
message: await apiMessage(err)
message: apiErrorMessage(err)
})
onDialogCancel()
}
@ -202,7 +190,7 @@ async function save() {
} catch (err) {
notify({
type: 'negative',
message: await apiMessage(err)
message: apiErrorMessage(err)
})
}
state.isLoading = false

@ -154,6 +154,7 @@ import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import Tree from '@/components/TreeNav.vue'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// 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 }) {
await loadTree({ parentId: nodeId })
done()
@ -420,7 +409,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
notify({
type: 'negative',
message: t('pageSaveDialog.loadFailed'),
caption: await apiErrorMessage(err, 'An unexpected error occured.')
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
if (parentId) {

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

@ -50,6 +50,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// 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
failure it did not report.
*/
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isDeleting = false

@ -44,6 +44,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { reactive } from 'vue'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -88,13 +89,9 @@ async function confirm() {
onDialogOK()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoading = false

@ -208,6 +208,7 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { computed, onMounted, reactive, ref } from 'vue'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
@ -369,13 +370,9 @@ async function fetchHook(id) {
lastErrorMessage: resp.lastErrorMessage ?? ''
}
} catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
onDialogHide()
}
@ -399,13 +396,9 @@ async function create() {
})
onDialogOK()
} catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.isLoading = false
@ -428,13 +421,9 @@ async function save() {
})
onDialogOK()
} catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
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 ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -273,14 +274,10 @@ async function globalSwitch() {
})
await load()
} catch (err) {
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: t('admin.api.toggleStateFailed'),
caption: apiMessage || err.message
caption: apiErrorMessage(err)
})
}
state.isToggleLoading = false

@ -126,6 +126,7 @@ import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import ApprovalRuleDialog from '@/components/ApprovalRuleDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -160,16 +161,6 @@ watch(() => adminStore.currentSiteId, load)
// METHODS
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
async function apiMessage(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function matchLabel(match) {
return (
{
@ -219,7 +210,7 @@ async function load() {
notify({
type: 'negative',
message: t('admin.approval.loadFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -250,7 +241,7 @@ async function setEnabled(rule, isEnabled) {
notify({
type: 'negative',
message: t('admin.approval.saveFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
await load()
}
@ -304,7 +295,7 @@ function deleteRule(rule) {
notify({
type: 'negative',
message: t('admin.approval.deleteFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--

@ -414,6 +414,7 @@ import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// 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() {
if (state.loading > 0) {
return
@ -650,7 +639,7 @@ async function save() {
state.selectedStrategy = resp.id
}
} 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({
type: 'negative',
message: t('admin.auth.deleteFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--

@ -117,6 +117,7 @@ import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { pick } from 'es-toolkit/object'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -231,13 +232,9 @@ function deleteBlock(id) {
await load()
} catch (err) {
// -> 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({
type: 'negative',
message: apiMessage || err.message
message: apiErrorMessage(err)
})
}
state.loading--

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

@ -120,6 +120,7 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES
@ -183,14 +184,10 @@ async function install(ext) {
await load()
} catch (err) {
// -> 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({
type: 'negative',
message: t('admin.extensions.installFailed'),
caption: apiMessage || err.message
caption: apiErrorMessage(err)
})
}
loading.hide()

@ -150,6 +150,7 @@ import { useSiteStore } from '@/stores/site'
import { useFlagsStore } from '@/stores/flags'
import { omit } from 'es-toolkit/object'
import { apiErrorMessage } from '@/helpers/apiError'
// STORES
@ -217,14 +218,10 @@ async function save() {
await load()
} catch (err) {
// -> 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({
type: 'negative',
message: t('admin.flags.saveFailed'),
caption: apiMessage || err.message
caption: apiErrorMessage(err)
})
}
state.loading--

@ -284,6 +284,7 @@ import { notify } from '@/composables/notify'
import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -334,18 +335,6 @@ const filteredAvailableSets = computed(() => {
// 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) {
if (bytes < 1024) {
return `${bytes} B`
@ -407,7 +396,7 @@ async function load() {
notify({
type: 'negative',
message: t('admin.icons.loadFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -441,7 +430,7 @@ async function openAddSet() {
try {
state.availableSets = (await API_CLIENT.get('icons/available-sets').json()) ?? []
} catch (err) {
state.availableError = await apiMessage(err)
state.availableError = apiErrorMessage(err)
}
state.loadingAvailable = false
}
@ -466,7 +455,7 @@ async function addSet(set) {
notify({
type: 'negative',
message: t('admin.icons.addFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -490,7 +479,7 @@ async function setSetState(set, isEnabled) {
notify({
type: 'negative',
message: t('admin.icons.saveFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -532,7 +521,7 @@ function confirmDeleteSet(set) {
notify({
type: 'negative',
message: t('admin.icons.deleteFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -570,7 +559,7 @@ function purgeCache() {
notify({
type: 'negative',
message: t('admin.icons.purgeCacheFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--

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

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

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

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

@ -77,8 +77,14 @@
</w-card>
</div>
<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 -->
<!-- ----------------------- -->
@ -516,7 +522,7 @@
</template>
</w-card>
</div>
<div class="col-span-12 lg:col-auto">
<div class="flex-none">
<!-- ----------------------- -->
<!-- Infobox -->
<!-- ----------------------- -->
@ -751,6 +757,7 @@ import { useSiteStore } from '@/stores/site'
import * as VNG from 'v-network-graph'
import GithubSetupInstallDialog from '../components/GithubSetupInstallDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -956,18 +963,6 @@ function inputTypeFor(cfg) {
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() {
state.loading++
loading.show()
@ -981,7 +976,7 @@ async function load() {
notify({
type: 'negative',
message: t('admin.storage.loadFailed'),
caption: await apiMessage(err),
caption: apiErrorMessage(err),
timeout: 20000
})
}
@ -1057,7 +1052,7 @@ async function save({ silent = false } = {}) {
notify({
type: 'negative',
message: t('admin.storage.saveFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
if (!silent) {
@ -1113,7 +1108,7 @@ async function executeAction(act) {
notify({
type: 'negative',
message: t('admin.storage.actionFailed', { action: act.label }),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.runningAction = false
@ -1190,7 +1185,7 @@ async function setupDestroy() {
notify({
type: 'negative',
message: t('admin.storage.githubSetupDestroyFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
})
@ -1321,7 +1316,7 @@ async function setupGitHubStep(step, code) {
notify({
type: 'negative',
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 { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -226,16 +227,6 @@ watch(
// METHODS
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
async function apiMessage(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
@ -257,7 +248,7 @@ async function load() {
notify({
type: 'negative',
message: t('inbox.reviewLoadFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -283,7 +274,7 @@ async function loadSubmission(id) {
notify({
type: 'negative',
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
@ -419,7 +410,7 @@ function approveSubmission() {
notify({
type: 'negative',
message: t('inbox.reviewApproveFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -453,7 +444,7 @@ function rejectSubmission() {
notify({
type: 'negative',
message: t('inbox.reviewDeclineFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--

@ -72,6 +72,7 @@ import { notify } from '@/composables/notify'
import { DEFAULT_PAGE_ICON, usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
// COMPOSABLES
@ -111,16 +112,6 @@ onMounted(load)
// METHODS
/** The reason the API gave, out of a response ky threw on, or the error's own message. */
async function apiMessage(err) {
return (
(await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)) ?? err.message
)
}
function humanizeDate(val) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
@ -136,7 +127,7 @@ async function load() {
notify({
type: 'negative',
message: t('inbox.watchingLoadFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -172,7 +163,7 @@ async function unwatch(page) {
notify({
type: 'negative',
message: t('inbox.watchingUnwatchFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.unwatching = null

@ -166,6 +166,7 @@ import { loading } from '@/composables/loading'
import { confirm, dialog } from '@/composables/dialog'
import { onMounted, reactive } from 'vue'
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import { apiErrorMessage } from '@/helpers/apiError'
import { localizeError } from '@/helpers/localization'
import ChangePwdDialog from '@/components/ChangePwdDialog.vue'
@ -192,19 +193,6 @@ const state = reactive({
// 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) {
return Temporal.Instant.from(val).toLocaleString(undefined, {
dateStyle: 'medium',
@ -222,7 +210,7 @@ async function fetchAuthMethods() {
notify({
type: 'negative',
message: t('profile.authLoadingFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
state.loading--
@ -260,7 +248,7 @@ function disableTfa(strategyId) {
notify({
type: 'negative',
message: t('profile.authDisableTfaFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
await fetchAuthMethods()
@ -306,7 +294,7 @@ async function setPasswordLogin(strategyId, isEnabled) {
message: isEnabled
? t('profile.authEnablePasswordLoginFailed')
: t('profile.authDisablePasswordLoginFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
await fetchAuthMethods()
@ -386,7 +374,7 @@ async function setupPasskey() {
notify({
type: 'negative',
message: t('profile.passkeysSetupFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
await fetchAuthMethods()
@ -415,7 +403,7 @@ async function deactivatePasskey(pkey) {
notify({
type: 'negative',
message: t('profile.passkeysDeactivateFailed'),
caption: await apiMessage(err)
caption: apiErrorMessage(err)
})
}
await fetchAuthMethods()

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

Loading…
Cancel
Save