From 09e9166bc6d90e7f78c45564f81d8b85840ea2f7 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 14 Aug 2026 03:14:42 -0400 Subject: [PATCH] feat: utilities implementation + remove deprecated jwks --- CLAUDE.md | 4 +- backend/api/apiKeys.ts | 2 +- backend/api/schemas/apiKey.ts | 5 + backend/api/schemas/security.ts | 16 - backend/api/system.ts | 330 +++++++++++++++++- backend/base.yml | 3 - backend/core/db.ts | 15 +- backend/core/maintenance.ts | 77 ++++ backend/locales/en.json | 40 ++- backend/models/apiKeys.ts | 158 ++++++++- backend/models/assets.ts | 16 + backend/models/pageHistory.ts | 50 ++- backend/models/rendering.ts | 35 +- backend/models/security.ts | 66 +--- backend/models/sessions.ts | 32 ++ backend/models/settings.ts | 38 +- backend/package-lock.json | 48 --- backend/package.json | 2 - .../icons/fluent-invert-files-selection.svg | 1 + frontend/src/App.vue | 5 - frontend/src/boot/api.js | 55 +-- frontend/src/css/_page-contents.scss | 31 +- frontend/src/main.js | 2 +- frontend/src/pages/AdminApi.vue | 75 ++-- frontend/src/pages/AdminAuth.vue | 17 +- frontend/src/pages/AdminBlocks.vue | 4 +- frontend/src/pages/AdminIcons.vue | 32 +- frontend/src/pages/AdminSecurity.vue | 53 --- frontend/src/pages/AdminStorage.vue | 19 +- frontend/src/pages/AdminUtilities.vue | 276 +++++++++++++-- 30 files changed, 1115 insertions(+), 392 deletions(-) create mode 100644 backend/core/maintenance.ts create mode 100644 frontend/public/_assets/icons/fluent-invert-files-selection.svg diff --git a/CLAUDE.md b/CLAUDE.md index 3630273f2..8ab04e3dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ initializers → mount. There is no UI framework: `src/components/shared/` is th (every component is `W*`, used in templates as ``, ``, …), registered globally by `boot/components.js` and styled with Tailwind. -- `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client with JWT refresh, exposed +- `src/boot/` — one-time app initializers: `api.js` (creates the `ky` client, exposed as the `API_CLIENT` global), `components.js` (global components), `eventbus.js` (`EVENT_BUS` global, mitt), `externals.js`, `i18n.js`, `iconify.js` (points Iconify at this instance's `/_icons`), `monaco.js`, `temporal.js` (conditionally polyfills `Temporal`, awaited before anything else in @@ -376,7 +376,7 @@ Consequences worth knowing: differ. Add a prop there rather than reaching around it. - HTTP calls go through the `ky` client, reachable as the `API_CLIENT` global (declared in the oxlint config, so no import needed) — e.g. `await API_CLIENT.get('sites').json()`. It handles the `/_api` - prefix and JWT refresh. + prefix; authentication is the session cookie, sent with every request. - Cross-component messaging uses the `EVENT_BUS` global (mitt). - State lives in Pinia option stores. For utilities and dates use `es-toolkit` and `Temporal` — see [Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older diff --git a/backend/api/apiKeys.ts b/backend/api/apiKeys.ts index 048d26b29..ffc6864d6 100644 --- a/backend/api/apiKeys.ts +++ b/backend/api/apiKeys.ts @@ -142,7 +142,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Revoke an API key', description: - 'Permanent: the key stays listed as revoked and stops authenticating on the next request. Keys are never deleted, so the record of what existed is kept.', + 'Permanent: the key stays listed as revoked and stops authenticating on the next request. Revoking never deletes, so the record of what existed is kept — `POST /system/api-keys/purge` is what discards those rows, when an administrator asks for it.', tags: ['API Keys'], params: { type: 'object', diff --git a/backend/api/schemas/apiKey.ts b/backend/api/schemas/apiKey.ts index 1a27e7e0a..63435541a 100644 --- a/backend/api/schemas/apiKey.ts +++ b/backend/api/schemas/apiKey.ts @@ -36,6 +36,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { isRevoked: { type: 'boolean' }, + isInvalidated: { + type: 'boolean', + description: + "Issued before the signing certificates were last regenerated, so its signature no longer verifies. Not a state anybody set — it is the key's age against the keypair's, and unlike revocation it applies to every key at once." + }, createdAt: { type: 'string', format: 'date-time', diff --git a/backend/api/schemas/security.ts b/backend/api/schemas/security.ts index 964c599c8..debb099f8 100644 --- a/backend/api/schemas/security.ts +++ b/backend/api/schemas/security.ts @@ -95,22 +95,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { maxLength: 16, description: 'How long a client is refused for once it goes over, as a duration — e.g. `15m`, `1h`. Attempts made while banned do not extend it.' - }, - authJwtAudience: { - type: 'string', - maxLength: 255, - description: - 'Audience claim of issued tokens. Changing it invalidates every API key already issued.' - }, - authJwtExpiration: { - type: 'string', - maxLength: 16, - description: 'Duration, e.g. `30m`.' - }, - authJwtRenewablePeriod: { - type: 'string', - maxLength: 16, - description: 'Duration, e.g. `14d`.' } } }) diff --git a/backend/api/system.ts b/backend/api/system.ts index 4d27fefc5..51370e7e8 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -10,6 +10,9 @@ import { tags as tagsTable, users as usersTable } from '../db/schema.ts' +import maintenance from '../core/maintenance.ts' +import { purgeTimeframes } from '../models/pageHistory.ts' +import type { PurgeTimeframe } from '../models/pageHistory.ts' import type { FastifyInstance } from 'fastify' /** @@ -295,7 +298,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Get the security configuration', description: - 'The JWT fields come from the `auth` settings, which are the ones actually in force. Most of the rest is applied when the HTTP server starts, so changing it takes effect on the next restart.', + 'Most of this is applied when the HTTP server starts, so changing it takes effect on the next restart.', tags: ['System'], response: { 200: { $ref: 'SecurityConfig#' } @@ -319,7 +322,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Update the security configuration', description: - 'Accepts any subset of the fields. Changing the JWT audience invalidates every API key already issued, since a key carries the audience it was signed with. Header, CORS and proxy settings are read when the HTTP server starts and therefore apply after a restart.', + 'Accepts any subset of the fields. Header, CORS and proxy settings are read when the HTTP server starts and therefore apply after a restart.', tags: ['System'], body: { $ref: 'SecurityConfig#' }, response: { @@ -884,6 +887,329 @@ async function routes(app: FastifyInstance) { } ) + /** + * DISCONNECT WEBSOCKET SESSIONS + */ + app.post( + '/websockets/disconnect', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Close every websocket connection, on every instance', + description: + 'The sockets are the editors of live collaborative editing (`/_collab`) and the admin terminal’s log stream (`/_terminal`). Closing one is not a refusal: the code sent is a plain "come back", so an editor reconnects on its own and picks up the room it was in, and its unsaved text survives as long as somebody else is still in that room. Every other instance is told to do the same over the event bus, and does it as it hears it — `count` is this instance’s own, since a socket is held by the instance the browser reached and nothing reports back.', + tags: ['System'], + response: { + 200: { + description: 'Websocket connections closed successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + count: { + type: 'number', + description: 'Connections that were open on this instance and have been closed.' + } + } + } + } + } + }, + async () => { + const count = maintenance.disconnectWebsockets() + WIKI.events.outbound.emit('disconnectWebsockets') + return { + ok: true, + message: `Closed ${count} websocket connection(s) on this instance.`, + count + } + } + ) + + /** + * FLUSH CACHE + */ + app.post( + '/cache/flush', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Flush the caches, on every instance', + description: + 'Throws away everything an instance holds that the database is the real copy of: the file and icon caches, in memory and on disk, and the site, group, page-rule and locale state that answers every request. Nothing is lost and nothing is disabled — what is read on every request is refilled before this answers, and the rest as it is asked for again. Every other instance is told to do the same over the event bus, and does it as it hears it.', + tags: ['System'], + response: { + 200: { + description: 'Cache flushed successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + } + } + } + } + } + }, + async () => { + await maintenance.flushCaches() + WIKI.events.outbound.emit('flushCaches') + return { + ok: true, + message: 'The cache has been flushed.' + } + } + ) + + /** + * GET API KEY CERTIFICATE STATE + */ + app.get( + '/certificates', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'When the API key signing certificates were generated', + description: + 'The moment the current keypair came into being — at install, or the last time an administrator regenerated it. Every key issued before it was signed by a keypair that no longer exists and cannot authenticate, which is what `isInvalidated` on a key reports.', + tags: ['System'], + response: { + 200: { + description: 'Certificate state', + type: 'object', + properties: { + generatedAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + } + } + } + } + } + }, + async () => { + return { generatedAt: WIKI.models.apiKeys.certificatesGeneratedAt() } + } + ) + + /** + * REGENERATE API KEY CERTIFICATES + */ + app.post( + '/certificates', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Replace the API key signing certificates', + description: + 'Generates a new keypair and a new passphrase for it. An API key is a token signed with that keypair, so every key ever issued stops authenticating at once, on every instance — this is what takes back a key that has escaped and cannot be revoked one at a time. The key rows are left as they are, still listed and still not revoked: what has to happen next is that each one is reissued. Logins are unaffected — session cookies are signed with a secret of their own.', + tags: ['System'], + response: { + 200: { + description: 'Certificates regenerated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + invalidatedKeys: { + type: 'number', + description: + 'Keys that were neither revoked nor expired, and have just stopped working.' + } + } + } + } + } + }, + async (req, reply) => { + const invalidatedKeys = await WIKI.models.apiKeys.regenerateCertificates() + if (invalidatedKeys === null) { + return reply.internalServerError('Failed to save the new certificates.') + } + return { + ok: true, + message: `Certificates regenerated successfully. ${invalidatedKeys} API key(s) will have to be reissued.`, + invalidatedKeys + } + } + ) + + /** + * PURGE REVOKED API KEYS + */ + app.post( + '/api-keys/purge', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Delete every revoked API key', + description: + 'Clears revoked keys out of the list for good. Nothing about access changes — a revoked key already authenticates nothing — so this trades the record that the key ever existed for a shorter list. Keys that are merely invalidated are kept: one of those is a key nobody has made a decision about, and its row is what tells its owner to reissue it.', + tags: ['System'], + response: { + 200: { + description: 'Revoked keys purged successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + count: { + type: 'number', + description: 'Keys deleted.' + } + } + } + } + } + }, + async () => { + const count = await WIKI.models.apiKeys.purgeRevoked() + return { + ok: true, + message: `Purged ${count} revoked API key(s).`, + count + } + } + ) + + /** + * INVALIDATE USER SESSIONS + */ + app.post( + '/sessions/invalidate', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Rotate the session secret and end every session', + description: + 'Logs everybody out, this caller included, and gives @fastify/session a new secret to sign cookies with. The two happen together on purpose: ending the sessions takes effect immediately and everywhere, since they are rows every instance shares, while the new secret is only picked up when an instance restarts — the plugins are handed it at startup. API keys are unaffected; their keypair carries its own passphrase.', + tags: ['System'], + response: { + 200: { + description: 'Sessions invalidated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + count: { + type: 'number', + description: 'Sessions that were open and have been ended.' + } + } + } + } + } + }, + async (req, reply) => { + const count = await WIKI.models.sessions.rotateSecret() + if (count === null) { + return reply.internalServerError('Failed to save the new session secret.') + } + + /* + This request's own session, which the rows above no longer include but which would come + straight back without this: @fastify/session writes the session it is holding as the response + is sent, so deleting the row from under it only means it is written again a moment later, and + the one account that would stay logged in is the one that asked for everybody to be logged + out. Destroying it detaches it from the request, which is what that hook skips on. + */ + await req.session.destroy() + + return { + ok: true, + message: `Ended ${count} session(s) and rotated the session secret.`, + count + } + } + ) + + /** + * PURGE PAGE HISTORY + */ + app.post<{ Body: { olderThan: PurgeTimeframe } }>( + '/history/purge', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Purge page history older than a timeframe', + description: + 'Deletes every version older than the cutoff, on every site. Pages themselves are untouched — a page row holds what it says now — so this shortens timelines and takes away what a page can be rolled back to, nothing more. With one exception: the versions of a page that was DELETED are all that is left of it, so purging past the day it went is what finally discards it. Nothing here can be undone.', + tags: ['System'], + body: { + type: 'object', + required: ['olderThan'], + properties: { + olderThan: { + type: 'string', + enum: Object.keys(purgeTimeframes), + description: 'How far back to keep. Everything older than this is deleted.' + } + } + }, + response: { + 200: { + description: 'Page history purged successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + count: { + type: 'number', + description: 'Versions deleted.' + } + } + } + } + } + }, + async (req) => { + const count = await WIKI.models.pageHistory.purge(req.body.olderThan) + return { + ok: true, + message: `Purged ${count} page version(s).`, + count + } + } + ) + /** * CHECK FOR UPDATE */ diff --git a/backend/base.yml b/backend/base.yml index 9d7ef7869..20da7e6fe 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -67,9 +67,6 @@ defaults: enforce2FA: false hideLocal: false loginBgUrl: '' - audience: 'urn:wiki.js' - tokenExpiration: '30m' - tokenRenewal: '14d' secret: 'abcdef1234567890abcdef1234567890abcdef' security: corsMode: 'OFF' diff --git a/backend/core/db.ts b/backend/core/db.ts index ce4600982..b13ae2179 100644 --- a/backend/core/db.ts +++ b/backend/core/db.ts @@ -13,6 +13,7 @@ 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 maintenance from './maintenance.ts' // import migrationSource from '../db/migrator-source.js' /** @@ -219,16 +220,15 @@ export default { } } catch {} }) - // FIXME: pre-existing bug — Emittery's `onAny` calls the listener as `(eventName, eventData)`, - // but `notifyViaDB` destructures a single `{ name, data }` object (the eventemitter2 signature - // it was written against). Both end up undefined, so HA event propagation publishes an empty - // event. Preserved as-is to keep the TypeScript migration behavior-neutral. + // -> Cast because `onAny` types the event as every pair the map allows plus Emittery's own meta + // events, and this listener is written to the one shape they have in common WIKI.events.outbound.onAny(this.notifyViaDB as any) // -> Listen to inbound events // WIKI.auth.subscribeToEvents() WIKI.configSvc.subscribeToEvents() + maintenance.subscribeToEvents() // WIKI.db.pages.subscribeToEvents() WIKI.logger.info('Event Listener initialized successfully: [ OK ]') @@ -249,8 +249,11 @@ export default { /** * Publish event via database NOTIFY * - * @param event Event fired - * @param value Payload of the event + * Takes one `{ name, data }` object, which is what Emittery hands an `onAny` listener — not the + * `(eventName, eventData)` pair `on` listeners used to get before 2.x. `data` is absent, rather + * than undefined, for an event emitted without a payload. + * + * @param event Event fired, and its payload */ notifyViaDB({ name, data }: { name?: string; data?: unknown }): void { notifier.send( diff --git a/backend/core/maintenance.ts b/backend/core/maintenance.ts new file mode 100644 index 000000000..8ac345e2b --- /dev/null +++ b/backend/core/maintenance.ts @@ -0,0 +1,77 @@ +/** + * The maintenance actions of the admin area's utilities view. + * + * Both of them act on things that are an instance's own: the websockets it is holding, the memory it + * has filled and the cache directory it has written. Nothing about either is stored anywhere, so + * neither can be carried out on another instance's behalf — which is why each is published on the + * event bus and every instance runs the same function when it hears it. + * + * That makes the acknowledgement local by nature. The route that starts one answers for the instance + * that ran it and says that the others were told; an instance acting on the event has nobody to + * report back to. There is no registry of instances either (see `api/system.ts`), so there is nothing + * to wait for a complete answer from in the first place. + */ +export default { + /** + * Close every websocket this instance is holding. + * + * Whichever controller opened it: live collaborative editing (`controllers/collab.ts`) and the admin + * terminal's log stream (`controllers/terminal.ts`) both take their sockets from the same upgrade + * handler, and this is deliberately "all of them" rather than "the ones a given feature knows + * about". + * + * Closing is not a refusal. Both controllers turn a session away with a code in the private 4000 + * range, which is how their clients know not to try again; this uses 1012, an ordinary drop, so an + * editor reconnects on its own and rejoins the room it was in. Unsaved text survives that for as + * long as somebody else is still in the room — and a room whose last participant is on their way + * back is the same race a network blip already produces, which the seeding in `core/collab.ts` is + * built to survive. + * + * @returns How many open connections were closed. + */ + disconnectWebsockets(): number { + let count = 0 + for (const client of WIKI.app.websocketServer.clients) { + if (client.readyState !== client.OPEN) { + continue + } + client.close(1012, 'Disconnected by an administrator') + count++ + } + WIKI.logger.info(`Closed ${count} websocket connection(s) [ OK ]`) + return count + }, + + /** + * Throw away everything this instance holds that the database is the real copy of. + * + * The file and icon caches, in memory and on disk, and the site, group, page rule and locale state + * that answers every request. Nothing is lost and nothing is turned off: what the caches held is + * read back from the database as it is asked for again, and the four reloaded here are refilled + * before this returns rather than left for the next visitor to pay for. + */ + async flushCaches(): Promise { + WIKI.cache.flushAll() + await WIKI.models.assets.purgeCache() + await WIKI.models.icons.purgeCache() + + await WIKI.models.locales.reloadCache() + await WIKI.models.sites.reloadCache() + await WIKI.models.groups.reloadCache() + await WIKI.models.approvals.reloadCache() + + WIKI.logger.info('Flushed all caches [ OK ]') + }, + + /** + * Subscribe to HA propagation events + */ + subscribeToEvents(): void { + WIKI.events.inbound.on('disconnectWebsockets', () => { + this.disconnectWebsockets() + }) + WIKI.events.inbound.on('flushCaches', async () => { + await this.flushCaches() + }) + } +} diff --git a/backend/locales/en.json b/backend/locales/en.json index 95da04728..42637c6d7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -35,6 +35,8 @@ "admin.api.headerLastUpdated": "Last Updated", "admin.api.headerName": "Name", "admin.api.headerRevoke": "Revoke", + "admin.api.invalidated": "Invalidated", + "admin.api.invalidatedHint": "This key was issued before the API keys certificates were regenerated on {date}, so it can no longer be used. Create a new key to replace it.", "admin.api.key": "API Key", "admin.api.keyEndingIn": "Ending in {suffix}", "admin.api.loadFailed": "Failed to load API keys.", @@ -740,9 +742,6 @@ "admin.security.hsts": "HSTS (HTTP Strict Transport Security)", "admin.security.hstsDuration": "HSTS Max Age", "admin.security.hstsDurationHint": "Defines the duration for which the server should only deliver content through HTTPS. It's a good idea to start with small values and make sure that nothing breaks on your wiki before moving to longer values.", - "admin.security.jwt": "JWT Configuration", - "admin.security.jwtAudience": "JWT Audience", - "admin.security.jwtAudienceHint": "Audience URN used in JWT issued upon login. Usually your domain name. (e.g. urn:your.domain.com)", "admin.security.loadFailed": "Failed to load the security configuration.", "admin.security.loginScreen": "Login Screen", "admin.security.maxUploadBatch": "Max Files per Upload", @@ -772,10 +771,6 @@ "admin.security.subtitle": "Configure security settings", "admin.security.title": "Security", "admin.security.tokenEndpointAuthMethod": "Token Endpoint Authentication Method", - "admin.security.tokenExpiration": "Token Expiration", - "admin.security.tokenExpirationHint": "The expiration period of a token until it must be renewed. (default: 30m)", - "admin.security.tokenRenewalPeriod": "Token Renewal Period", - "admin.security.tokenRenewalPeriodHint": "The maximum period a token can be renewed when expired. (default: 14d)", "admin.security.trustProxy": "Trust X-Forwarded-* Proxy Headers", "admin.security.trustProxyHint": "Should be enabled when using a reverse-proxy like nginx, apache, CloudFlare, etc in front of Wiki.js. Turn off otherwise.", "admin.security.uploads": "Uploads", @@ -1236,23 +1231,46 @@ "admin.utilities.contentSubtitle": "Various tools for pages", "admin.utilities.contentTitle": "Content", "admin.utilities.disconnectWS": "Disconnect WebSocket Sessions", - "admin.utilities.disconnectWSHint": "Force all active websocket connections to be closed.", + "admin.utilities.disconnectWSConfirm": "Every open editing session and admin terminal will be disconnected, on every instance. Their clients reconnect on their own, and unsaved text is not lost.", + "admin.utilities.disconnectWSFailed": "Failed to disconnect the websocket connections.", + "admin.utilities.disconnectWSHint": "Force all active websocket connections to be closed, on every instance.", "admin.utilities.disconnectWSSuccess": "All active websocket connections have been terminated.", "admin.utilities.export": "Export", "admin.utilities.exportHint": "Export content to tarball for backup / migration.", "admin.utilities.flushCache": "Flush Cache", - "admin.utilities.flushCacheHint": "Pages and Assets are cached to disk for better performance. You can flush the cache to force all content to be fetched from the DB again.", + "admin.utilities.flushCacheFailed": "Failed to flush the cache.", + "admin.utilities.flushCacheHint": "Files, icons and site settings are cached for better performance. Flushing forces everything to be fetched from the database again, on every instance.", + "admin.utilities.flushCacheSuccess": "The cache has been flushed.", "admin.utilities.graphEndpointSubtitle": "Change the GraphQL endpoint for Wiki.js", "admin.utilities.graphEndpointTitle": "GraphQL Endpoint", "admin.utilities.import": "Import", "admin.utilities.importHint": "Import content from a tarball backup or a 2.X backup.", "admin.utilities.importv1Subtitle": "Migrate data from a previous 1.x installation", "admin.utilities.importv1Title": "Import from Wiki.js 1.x", - "admin.utilities.invalidAuthCertificates": "Invalidate Authentication Certificates", - "admin.utilities.invalidAuthCertificatesHint": "Regenerate the public and private keys used for authentication. This will instantly log everyone out.", + "admin.utilities.invalidApiCertificates": "Invalidate API Keys Certificates", + "admin.utilities.invalidApiCertificatesConfirm": "A new passphrase and keypair will be generated, and every API key ever issued will stop working immediately.", + "admin.utilities.invalidApiCertificatesConfirmWarn": "Keys are not deleted — they stay listed, and each one has to be reissued to the integration using it. Nobody is logged out: user sessions are signed with a separate secret.", + "admin.utilities.invalidApiCertificatesFailed": "Failed to regenerate the API keys certificates.", + "admin.utilities.invalidApiCertificatesHint": "Regenerate the passphrase and the keypair API keys are signed with. Every key already issued stops working and must be reissued.", + "admin.utilities.invalidApiCertificatesSuccess": "Certificates regenerated. No API key was still in use. | Certificates regenerated. 1 API key must be reissued. | Certificates regenerated. {count} API keys must be reissued.", + "admin.utilities.invalidSessionSecret": "Invalidate User Sessions Secret", + "admin.utilities.invalidSessionSecretConfirm": "A new secret will be generated for signing session cookies, and every open session will be ended.", + "admin.utilities.invalidSessionSecretConfirmWarn": "Everyone is logged out immediately, you included — you will have to sign in again. The new secret is only used for signing once each server has been restarted. API keys are unaffected.", + "admin.utilities.invalidSessionSecretFailed": "Failed to rotate the user sessions secret.", + "admin.utilities.invalidSessionSecretHint": "Rotate the secret used to sign session cookies and end every open session. Everyone is logged out.", "admin.utilities.purgeHistory": "Purge History", + "admin.utilities.purgeHistoryConfirm": "Every page version older than **{timeframe}** will be deleted, on every site.", + "admin.utilities.purgeHistoryConfirmWarn": "Pages keep the content they have now, but a discarded version cannot be brought back. Any deleted page older than the selected timeframe cannot be recovered.", + "admin.utilities.purgeHistoryFailed": "Failed to purge the page history.", "admin.utilities.purgeHistoryHint": "Delete history (content versioning) older than the selected timeframe.", + "admin.utilities.purgeHistorySuccess": "No page version was old enough to purge. | 1 page version purged. | {count} page versions purged.", "admin.utilities.purgeHistoryTimeframe": "Delete older than...", + "admin.utilities.purgeRevokedKeys": "Purge Revoked API Keys", + "admin.utilities.purgeRevokedKeysConfirm": "Every API key that has been revoked will be deleted permanently.", + "admin.utilities.purgeRevokedKeysConfirmWarn": "Nobody loses access — a revoked key already grants none — but the record that it ever existed goes with it. Keys marked as invalidated are kept.", + "admin.utilities.purgeRevokedKeysFailed": "Failed to purge the revoked API keys.", + "admin.utilities.purgeRevokedKeysHint": "Permanently delete the API keys that have been revoked. Invalidated keys are kept.", + "admin.utilities.purgeRevokedKeysSuccess": "No revoked API key to purge. | 1 revoked API key deleted. | {count} revoked API keys deleted.", "admin.utilities.scanPageProblems": "Scan for Page Problems", "admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.", "admin.utilities.subtitle": "Maintenance and miscellaneous tools", diff --git a/backend/models/apiKeys.ts b/backend/models/apiKeys.ts index e48941293..df55c68d2 100644 --- a/backend/models/apiKeys.ts +++ b/backend/models/apiKeys.ts @@ -1,9 +1,70 @@ import crypto from 'node:crypto' import { apiKeys as apiKeysTable, groups as groupsTable } from '../db/schema.ts' -import { desc, eq, inArray, sql } from 'drizzle-orm' +import { and, desc, eq, gt, inArray, sql } from 'drizzle-orm' import { flatten, uniq } from 'es-toolkit/array' import { epochSeconds, signJwt, verifyJwt } from '../helpers/jwt.ts' +/** + * The `aud` claim every key carries, and the one value `verify()` accepts. + * + * Fixed rather than configurable: the wiki is both the issuer and the only audience of these tokens, + * so there is nothing for an operator to point it at. It was a setting until the admin area's JWT + * section went — a section whose other two fields nothing read — and all changing it ever did was + * invalidate every key already issued. + */ +const TOKEN_AUDIENCE = 'urn:wiki.js' + +/** An API key signing keypair, with the passphrase its private half is encrypted under. */ +export interface SigningCertificates { + /** Protects the private key at rest. Belongs to the keypair, and is rotated with it. */ + passphrase: string + /** + * When this keypair came into being, as an RFC 3339 instant. + * + * Kept because it is the only thing that can explain a key which is neither revoked nor expired + * and still does not work: a key issued before this moment was signed by a keypair that no longer + * exists. See {@link ApiKeys.getKeys}. + */ + generatedAt: string + public: string + private: string +} + +/** + * A fresh signing keypair. + * + * Called twice: once at install, to seed `auth.certs` (`models/settings.ts`), and again whenever an + * administrator invalidates the certificates. Both go through here so that a rotated keypair is + * generated exactly like the original one. + * + * The passphrase is generated with the keypair rather than taken from anywhere else. It used to be + * `auth.secret` — the same value @fastify/session signs cookies with — which tied two unrelated + * secrets together: rotating the session secret would have left the private key undecryptable, and + * replacing the keypair meant logging everybody out. + */ +export function generateSigningCertificates(): SigningCertificates { + const passphrase = crypto.randomBytes(32).toString('hex') + const pair = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase + } + }) + return { + passphrase, + generatedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }), + public: pair.publicKey, + private: pair.privateKey + } +} + /** The lifetimes the admin area offers, as durations the API accepts. */ export const KEY_EXPIRATIONS = { '30d': { days: 30 }, @@ -27,6 +88,16 @@ export interface ApiKey { updatedAt: Date } +/** + * A key as the admin area lists it: the row, plus whether the certificates have moved on without it. + * + * `isInvalidated` is not stored anywhere. It is the row's age compared against the keypair's, which + * is the whole of what makes a key stop working when the certificates are regenerated. + */ +export interface ApiKeyListEntry extends ApiKey { + isInvalidated: boolean +} + /** What a verified key grants, resolved from its groups at request time. */ export interface ApiKeyIdentity { id: string @@ -62,19 +133,66 @@ class ApiKeys { private privateKey(): crypto.KeyObject { return crypto.createPrivateKey({ key: WIKI.config.auth.certs.private, - passphrase: WIKI.config.auth.secret + passphrase: WIKI.config.auth.certs.passphrase }) } + /** + * Replace the signing keypair and its passphrase, invalidating every key ever issued. + * + * A key is only a signature over its claims, so this is what takes back keys that have escaped: + * the rows stay, and every token signed by the old key stops verifying on the next request. The + * rows are not marked revoked — revocation is a decision an administrator made about one key, and + * saying that about all of them would lose the distinction. Minting a key from the same row is not + * possible either, so the count returned is what an administrator has to reissue. + * + * Session cookies are untouched: they are signed with `auth.secret`, which this does not go near. + * + * @returns How many keys were still usable and no longer are, or null if the settings failed to save + */ + async regenerateCertificates(): Promise { + const previousAuth = WIKI.config.auth + const usable = await WIKI.db.$count( + apiKeysTable, + and(eq(apiKeysTable.isRevoked, false), gt(apiKeysTable.expiration, sql`now()`)) + ) + + WIKI.config.auth = { ...previousAuth, certs: generateSigningCertificates() } + // -> Propagates as `reloadConfig`, which is how the other instances pick up the new public key + // rather than going on trusting tokens this one has just disowned + if (!(await WIKI.configSvc.saveToDb(['auth']))) { + WIKI.config.auth = previousAuth + return null + } + + WIKI.logger.info(`Regenerated the API key certificates, invalidating ${usable} key(s) [ OK ]`) + return usable + } + /** * Every key, newest first. Revoked and expired keys are kept: the admin list shows their state. + * + * Each one is marked against the age of the signing keypair. A key issued before the certificates + * were last regenerated was signed by a keypair that is gone, so it fails verification on its + * signature and there is nothing about the row itself to explain why — which is exactly the state + * an administrator needs pointed out, and the one thing distinguishing it from a key somebody + * chose to revoke. */ - async getKeys(): Promise { + async getKeys(): Promise { const results = await WIKI.db .select(keySelection) .from(apiKeysTable) .orderBy(desc(apiKeysTable.createdAt)) - return results as ApiKey[] + const generatedAt = Temporal.Instant.from(WIKI.config.auth.certs.generatedAt) + return (results as ApiKey[]).map((key) => ({ + ...key, + isInvalidated: Temporal.Instant.compare(key.createdAt.toTemporalInstant(), generatedAt) < 0 + })) + } + + /** When the keypair keys are signed with came into being. */ + certificatesGeneratedAt(): string { + return WIKI.config.auth.certs.generatedAt } /** @@ -98,12 +216,9 @@ class ApiKeys { const key = signJwt( { - // -> `api` marks the token as a key rather than a user token, so the two can never be - // confused should user tokens ever be signed with the same keypair - api: 1, id, grp: groups, - aud: WIKI.config.auth.audience, + aud: TOKEN_AUDIENCE, iat: epochSeconds(), exp: epochSeconds(expiresAt) }, @@ -147,6 +262,27 @@ class ApiKeys { return (result.rowCount ?? 0) > 0 } + /** + * Delete every revoked key. + * + * Housekeeping, not a security measure: a revoked key already authenticates nothing, and this only + * takes its row out of the admin list. What it costs is the record that the key ever existed, which + * is why nothing does it automatically. + * + * Invalidated keys are left alone. One of those is still a key somebody issued and has not decided + * anything about — it stopped working because the certificates moved, and the row is what tells its + * owner they have to reissue it. A key that is both revoked and invalidated goes: revoking is the + * decision, and this deletes what was decided about. + * + * @returns How many keys were deleted + */ + async purgeRevoked(): Promise { + const result = await WIKI.db.delete(apiKeysTable).where(eq(apiKeysTable.isRevoked, true)) + const purged = result.rowCount ?? 0 + WIKI.logger.info(`Purged ${purged} revoked API key(s) [ OK ]`) + return purged + } + /** * The union of the permissions held by the given groups. * @@ -177,13 +313,15 @@ class ApiKeys { let claims try { claims = verifyJwt(token, WIKI.config.auth.certs.public, { - audience: WIKI.config.auth.audience + audience: TOKEN_AUDIENCE }) } catch (err: any) { throw new ApiKeyError(err.message) } - if (claims.api !== 1 || typeof claims.id !== 'string') { + // -> A token this keypair signed but which names no key. There is nothing else it could be — + // logins are sessions, and this keypair signs nothing but API keys. + if (typeof claims.id !== 'string') { throw new ApiKeyError('Token is not an API key.') } diff --git a/backend/models/assets.ts b/backend/models/assets.ts index 8391ef3b9..3ba2b2a63 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -793,6 +793,22 @@ class Assets { } } + /** + * Drop both serving caches of this instance. + * + * Nothing is lost: the metadata is read back from the database on the next request for a path, and + * the bytes on the next request for a file. What it costs is the refill — every image on the next + * page view goes to the database once — which is the price of being certain nothing stale is being + * served. + */ + async purgeCache(): Promise { + this.pathCache.clear() + this.writtenSinceSweep = 0 + await fs.rm(this.cachePath, { recursive: true, force: true }) + await fs.mkdir(this.cachePath, { recursive: true }) + WIKI.logger.info('Purged the file cache [ OK ]') + } + /** Where the disk cache lives. Derived data — deleting it costs a refill and nothing else. */ get cachePath(): string { return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/files') diff --git a/backend/models/pageHistory.ts b/backend/models/pageHistory.ts index 743a01189..a635a8e30 100644 --- a/backend/models/pageHistory.ts +++ b/backend/models/pageHistory.ts @@ -1,5 +1,5 @@ import { isEqual } from 'es-toolkit/predicate' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, lt, sql } from 'drizzle-orm' import { pageHistory as pageHistoryTable, pages as pagesTable, @@ -17,6 +17,26 @@ export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted'] as export type PageHistoryAction = (typeof pageHistoryActions)[number] +/** + * How far back the admin area's purge can be told to keep, and the interval each answer means. + * + * The values are postgres intervals rather than a duration computed here, so that the cutoff is + * measured against the same clock the rows were written by: `versionDate` takes the column default, + * which is `now()`, and a timestamp column carries no offset to reconcile a date computed in this + * process against. It also gets the calendar arithmetic for free — a month is a month, whichever one + * it lands in. + */ +export const purgeTimeframes = { + '24h': '24 hours', + '1m': '1 month', + '3m': '3 months', + '6m': '6 months', + '1y': '1 year', + '2y': '2 years' +} as const + +export type PurgeTimeframe = keyof typeof purgeTimeframes + /** * The page fields a version carries beyond the ones with columns of their own. * @@ -276,6 +296,34 @@ class PageHistory { } } + /** + * Drop every version older than a timeframe, across every site. + * + * Content versioning is the only thing this touches: a page's own row holds what it says now, so + * purging changes nothing anybody reads — it shortens timelines and takes away what a page can be + * rolled back to. A page whose every version is older than the cutoff keeps the page and loses its + * history entirely, which includes the `created` row saying when it appeared. + * + * What it does not spare is a page that no longer exists. Its versions outlive it deliberately (see + * `db/schema.ts`), and they are all that is left of it — so purging past the day it was deleted is + * what finally discards it. Reclaiming that space is the point of this; there is nothing to undo it + * with. + * + * @param olderThan How far back to keep, as one of {@link purgeTimeframes} + * @returns How many versions were dropped + */ + async purge(olderThan: PurgeTimeframe): Promise { + const interval = purgeTimeframes[olderThan] + const result = await WIKI.db + .delete(pageHistoryTable) + // -> The interval is bound as a parameter and cast, rather than interpolated: the value is off + // a closed list, but a raw fragment built from a request is a habit worth not having + .where(lt(pageHistoryTable.versionDate, sql`now() - ${interval}::interval`)) + const purged = result.rowCount ?? 0 + WIKI.logger.info(`Purged ${purged} page version(s) older than ${interval} [ OK ]`) + return purged + } + /** * Which of a page's fields a patch actually changes. * diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts index 8482c29e9..cf3f7d45e 100644 --- a/backend/models/rendering.ts +++ b/backend/models/rendering.ts @@ -295,6 +295,18 @@ const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'ftp'] /** Attributes the editor adds for its own preview and that mean nothing in a stored page. */ const EDITOR_ARTIFACT_ATTRIBUTES = ['data-line'] +/** + * An icon dimension as a CSS length, or nothing when it is not one. + * + * Iconify reads a bare `32` as pixels and CSS does not, so the unit has to be spelled out. Anything + * that is not a plain length is refused rather than passed along: this ends up inside a `style`, + * where a value carrying a `;` would be a second declaration riding in on the first. + */ +function cssLength(value: string): string { + const match = /^(\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(value.trim()) + return match ? `${match[1]}${match[2] ?? 'px'}` : '' +} + /** * Turn a heading into an anchor fragment. * @@ -639,8 +651,27 @@ class Rendering { shortcodes become are styled there for the same reason. */ svg.attr('class', ['icon', authorClass].filter(Boolean).join(' ')) - // -> Ours first so that an author who set `vertical-align` themselves still wins - const styles = [inline === undefined ? '' : 'vertical-align:-0.125em', style ?? ''] + /* + The size goes into the style as well as into the attributes, and only when it was asked for. + `.page-contents` sizes an icon to 1.4em by default — an icon reads small beside text at the 1em + Iconify draws at — and a CSS width outranks the `width` attribute, so an author who wrote + `width="32"` would otherwise be overruled by the default they were overriding. + + Both axes, read back off the drawing rather than from what was asked for: an author who gave + only `width` had the other worked out for them from the icon's ratio, and pinning theirs alone + would leave the stylesheet supplying a height that does not go with it. + */ + const sized: string[] = [] + if (width || height) { + for (const axis of ['width', 'height'] as const) { + const length = cssLength(svg.attr(axis) ?? '') + if (length) { + sized.push(`${axis}:${length}`) + } + } + } + // -> Ours first so that an author who set any of these themselves still wins + const styles = [...sized, inline === undefined ? '' : 'vertical-align:-0.125em', style ?? ''] .filter(Boolean) .join(';') if (styles) { diff --git a/backend/models/security.ts b/backend/models/security.ts index 788621cb8..da42537eb 100644 --- a/backend/models/security.ts +++ b/backend/models/security.ts @@ -23,27 +23,15 @@ export const SECURITY_FIELDS = [ 'uploadScanSVG' ] as const -/** - * The JWT fields the admin area shows, mapped onto the `auth` settings they really live in. - * - * The `security` blob used to carry copies of these under the 2.x names, which nothing read — so the - * view was editing values with no effect. These are the keys the running server uses. - */ -export const AUTH_FIELD_MAP = { - authJwtAudience: 'audience', - authJwtExpiration: 'tokenExpiration', - authJwtRenewablePeriod: 'tokenRenewal' -} as const - /** A duration as the admin area writes it: `30m`, `14d`, `1y`. */ const DURATION_PATTERN = /^\d+[smhdwy]$/ /** * Security model * - * One flat surface for the admin area's security view, even though the values are stored in two - * settings blobs. Most of them are read when the HTTP server starts — see the `Security` section of - * `index.ts` — so saving them here takes effect on the next restart. + * The admin area's security view, which is exactly the `security` settings blob. Most of it is read + * when the HTTP server starts — see the `Security` section of `index.ts` — so saving here takes + * effect on the next restart. */ class Security { /** @@ -55,9 +43,6 @@ class Security { for (const field of SECURITY_FIELDS) { config[field] = security[field] } - for (const [field, authKey] of Object.entries(AUTH_FIELD_MAP)) { - config[field] = WIKI.config.auth?.[authKey] - } return config } @@ -66,7 +51,7 @@ class Security { */ pickFields(body: Record): Record { const patch: Record = {} - for (const field of [...SECURITY_FIELDS, ...Object.keys(AUTH_FIELD_MAP)]) { + for (const field of SECURITY_FIELDS) { if (body[field] !== undefined) { patch[field] = body[field] } @@ -130,57 +115,20 @@ class Security { } } - for (const [field, label] of [ - ['authJwtExpiration', 'token expiration'], - ['authJwtRenewablePeriod', 'token renewal period'] - ] as const) { - if (!DURATION_PATTERN.test(merged[field] ?? '')) { - return `The ${label} must be a duration such as 30m, 12h or 14d.` - } - } - if (!merged.authJwtAudience || `${merged.authJwtAudience}`.trim().length < 1) { - return 'The JWT audience cannot be empty.' - } - return null } /** - * Save a validated patch, splitting it across the two settings blobs it belongs to. - * - * Both are written in one go and rolled back together, so a failure cannot leave the JWT settings - * updated while the rest is not. + * Save a validated patch. * * @returns Whether the settings were saved */ async updateConfig(patch: Record): Promise { const previousSecurity = WIKI.config.security - const previousAuth = WIKI.config.auth - const keys: string[] = [] - - const securityPatch: Record = {} - const authPatch: Record = {} - for (const [field, value] of Object.entries(patch)) { - const authKey = AUTH_FIELD_MAP[field as keyof typeof AUTH_FIELD_MAP] - if (authKey) { - authPatch[authKey] = typeof value === 'string' ? value.trim() : value - } else { - securityPatch[field] = value - } - } - - if (Object.keys(securityPatch).length > 0) { - WIKI.config.security = { ...previousSecurity, ...securityPatch } - keys.push('security') - } - if (Object.keys(authPatch).length > 0) { - WIKI.config.auth = { ...previousAuth, ...authPatch } - keys.push('auth') - } + WIKI.config.security = { ...previousSecurity, ...patch } - if (!(await WIKI.configSvc.saveToDb(keys))) { + if (!(await WIKI.configSvc.saveToDb(['security']))) { WIKI.config.security = previousSecurity - WIKI.config.auth = previousAuth return false } return true diff --git a/backend/models/sessions.ts b/backend/models/sessions.ts index f3f59bb85..e0c18580a 100644 --- a/backend/models/sessions.ts +++ b/backend/models/sessions.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import { eq, sql } from 'drizzle-orm' import { sessions as sessionsTable } from '../db/schema.ts' @@ -77,6 +78,37 @@ class Sessions { async clearSessionsFromUser(userId: string) { return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, userId)) } + + /** + * Replace the secret cookies are signed with, and end every session there is. + * + * The two halves do different work, and both are needed. Dropping the rows is what logs everybody + * out **now**: a cookie whose session is gone identifies nothing, so the next request from every + * browser — on every instance, since the rows are shared — starts a new, anonymous one. Rotating + * the secret is what makes the cookies themselves worthless, and that one waits: @fastify/session + * and @fastify/cookie are handed the secret when the HTTP server starts (`index.ts`), so this + * server goes on validating signatures with the old one until it is restarted. + * + * The API key keypair is untouched: it carries its own passphrase (`models/apiKeys.ts`), so keys + * already issued keep working. + * + * @returns How many sessions were ended, or null if the settings failed to save + */ + async rotateSecret(): Promise { + const previousAuth = WIKI.config.auth + WIKI.config.auth = { ...previousAuth, secret: crypto.randomBytes(32).toString('hex') } + // -> Propagates as `reloadConfig`, so the other instances are holding the new secret the next + // time any of them restarts + if (!(await WIKI.configSvc.saveToDb(['auth']))) { + WIKI.config.auth = previousAuth + return null + } + + const result = await WIKI.db.delete(sessionsTable) + const ended = result.rowCount ?? 0 + WIKI.logger.info(`Rotated the session secret and ended ${ended} session(s) [ OK ]`) + return ended + } } export const sessions = new Sessions() diff --git a/backend/models/settings.ts b/backend/models/settings.ts index 0a45eebe2..e9d4e8aef 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -1,5 +1,5 @@ import { settings as settingsTable } from '../db/schema.ts' -import { pem2jwk } from 'pem-jwk' +import { generateSigningCertificates } from './apiKeys.ts' import crypto from 'node:crypto' import type { SystemIds } from './types.ts' @@ -41,20 +41,7 @@ class Settings { */ async init(ids: SystemIds): Promise { WIKI.logger.info('Generating certificates...') - const secret = crypto.randomBytes(32).toString('hex') - const certs = crypto.generateKeyPairSync('rsa', { - modulusLength: 2048, - publicKeyEncoding: { - type: 'pkcs1', - format: 'pem' - }, - privateKeyEncoding: { - type: 'pkcs1', - format: 'pem', - cipher: 'aes-256-cbc', - passphrase: secret - } - }) + const certs = generateSigningCertificates() WIKI.logger.info('Inserting default settings...') await WIKI.db.insert(settingsTable).values([ @@ -67,15 +54,14 @@ class Settings { { key: 'auth', value: { - audience: 'urn:wiki.js', - tokenExpiration: '30m', - tokenRenewal: '14d', - certs: { - jwk: pem2jwk(certs.publicKey), - public: certs.publicKey, - private: certs.privateKey - }, - secret, + // -> The installation keypair, carrying its own passphrase. Its one job is signing API + // keys (`models/apiKeys.ts`). + certs, + // -> What @fastify/session signs its cookies with, and nothing else. Separate from the + // keypair's passphrase so that either can be rotated without disturbing the other — + // the two utilities that do so are `POST /system/certificates` and + // `POST /system/sessions/invalidate`. + secret: crypto.randomBytes(32).toString('hex'), rootAdminGroupId: ids.groupAdminId, rootAdminUserId: ids.userAdminId, guestUserId: ids.userGuestId @@ -152,10 +138,6 @@ class Settings { forceAssetDownload: true, hstsDuration: 0, trustProxy: false, - // NOTE: the JWT audience, expiration and renewal period are deliberately absent here. - // They used to be duplicated under 2.x names (`authJwt*`) that nothing read, so the - // admin area edited values with no effect. They live in the `auth` settings above, - // which is what the server uses; the security view maps onto those. uploadMaxFileSize: 10485760, uploadMaxFiles: 20, uploadScanSVG: true diff --git a/backend/package-lock.json b/backend/package-lock.json index cef68ea59..8e69eef94 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -42,7 +42,6 @@ "nanoid": "6.0.1", "node-cache": "5.1.2", "openid-client": "6.8.4", - "pem-jwk": "2.0.0", "pg": "8.23.0", "poolifier": "5.3.2", "qrcode": "1.5.4", @@ -56,7 +55,6 @@ "@types/fs-extra": "11.0.4", "@types/js-yaml": "4.0.9", "@types/node": "26.2.0", - "@types/pem-jwk": "2.0.2", "@types/pg": "8.21.0", "@types/qrcode": "1.5.6", "@types/sanitize-html": "2.16.1", @@ -2702,13 +2700,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/pem-jwk": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/pem-jwk/-/pem-jwk-2.0.2.tgz", - "integrity": "sha512-wkdQZtXBObWNxv8Uo1N6SiNU/ZkhvK7UkrBPr0yNvEUlI6/zx2FuQJ95njlw/1jmlKE+cHAFDVB26Z9vNuebfg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/pg": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz", @@ -3206,18 +3197,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -3302,12 +3281,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4820,12 +4793,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5244,21 +5211,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pem-jwk": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pem-jwk/-/pem-jwk-2.0.0.tgz", - "integrity": "sha512-rFxu7rVoHgQ5H9YsP50dDWf0rHjreVA2z0yPiWr5WdH/UHb29hKtF7h6l8vNd1cbYR1t0QL+JKhW55a2ZV4KtA==", - "license": "MPL-2.0", - "dependencies": { - "asn1.js": "^5.0.1" - }, - "bin": { - "pem-jwk": "bin/pem-jwk.js" - }, - "engines": { - "node": ">=5.10.0" - } - }, "node_modules/pg": { "version": "8.23.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1f03890ac..fd3b621fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -68,7 +68,6 @@ "nanoid": "6.0.1", "node-cache": "5.1.2", "openid-client": "6.8.4", - "pem-jwk": "2.0.0", "pg": "8.23.0", "poolifier": "5.3.2", "qrcode": "1.5.4", @@ -85,7 +84,6 @@ "@types/fs-extra": "11.0.4", "@types/js-yaml": "4.0.9", "@types/node": "26.2.0", - "@types/pem-jwk": "2.0.2", "@types/pg": "8.21.0", "@types/qrcode": "1.5.6", "@types/sanitize-html": "2.16.1", diff --git a/frontend/public/_assets/icons/fluent-invert-files-selection.svg b/frontend/public/_assets/icons/fluent-invert-files-selection.svg new file mode 100644 index 000000000..0d7eb26f5 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-invert-files-selection.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f57b0e08b..1400f1b8a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -215,11 +215,6 @@ async function loadBootstrap() { router.beforeEach(async (to, from) => { commonStore.routerLoading = true - // -> Init Auth Token - // if (userStore.token && !userStore.authenticated) { - // userStore.loadToken() - // } - /* -> Site info, system flags and the session One request for the three of them: none touches the database, so what they cost is the round trip, diff --git a/frontend/src/boot/api.js b/frontend/src/boot/api.js index 4e952cb24..8eea3a11e 100644 --- a/frontend/src/boot/api.js +++ b/frontend/src/boot/api.js @@ -1,51 +1,20 @@ import ky from 'ky' -import { useUserStore } from '@/stores/user' - -export function initializeApi(store) { - const userStore = useUserStore(store) - - let refreshPromise = null - let fetching = false - +/** + * The HTTP client every call to the API goes through, exposed as the `API_CLIENT` global. + * + * Nothing is attached to a request beyond the session cookie: authentication is the `wikiSession` + * cookie the server sets, sent because of `credentials`. There used to be a `beforeRequest` hook here + * that refreshed a JWT and set an `Authorization` header — a leftover from when 3.x authenticated + * with tokens. The user store it read has had no token since sessions replaced them, so the hook only + * ever set an empty header. API keys still use bearer tokens, but those belong to callers outside + * this app. + */ +export function initializeApi() { const client = ky.create({ prefix: '/_api', credentials: 'same-origin', - throwHttpErrors: (statusNumber) => statusNumber > 400, // Don't throw for 400 - hooks: { - beforeRequest: [ - async ({ request }) => { - // -> Guest - if (!userStore.token) { - request.headers.set('Authorization', '') - return - } - - // -> Refresh Token - if (!userStore.isTokenValid({ minutes: 1 })) { - if (!fetching) { - refreshPromise = new Promise((resolve, reject) => { - ;(async () => { - fetching = true - try { - await userStore.refreshToken() - resolve() - } catch (err) { - reject(err) - } - fetching = false - })() - }) - } else { - // -> Another request is already executing, wait for it to complete - await refreshPromise - } - } - - request.headers.set('Authorization', userStore.token ? `Bearer ${userStore.token}` : '') - } - ] - } + throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 }) if (import.meta.env.SSR) { diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index 4282cec78..22948457d 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -1390,16 +1390,35 @@ /* An Iconify icon, drawn into the page as an `` when it was saved -- see `inlineIcons` in - `models/rendering.ts`. Sized and coloured by the markup itself (`1em`, `currentColor`), so all it - needs from here is to be part of the line: Preflight makes every `svg` a block, which would put - an icon written mid-sentence on a line of its own. + `models/rendering.ts`. Coloured by the markup itself (`currentColor`), so what it needs from here + is to be part of the line -- Preflight makes every `svg` a block, which would put an icon written + mid-sentence on a line of its own -- and to be big enough to read beside the text it sits in. - An `` that has not been through that yet -- a page saved before this, or an icon - whose set could not be resolved at the time -- carries the same rule from its own `:host`, so the - two sit identically and the editor's preview matches the page. + 1.4em rather than the 1em Iconify draws at: an icon is read as a glyph, and a glyph the same + nominal size as the type around it looks smaller than it, because a letter fills a fraction of + its em box while an icon fills the whole one. + + An author who asked for a size gets it: `inlineIcons` writes that one as an inline style, which + outranks this. */ svg.icon { display: inline-block; + width: 1.4em; + height: 1.4em; + } + + /* + The same icon before it has been inlined: what the editor's preview draws, what a page saved + before this carries, and what is left where a set could not be resolved at save time. + + Sized through `font-size` rather than through `width`, which is the one difference between the + two: the element draws its own `` inside a shadow root nothing here can reach, + so growing the host box would leave a 1em glyph centred in a 1.4em one. Scaling what that `em` + measures against grows the drawing. An explicit `width` on the element is in pixels and is + untouched by this, exactly as the inline style above leaves the inlined form alone. + */ + iconify-icon { + font-size: 1.4em; } /* Twemoji, which the renderer swaps in for `:shortcodes:` */ diff --git a/frontend/src/main.js b/frontend/src/main.js index 034c9c711..8e34b6262 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -26,7 +26,7 @@ app.use(store) app.use(router) initializeHairlines() -initializeApi(store) +initializeApi() initializeComponents(app) initializeEventBus() initializeIconify() diff --git a/frontend/src/pages/AdminApi.vue b/frontend/src/pages/AdminApi.vue index 0e1c5d289..d84b8edbd 100644 --- a/frontend/src/pages/AdminApi.vue +++ b/frontend/src/pages/AdminApi.vue @@ -113,22 +113,23 @@ }} - - - -
- {{ key.isRevoked ? t('admin.api.revoked') : t('admin.api.expired') }} + + +
+ +
+ {{ t(`admin.api.${keyState(key)}`) }} +
+
+ +
+ {{ stateHint(key) }}
- {{ - key.isRevoked ? t('admin.api.revokedHint') : t('admin.api.expiredHint') - }}
@@ -196,7 +197,9 @@ const state = reactive({ loading: 0, isToggleLoading: false, keys: [], - groups: [] + groups: [], + /** When the signing keypair was generated — what an invalidated key is invalidated by. */ + certificatesGeneratedAt: null }) // METHODS @@ -222,8 +225,37 @@ function isExpired(key) { ) } +/** + * Why a key does not work, or null when it does. + * + * A key can be in more than one of these at once — revoked *and* long expired, say — so they are + * ordered by how much each explains: what somebody did to this one key, then what the certificates + * did to all of them, then time running out. `isInvalidated` comes from the server, which is the + * side holding the date the keypair was generated. + */ +function keyState(key) { + if (key.isRevoked) { + return 'revoked' + } + if (key.isInvalidated) { + return 'invalidated' + } + return isExpired(key) ? 'expired' : null +} + +/** The sentence under a key's state: what it means, and what to do about it. */ +function stateHint(key) { + const status = keyState(key) + if (!status) { + return '' + } + return status === 'invalidated' + ? t('admin.api.invalidatedHint', { date: humanizeDate(state.certificatesGeneratedAt) }) + : t(`admin.api.${status}Hint`) +} + function isUsable(key) { - return !key.isRevoked && !isExpired(key) + return keyState(key) === null } /** Group names rather than IDs, falling back to the ID for a group that has since been deleted. */ @@ -237,15 +269,18 @@ async function load() { state.loading++ loading.show() try { - // -> Groups are fetched alongside the keys so the list can name the permissions each key carries - const [keys, apiState, groups] = await Promise.all([ + // -> Groups are fetched alongside the keys so the list can name the permissions each key carries, + // and the certificate date so an invalidated key can say what invalidated it + const [keys, apiState, groups, certs] = await Promise.all([ API_CLIENT.get('api-keys').json(), API_CLIENT.get('system/api').json(), - API_CLIENT.get('groups').json() + API_CLIENT.get('groups').json(), + API_CLIENT.get('system/certificates').json() ]) state.keys = keys ?? [] state.groups = groups ?? [] state.enabled = apiState?.isEnabled === true + state.certificatesGeneratedAt = certs?.generatedAt ?? null // -> Keeps the status light in the admin sidebar in step without another round trip adminStore.info.isApiEnabled = state.enabled } catch (err) { diff --git a/frontend/src/pages/AdminAuth.vue b/frontend/src/pages/AdminAuth.vue index 8e9c450de..2e9588e3b 100644 --- a/frontend/src/pages/AdminAuth.vue +++ b/frontend/src/pages/AdminAuth.vue @@ -411,7 +411,7 @@ import { useDark } from '@/composables/dark' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' import { loading } from '@/composables/loading' -import { dialog } from '@/composables/dialog' +import { confirm } from '@/composables/dialog' import { useSiteStore } from '@/stores/site' import { apiErrorMessage } from '@/helpers/apiError' @@ -706,20 +706,13 @@ function confirmDelete() { state.strategy = state.activeStrategies[0] ?? { strategy: {} } return } - dialog({ + confirm({ title: t('admin.auth.deleteStrategy'), message: t('admin.auth.deleteConfirm', { strategy: strategy.displayName }), persistent: true, - ok: { - label: t('common.actions.delete'), - color: 'negative', - unelevated: true - }, - cancel: { - label: t('common.actions.cancel'), - color: 'grey', - flat: true - } + cancel: true, + color: 'negative', + okLabel: t('common.actions.delete') }).onOk(async () => { state.loading++ try { diff --git a/frontend/src/pages/AdminBlocks.vue b/frontend/src/pages/AdminBlocks.vue index bc800a8d1..1e45b6c48 100644 --- a/frontend/src/pages/AdminBlocks.vue +++ b/frontend/src/pages/AdminBlocks.vue @@ -110,7 +110,7 @@ import { useDark } from '@/composables/dark' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' import { loading } from '@/composables/loading' -import { dialog } from '@/composables/dialog' +import { confirm } from '@/composables/dialog' import { useAdminStore } from '@/stores/admin' import { useFlagsStore } from '@/stores/flags' @@ -213,7 +213,7 @@ function addBlock() { function deleteBlock(id) { const block = state.blocks.find((bl) => bl.id === id) - dialog({ + confirm({ title: t('admin.blocks.delete'), message: t('admin.blocks.deleteConfirm', { blockName: block?.name ?? '' }), cancel: true, diff --git a/frontend/src/pages/AdminIcons.vue b/frontend/src/pages/AdminIcons.vue index 238a015e4..45304a82b 100644 --- a/frontend/src/pages/AdminIcons.vue +++ b/frontend/src/pages/AdminIcons.vue @@ -281,7 +281,7 @@ import { computed, onMounted, reactive } from 'vue' import { useDark } from '@/composables/dark' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' -import { dialog } from '@/composables/dialog' +import { confirm } from '@/composables/dialog' import { useSiteStore } from '@/stores/site' import { apiErrorMessage } from '@/helpers/apiError' @@ -487,20 +487,13 @@ async function setSetState(set, isEnabled) { } function confirmDeleteSet(set) { - dialog({ + confirm({ title: t('admin.icons.deleteSet'), message: t('admin.icons.deleteSetConfirm', { set: set.name, count: set.iconCount }), persistent: true, - ok: { - label: t('common.actions.delete'), - color: 'negative', - unelevated: true - }, - cancel: { - label: t('common.actions.cancel'), - color: 'grey', - flat: true - } + cancel: true, + color: 'negative', + okLabel: t('common.actions.delete') }).onOk(async () => { state.loading++ try { @@ -530,20 +523,13 @@ function confirmDeleteSet(set) { } function purgeCache() { - dialog({ + confirm({ title: t('admin.icons.purgeCache'), message: t('admin.icons.purgeCacheConfirm'), persistent: true, - ok: { - label: t('admin.icons.purgeCache'), - color: 'negative', - unelevated: true - }, - cancel: { - label: t('common.actions.cancel'), - color: 'grey', - flat: true - } + cancel: true, + color: 'negative', + okLabel: t('admin.icons.purgeCache') }).onOk(async () => { state.loading++ try { diff --git a/frontend/src/pages/AdminSecurity.vue b/frontend/src/pages/AdminSecurity.vue index e6c037fed..6bdf191d2 100644 --- a/frontend/src/pages/AdminSecurity.vue +++ b/frontend/src/pages/AdminSecurity.vue @@ -406,56 +406,6 @@ - - - - - {{ t('admin.security.jwt') }} - - - - {{ t(`admin.security.jwtAudience`) }} - {{ t(`admin.security.jwtAudienceHint`) }} - - - - - - - - - - {{ t(`admin.security.tokenExpiration`) }} - {{ t(`admin.security.tokenExpirationHint`) }} - - - - - - - - - - {{ t(`admin.security.tokenRenewalPeriod`) }} - {{ t(`admin.security.tokenRenewalPeriodHint`) }} - - - - - -
@@ -510,9 +460,6 @@ const state = reactive({ authRateLimitMax: 10, authRateLimitWindow: '5m', authRateLimitBan: '15m', - authJwtAudience: 'urn:wiki.js', - authJwtExpiration: '30m', - authJwtRenewablePeriod: '14d', uploadMaxFileSize: 0, uploadMaxFiles: 0, uploadScanSVG: false diff --git a/frontend/src/pages/AdminStorage.vue b/frontend/src/pages/AdminStorage.vue index c81bea045..2d6763c8d 100644 --- a/frontend/src/pages/AdminStorage.vue +++ b/frontend/src/pages/AdminStorage.vue @@ -750,7 +750,7 @@ import { useDark } from '@/composables/dark' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' import { loading } from '@/composables/loading' -import { dialog } from '@/composables/dialog' +import { confirm, dialog } from '@/composables/dialog' import { useAdminStore } from '@/stores/admin' import { useSiteStore } from '@/stores/site' @@ -1117,20 +1117,13 @@ async function executeAction(act) { // -> An action that declares a warning destroys something, so it is never run on a single click if (act.warn) { - dialog({ + confirm({ title: act.label, message: act.warn, persistent: true, - ok: { - label: t('common.actions.proceed'), - color: 'negative', - unelevated: true - }, - cancel: { - label: t('common.actions.cancel'), - color: 'grey', - flat: true - } + cancel: true, + color: 'negative', + okLabel: t('common.actions.proceed') }).onOk(run) } else { await run() @@ -1154,7 +1147,7 @@ async function handleSetupCallback() { } async function setupDestroy() { - dialog({ + confirm({ title: t('admin.storage.destroyConfirm'), message: t('admin.storage.destroyConfirmInfo'), cancel: true, diff --git a/frontend/src/pages/AdminUtilities.vue b/frontend/src/pages/AdminUtilities.vue index 69b390ea8..eeb70eda1 100644 --- a/frontend/src/pages/AdminUtilities.vue +++ b/frontend/src/pages/AdminUtilities.vue @@ -57,6 +57,7 @@ flat icon="la:arrow-circle-right" color="primary" + disabled :label="t(`common.actions.proceed`)" /> @@ -72,6 +73,7 @@ flat icon="la:arrow-circle-right" color="primary" + @click="flushCache" :label="t(`common.actions.proceed`)" /> @@ -87,15 +89,16 @@ flat icon="la:arrow-circle-right" color="primary" + disabled :label="t(`common.actions.proceed`)" /> - {{ t(`admin.utilities.invalidAuthCertificates`) }} + {{ t(`admin.utilities.invalidApiCertificates`) }} {{ - t(`admin.utilities.invalidAuthCertificatesHint`) + t(`admin.utilities.invalidApiCertificatesHint`) }} @@ -104,6 +107,25 @@ flat icon="la:arrow-circle-right" color="primary" + @click="invalidateApiCertificates" + :label="t(`common.actions.proceed`)" /> + + + + + + {{ t(`admin.utilities.invalidSessionSecret`) }} + {{ + t(`admin.utilities.invalidSessionSecretHint`) + }} + + + @@ -131,6 +153,23 @@ flat icon="la:arrow-circle-right" color="primary" + @click="purgeHistory" + :label="t(`common.actions.proceed`)" /> + + + + + + {{ t(`admin.utilities.purgeRevokedKeys`) }} + {{ t(`admin.utilities.purgeRevokedKeysHint`) }} + + + @@ -146,6 +185,7 @@ flat icon="la:arrow-circle-right" color="primary" + disabled :label="t(`common.actions.proceed`)" /> @@ -162,6 +202,8 @@ import { useI18n } from 'vue-i18n' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' import { loading } from '@/composables/loading' +import { confirm } from '@/composables/dialog' +import { apiErrorMessage } from '@/helpers/apiError' import { useSiteStore } from '@/stores/site' @@ -198,35 +240,223 @@ const purgeHistoryTimeframes = computed(() => [ // METHODS -async function disconnectWS() { - loading.show() - try { - const resp = await APOLLO_CLIENT.mutate({ - mutation: ` - mutation disconnectWS { - disconnectWS { - operation { - succeeded - message - } - } - } - `, - fetchPolicy: 'network-only' - }) - if (resp?.data?.disconnectWS?.operation?.succeeded) { +/** + * Close every websocket the wiki holds — the editors of anyone collaborating on a page, and any open + * admin terminal. Confirmed first because it interrupts people who are working: their clients + * reconnect on their own, but an editor is briefly cut off from the others in its room. + * + * Both this and {@link flushCache} reach every instance: the one answering the request acts on itself + * and publishes the same instruction to the others. `count` in the response is therefore only what + * this one closed, which is why it is not reported. + */ +function disconnectWS() { + confirm({ + title: t('admin.utilities.disconnectWS'), + message: t('admin.utilities.disconnectWSConfirm'), + cancel: true, + color: 'negative', + okLabel: t('common.actions.proceed') + }).onOk(async () => { + loading.show() + try { + const resp = await API_CLIENT.post('system/websockets/disconnect').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } notify({ type: 'positive', message: t('admin.utilities.disconnectWSSuccess') }) - } else { - throw new Error(resp?.data?.disconnectWS?.operation?.succeeded) + } catch (err) { + notify({ + type: 'negative', + message: t('admin.utilities.disconnectWSFailed'), + caption: apiErrorMessage(err) + }) } + loading.hide() + }) +} + +/** + * Replace the keypair API keys are signed with, taking back every key ever issued. + * + * Nobody is logged out by this — session cookies are signed with a secret of their own, which is the + * point of the two being separate — but every integration holding a key stops working until it is + * given a new one, so the confirmation says how many are affected rather than asking blind. + */ +function invalidateApiCertificates() { + confirm({ + title: t('admin.utilities.invalidApiCertificates'), + message: t('admin.utilities.invalidApiCertificatesConfirm'), + caption: t('admin.utilities.invalidApiCertificatesConfirmWarn'), + cancel: true, + persistent: true, + color: 'negative', + okLabel: t('common.actions.proceed') + }).onOk(async () => { + loading.show() + try { + const resp = await API_CLIENT.post('system/certificates').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + const count = resp.invalidatedKeys ?? 0 + notify({ + type: 'positive', + message: t('admin.utilities.invalidApiCertificatesSuccess', count, { count }) + }) + } catch (err) { + notify({ + type: 'negative', + message: t('admin.utilities.invalidApiCertificatesFailed'), + caption: apiErrorMessage(err) + }) + } + loading.hide() + }) +} + +/** + * Rotate the secret session cookies are signed with, and end every session. + * + * Including this one: the admin who clicks it is logged out with everybody else, which the + * confirmation says outright. Nothing is notified afterwards for that reason — the router lands on + * the login screen while the notification would still be on its way. + */ +function invalidateSessionSecret() { + confirm({ + title: t('admin.utilities.invalidSessionSecret'), + message: t('admin.utilities.invalidSessionSecretConfirm'), + caption: t('admin.utilities.invalidSessionSecretConfirmWarn'), + cancel: true, + persistent: true, + color: 'negative', + okLabel: t('common.actions.proceed') + }).onOk(async () => { + loading.show() + try { + const resp = await API_CLIENT.post('system/sessions/invalidate').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + // -> This session is one of the ones just ended, so there is nowhere to go but back to the + // login screen. A full load rather than a route push: every store is holding the state of + // somebody who is no longer signed in. + window.location.assign('/login') + } catch (err) { + loading.hide() + notify({ + type: 'negative', + message: t('admin.utilities.invalidSessionSecretFailed'), + caption: apiErrorMessage(err) + }) + } + }) +} + +/** + * Delete every page version older than the selected timeframe, on every site. + * + * Confirmed, and named in the confirmation: pages keep what they say now, but a version thrown away + * here is gone for good — and the versions of a page somebody deleted are all that is left of it. + */ +function purgeHistory() { + const timeframe = purgeHistoryTimeframes.value.find( + (tf) => tf.value === state.purgeHistoryTimeframe + ) + confirm({ + title: t('admin.utilities.purgeHistory'), + message: t('admin.utilities.purgeHistoryConfirm', { timeframe: timeframe?.label ?? '' }), + caption: t('admin.utilities.purgeHistoryConfirmWarn'), + cancel: true, + persistent: true, + color: 'negative', + okLabel: t('common.actions.proceed') + }).onOk(async () => { + loading.show() + try { + const resp = await API_CLIENT.post('system/history/purge', { + json: { olderThan: state.purgeHistoryTimeframe } + }).json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + const count = resp.count ?? 0 + notify({ + type: 'positive', + message: t('admin.utilities.purgeHistorySuccess', count, { count }) + }) + } catch (err) { + notify({ + type: 'negative', + message: t('admin.utilities.purgeHistoryFailed'), + caption: apiErrorMessage(err) + }) + } + loading.hide() + }) +} + +/** + * Delete the rows of keys somebody revoked. + * + * Confirmed, but not coloured as a destruction: nothing loses access here, since a revoked key + * already had none. What goes is the record that it existed. + */ +function purgeRevokedKeys() { + confirm({ + title: t('admin.utilities.purgeRevokedKeys'), + message: t('admin.utilities.purgeRevokedKeysConfirm'), + caption: t('admin.utilities.purgeRevokedKeysConfirmWarn'), + cancel: true, + persistent: true, + color: 'negative', + okLabel: t('common.actions.proceed') + }).onOk(async () => { + loading.show() + try { + const resp = await API_CLIENT.post('system/api-keys/purge').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + const count = resp.count ?? 0 + notify({ + type: 'positive', + message: t('admin.utilities.purgeRevokedKeysSuccess', count, { count }) + }) + } catch (err) { + notify({ + type: 'negative', + message: t('admin.utilities.purgeRevokedKeysFailed'), + caption: apiErrorMessage(err) + }) + } + loading.hide() + }) +} + +/** + * Throw away everything the wiki has cached off the database — files, icons, and the site, group and + * locale state read on every request. Not confirmed: nothing is lost and nothing stops working, the + * next request simply pays for the refill. + */ +async function flushCache() { + loading.show() + try { + const resp = await API_CLIENT.post('system/cache/flush').json() + if (!resp?.ok) { + throw new Error(resp?.message || 'An unexpected error occured.') + } + notify({ + type: 'positive', + message: t('admin.utilities.flushCacheSuccess') + }) } catch (err) { notify({ type: 'negative', - message: 'Failed to disconnect WS connections.', - caption: err.message + message: t('admin.utilities.flushCacheFailed'), + caption: apiErrorMessage(err) }) } loading.hide()