feat: utilities implementation + remove deprecated jwks

scarlett
NGPixel 4 weeks ago
parent ce530a40d2
commit 09e9166bc6
No known key found for this signature in database

@ -85,7 +85,7 @@ initializers → mount. There is no UI framework: `src/components/shared/` is th
(every component is `W*`, used in templates as `<w-btn>`, `<w-input>`, …), registered globally by (every component is `W*`, used in templates as `<w-btn>`, `<w-input>`, …), registered globally by
`boot/components.js` and styled with Tailwind. `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, 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`), 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 `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. 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 - 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` 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). - 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 - 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 [Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older

@ -142,7 +142,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Revoke an API key', summary: 'Revoke an API key',
description: 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'], tags: ['API Keys'],
params: { params: {
type: 'object', type: 'object',

@ -36,6 +36,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
isRevoked: { isRevoked: {
type: 'boolean' 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: { createdAt: {
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',

@ -95,22 +95,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
maxLength: 16, maxLength: 16,
description: 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.' '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`.'
} }
} }
}) })

@ -10,6 +10,9 @@ import {
tags as tagsTable, tags as tagsTable,
users as usersTable users as usersTable
} from '../db/schema.ts' } 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' import type { FastifyInstance } from 'fastify'
/** /**
@ -295,7 +298,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Get the security configuration', summary: 'Get the security configuration',
description: 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'], tags: ['System'],
response: { response: {
200: { $ref: 'SecurityConfig#' } 200: { $ref: 'SecurityConfig#' }
@ -319,7 +322,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Update the security configuration', summary: 'Update the security configuration',
description: 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'], tags: ['System'],
body: { $ref: 'SecurityConfig#' }, body: { $ref: 'SecurityConfig#' },
response: { 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 terminals 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 instances 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 * CHECK FOR UPDATE
*/ */

@ -67,9 +67,6 @@ defaults:
enforce2FA: false enforce2FA: false
hideLocal: false hideLocal: false
loginBgUrl: '' loginBgUrl: ''
audience: 'urn:wiki.js'
tokenExpiration: '30m'
tokenRenewal: '14d'
secret: 'abcdef1234567890abcdef1234567890abcdef' secret: 'abcdef1234567890abcdef1234567890abcdef'
security: security:
corsMode: 'OFF' corsMode: 'OFF'

@ -13,6 +13,7 @@ import { relations } from '../db/relations.ts'
import { flags } from '../models/flags.ts' import { flags } from '../models/flags.ts'
import { createDeferred } from '../helpers/common.ts' import { createDeferred } from '../helpers/common.ts'
import { createNotifier } from '../helpers/pubsub.ts' import { createNotifier } from '../helpers/pubsub.ts'
import maintenance from './maintenance.ts'
// import migrationSource from '../db/migrator-source.js' // import migrationSource from '../db/migrator-source.js'
/** /**
@ -219,16 +220,15 @@ export default {
} }
} catch {} } catch {}
}) })
// FIXME: pre-existing bug — Emittery's `onAny` calls the listener as `(eventName, eventData)`, // -> Cast because `onAny` types the event as every pair the map allows plus Emittery's own meta
// but `notifyViaDB` destructures a single `{ name, data }` object (the eventemitter2 signature // events, and this listener is written to the one shape they have in common
// 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.
WIKI.events.outbound.onAny(this.notifyViaDB as any) WIKI.events.outbound.onAny(this.notifyViaDB as any)
// -> Listen to inbound events // -> Listen to inbound events
// WIKI.auth.subscribeToEvents() // WIKI.auth.subscribeToEvents()
WIKI.configSvc.subscribeToEvents() WIKI.configSvc.subscribeToEvents()
maintenance.subscribeToEvents()
// WIKI.db.pages.subscribeToEvents() // WIKI.db.pages.subscribeToEvents()
WIKI.logger.info('Event Listener initialized successfully: [ OK ]') WIKI.logger.info('Event Listener initialized successfully: [ OK ]')
@ -249,8 +249,11 @@ export default {
/** /**
* Publish event via database NOTIFY * Publish event via database NOTIFY
* *
* @param event Event fired * Takes one `{ name, data }` object, which is what Emittery hands an `onAny` listener not the
* @param value Payload of the event * `(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 { notifyViaDB({ name, data }: { name?: string; data?: unknown }): void {
notifier.send( notifier.send(

@ -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<void> {
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()
})
}
}

@ -35,6 +35,8 @@
"admin.api.headerLastUpdated": "Last Updated", "admin.api.headerLastUpdated": "Last Updated",
"admin.api.headerName": "Name", "admin.api.headerName": "Name",
"admin.api.headerRevoke": "Revoke", "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.key": "API Key",
"admin.api.keyEndingIn": "Ending in {suffix}", "admin.api.keyEndingIn": "Ending in {suffix}",
"admin.api.loadFailed": "Failed to load API keys.", "admin.api.loadFailed": "Failed to load API keys.",
@ -740,9 +742,6 @@
"admin.security.hsts": "HSTS (HTTP Strict Transport Security)", "admin.security.hsts": "HSTS (HTTP Strict Transport Security)",
"admin.security.hstsDuration": "HSTS Max Age", "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.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.loadFailed": "Failed to load the security configuration.",
"admin.security.loginScreen": "Login Screen", "admin.security.loginScreen": "Login Screen",
"admin.security.maxUploadBatch": "Max Files per Upload", "admin.security.maxUploadBatch": "Max Files per Upload",
@ -772,10 +771,6 @@
"admin.security.subtitle": "Configure security settings", "admin.security.subtitle": "Configure security settings",
"admin.security.title": "Security", "admin.security.title": "Security",
"admin.security.tokenEndpointAuthMethod": "Token Endpoint Authentication Method", "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.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.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", "admin.security.uploads": "Uploads",
@ -1236,23 +1231,46 @@
"admin.utilities.contentSubtitle": "Various tools for pages", "admin.utilities.contentSubtitle": "Various tools for pages",
"admin.utilities.contentTitle": "Content", "admin.utilities.contentTitle": "Content",
"admin.utilities.disconnectWS": "Disconnect WebSocket Sessions", "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.disconnectWSSuccess": "All active websocket connections have been terminated.",
"admin.utilities.export": "Export", "admin.utilities.export": "Export",
"admin.utilities.exportHint": "Export content to tarball for backup / migration.", "admin.utilities.exportHint": "Export content to tarball for backup / migration.",
"admin.utilities.flushCache": "Flush Cache", "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.graphEndpointSubtitle": "Change the GraphQL endpoint for Wiki.js",
"admin.utilities.graphEndpointTitle": "GraphQL Endpoint", "admin.utilities.graphEndpointTitle": "GraphQL Endpoint",
"admin.utilities.import": "Import", "admin.utilities.import": "Import",
"admin.utilities.importHint": "Import content from a tarball backup or a 2.X backup.", "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.importv1Subtitle": "Migrate data from a previous 1.x installation",
"admin.utilities.importv1Title": "Import from Wiki.js 1.x", "admin.utilities.importv1Title": "Import from Wiki.js 1.x",
"admin.utilities.invalidAuthCertificates": "Invalidate Authentication Certificates", "admin.utilities.invalidApiCertificates": "Invalidate API Keys Certificates",
"admin.utilities.invalidAuthCertificatesHint": "Regenerate the public and private keys used for authentication. This will instantly log everyone out.", "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.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.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.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.scanPageProblems": "Scan for Page Problems",
"admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.", "admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
"admin.utilities.subtitle": "Maintenance and miscellaneous tools", "admin.utilities.subtitle": "Maintenance and miscellaneous tools",

@ -1,9 +1,70 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { apiKeys as apiKeysTable, groups as groupsTable } from '../db/schema.ts' 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 { flatten, uniq } from 'es-toolkit/array'
import { epochSeconds, signJwt, verifyJwt } from '../helpers/jwt.ts' 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. */ /** The lifetimes the admin area offers, as durations the API accepts. */
export const KEY_EXPIRATIONS = { export const KEY_EXPIRATIONS = {
'30d': { days: 30 }, '30d': { days: 30 },
@ -27,6 +88,16 @@ export interface ApiKey {
updatedAt: Date 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. */ /** What a verified key grants, resolved from its groups at request time. */
export interface ApiKeyIdentity { export interface ApiKeyIdentity {
id: string id: string
@ -62,19 +133,66 @@ class ApiKeys {
private privateKey(): crypto.KeyObject { private privateKey(): crypto.KeyObject {
return crypto.createPrivateKey({ return crypto.createPrivateKey({
key: WIKI.config.auth.certs.private, 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<number | null> {
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. * 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<ApiKey[]> { async getKeys(): Promise<ApiKeyListEntry[]> {
const results = await WIKI.db const results = await WIKI.db
.select(keySelection) .select(keySelection)
.from(apiKeysTable) .from(apiKeysTable)
.orderBy(desc(apiKeysTable.createdAt)) .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( 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, id,
grp: groups, grp: groups,
aud: WIKI.config.auth.audience, aud: TOKEN_AUDIENCE,
iat: epochSeconds(), iat: epochSeconds(),
exp: epochSeconds(expiresAt) exp: epochSeconds(expiresAt)
}, },
@ -147,6 +262,27 @@ class ApiKeys {
return (result.rowCount ?? 0) > 0 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<number> {
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. * The union of the permissions held by the given groups.
* *
@ -177,13 +313,15 @@ class ApiKeys {
let claims let claims
try { try {
claims = verifyJwt(token, WIKI.config.auth.certs.public, { claims = verifyJwt(token, WIKI.config.auth.certs.public, {
audience: WIKI.config.auth.audience audience: TOKEN_AUDIENCE
}) })
} catch (err: any) { } catch (err: any) {
throw new ApiKeyError(err.message) 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.') throw new ApiKeyError('Token is not an API key.')
} }

@ -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<void> {
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. */ /** Where the disk cache lives. Derived data — deleting it costs a refill and nothing else. */
get cachePath(): string { get cachePath(): string {
return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/files') return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/files')

@ -1,5 +1,5 @@
import { isEqual } from 'es-toolkit/predicate' import { isEqual } from 'es-toolkit/predicate'
import { and, desc, eq } from 'drizzle-orm' import { and, desc, eq, lt, sql } from 'drizzle-orm'
import { import {
pageHistory as pageHistoryTable, pageHistory as pageHistoryTable,
pages as pagesTable, pages as pagesTable,
@ -17,6 +17,26 @@ export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted'] as
export type PageHistoryAction = (typeof pageHistoryActions)[number] 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. * 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<number> {
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. * Which of a page's fields a patch actually changes.
* *

@ -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. */ /** Attributes the editor adds for its own preview and that mean nothing in a stored page. */
const EDITOR_ARTIFACT_ATTRIBUTES = ['data-line'] 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. * Turn a heading into an anchor fragment.
* *
@ -639,8 +651,27 @@ class Rendering {
shortcodes become are styled there for the same reason. shortcodes become are styled there for the same reason.
*/ */
svg.attr('class', ['icon', authorClass].filter(Boolean).join(' ')) 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) .filter(Boolean)
.join(';') .join(';')
if (styles) { if (styles) {

@ -23,27 +23,15 @@ export const SECURITY_FIELDS = [
'uploadScanSVG' 'uploadScanSVG'
] as const ] 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`. */ /** A duration as the admin area writes it: `30m`, `14d`, `1y`. */
const DURATION_PATTERN = /^\d+[smhdwy]$/ const DURATION_PATTERN = /^\d+[smhdwy]$/
/** /**
* Security model * Security model
* *
* One flat surface for the admin area's security view, even though the values are stored in two * The admin area's security view, which is exactly the `security` settings blob. Most of it is read
* settings blobs. Most of them are read when the HTTP server starts see the `Security` section of * when the HTTP server starts see the `Security` section of `index.ts` so saving here takes
* `index.ts` so saving them here takes effect on the next restart. * effect on the next restart.
*/ */
class Security { class Security {
/** /**
@ -55,9 +43,6 @@ class Security {
for (const field of SECURITY_FIELDS) { for (const field of SECURITY_FIELDS) {
config[field] = security[field] config[field] = security[field]
} }
for (const [field, authKey] of Object.entries(AUTH_FIELD_MAP)) {
config[field] = WIKI.config.auth?.[authKey]
}
return config return config
} }
@ -66,7 +51,7 @@ class Security {
*/ */
pickFields(body: Record<string, any>): Record<string, any> { pickFields(body: Record<string, any>): Record<string, any> {
const patch: Record<string, any> = {} const patch: Record<string, any> = {}
for (const field of [...SECURITY_FIELDS, ...Object.keys(AUTH_FIELD_MAP)]) { for (const field of SECURITY_FIELDS) {
if (body[field] !== undefined) { if (body[field] !== undefined) {
patch[field] = body[field] 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 return null
} }
/** /**
* Save a validated patch, splitting it across the two settings blobs it belongs to. * Save a validated patch.
*
* Both are written in one go and rolled back together, so a failure cannot leave the JWT settings
* updated while the rest is not.
* *
* @returns Whether the settings were saved * @returns Whether the settings were saved
*/ */
async updateConfig(patch: Record<string, any>): Promise<boolean> { async updateConfig(patch: Record<string, any>): Promise<boolean> {
const previousSecurity = WIKI.config.security const previousSecurity = WIKI.config.security
const previousAuth = WIKI.config.auth WIKI.config.security = { ...previousSecurity, ...patch }
const keys: string[] = []
const securityPatch: Record<string, any> = {}
const authPatch: Record<string, any> = {}
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')
}
if (!(await WIKI.configSvc.saveToDb(keys))) { if (!(await WIKI.configSvc.saveToDb(['security']))) {
WIKI.config.security = previousSecurity WIKI.config.security = previousSecurity
WIKI.config.auth = previousAuth
return false return false
} }
return true return true

@ -1,3 +1,4 @@
import crypto from 'node:crypto'
import { eq, sql } from 'drizzle-orm' import { eq, sql } from 'drizzle-orm'
import { sessions as sessionsTable } from '../db/schema.ts' import { sessions as sessionsTable } from '../db/schema.ts'
@ -77,6 +78,37 @@ class Sessions {
async clearSessionsFromUser(userId: string) { async clearSessionsFromUser(userId: string) {
return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, userId)) 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<number | null> {
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() export const sessions = new Sessions()

@ -1,5 +1,5 @@
import { settings as settingsTable } from '../db/schema.ts' import { settings as settingsTable } from '../db/schema.ts'
import { pem2jwk } from 'pem-jwk' import { generateSigningCertificates } from './apiKeys.ts'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import type { SystemIds } from './types.ts' import type { SystemIds } from './types.ts'
@ -41,20 +41,7 @@ class Settings {
*/ */
async init(ids: SystemIds): Promise<void> { async init(ids: SystemIds): Promise<void> {
WIKI.logger.info('Generating certificates...') WIKI.logger.info('Generating certificates...')
const secret = crypto.randomBytes(32).toString('hex') const certs = generateSigningCertificates()
const certs = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'pkcs1',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: secret
}
})
WIKI.logger.info('Inserting default settings...') WIKI.logger.info('Inserting default settings...')
await WIKI.db.insert(settingsTable).values([ await WIKI.db.insert(settingsTable).values([
@ -67,15 +54,14 @@ class Settings {
{ {
key: 'auth', key: 'auth',
value: { value: {
audience: 'urn:wiki.js', // -> The installation keypair, carrying its own passphrase. Its one job is signing API
tokenExpiration: '30m', // keys (`models/apiKeys.ts`).
tokenRenewal: '14d', certs,
certs: { // -> What @fastify/session signs its cookies with, and nothing else. Separate from the
jwk: pem2jwk(certs.publicKey), // keypair's passphrase so that either can be rotated without disturbing the other —
public: certs.publicKey, // the two utilities that do so are `POST /system/certificates` and
private: certs.privateKey // `POST /system/sessions/invalidate`.
}, secret: crypto.randomBytes(32).toString('hex'),
secret,
rootAdminGroupId: ids.groupAdminId, rootAdminGroupId: ids.groupAdminId,
rootAdminUserId: ids.userAdminId, rootAdminUserId: ids.userAdminId,
guestUserId: ids.userGuestId guestUserId: ids.userGuestId
@ -152,10 +138,6 @@ class Settings {
forceAssetDownload: true, forceAssetDownload: true,
hstsDuration: 0, hstsDuration: 0,
trustProxy: false, 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, uploadMaxFileSize: 10485760,
uploadMaxFiles: 20, uploadMaxFiles: 20,
uploadScanSVG: true uploadScanSVG: true

@ -42,7 +42,6 @@
"nanoid": "6.0.1", "nanoid": "6.0.1",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"openid-client": "6.8.4", "openid-client": "6.8.4",
"pem-jwk": "2.0.0",
"pg": "8.23.0", "pg": "8.23.0",
"poolifier": "5.3.2", "poolifier": "5.3.2",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@ -56,7 +55,6 @@
"@types/fs-extra": "11.0.4", "@types/fs-extra": "11.0.4",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/node": "26.2.0", "@types/node": "26.2.0",
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.21.0", "@types/pg": "8.21.0",
"@types/qrcode": "1.5.6", "@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1", "@types/sanitize-html": "2.16.1",
@ -2702,13 +2700,6 @@
"undici-types": "~8.3.0" "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": { "node_modules/@types/pg": {
"version": "8.21.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz",
@ -3206,18 +3197,6 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0" "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": { "node_modules/asn1js": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@ -3302,12 +3281,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@ -4820,12 +4793,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/minimatch": {
"version": "10.2.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@ -5244,21 +5211,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/pg": {
"version": "8.23.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",

@ -68,7 +68,6 @@
"nanoid": "6.0.1", "nanoid": "6.0.1",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"openid-client": "6.8.4", "openid-client": "6.8.4",
"pem-jwk": "2.0.0",
"pg": "8.23.0", "pg": "8.23.0",
"poolifier": "5.3.2", "poolifier": "5.3.2",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@ -85,7 +84,6 @@
"@types/fs-extra": "11.0.4", "@types/fs-extra": "11.0.4",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/node": "26.2.0", "@types/node": "26.2.0",
"@types/pem-jwk": "2.0.2",
"@types/pg": "8.21.0", "@types/pg": "8.21.0",
"@types/qrcode": "1.5.6", "@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1", "@types/sanitize-html": "2.16.1",

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="ASEE8~aQUwwpBtEoXVVH3a" x1="27.973" x2="40.027" y1="4.719" y2="23.281" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#50e6ff"/><stop offset=".338" stop-color="#4adefc"/><stop offset=".864" stop-color="#3ac8f4"/><stop offset="1" stop-color="#35c1f1"/></linearGradient><rect width="16" height="16" x="26" y="6" fill="url(#ASEE8~aQUwwpBtEoXVVH3a)"/><linearGradient id="ASEE8~aQUwwpBtEoXVVH3b" x1="7.973" x2="20.027" y1="24.719" y2="43.281" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#50e6ff"/><stop offset=".338" stop-color="#4adefc"/><stop offset=".864" stop-color="#3ac8f4"/><stop offset="1" stop-color="#35c1f1"/></linearGradient><rect width="16" height="16" x="6" y="26" fill="url(#ASEE8~aQUwwpBtEoXVVH3b)"/><linearGradient id="ASEE8~aQUwwpBtEoXVVH3c" x1="9.972" x2="20.028" y1="7.186" y2="22.814" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcfcfc"/><stop offset=".495" stop-color="#f4f4f4"/><stop offset=".946" stop-color="#e8e8e8"/><stop offset="1" stop-color="#e8e8e8"/></linearGradient><path fill="url(#ASEE8~aQUwwpBtEoXVVH3c)" d="M9,22c-0.552,0-1-0.449-1-1V9c0-0.551,0.448-1,1-1h12c0.552,0,1,0.449,1,1v12 c0,0.551-0.448,1-1,1H9z"/><path fill="#cdcdcd" d="M21,9v12H9V9H21 M21,7H9C7.895,7,7,7.895,7,9v12c0,1.105,0.895,2,2,2h12c1.105,0,2-0.895,2-2V9 C23,7.895,22.105,7,21,7L21,7z"/><linearGradient id="ASEE8~aQUwwpBtEoXVVH3d" x1="27.972" x2="38.028" y1="25.186" y2="40.814" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcfcfc"/><stop offset=".495" stop-color="#f4f4f4"/><stop offset=".946" stop-color="#e8e8e8"/><stop offset="1" stop-color="#e8e8e8"/></linearGradient><path fill="url(#ASEE8~aQUwwpBtEoXVVH3d)" d="M27,40c-0.552,0-1-0.449-1-1V27c0-0.551,0.448-1,1-1h12c0.552,0,1,0.449,1,1v12 c0,0.551-0.448,1-1,1H27z"/><path fill="#cdcdcd" d="M39,27v12H27V27H39 M39,25H27c-1.105,0-2,0.895-2,2v12c0,1.105,0.895,2,2,2h12c1.105,0,2-0.895,2-2 V27C41,25.895,40.105,25,39,25L39,25z"/><path fill="#199ae0" d="M21,27v14H7V27H21 M21,25H7c-1.105,0-2,0.895-2,2v14c0,1.105,0.895,2,2,2h14c1.105,0,2-0.895,2-2V27 C23,25.895,22.105,25,21,25L21,25z"/><path fill="#199ae0" d="M41,7v14H27V7H41 M41,5H27c-1.105,0-2,0.895-2,2v14c0,1.105,0.895,2,2,2h14c1.105,0,2-0.895,2-2V7 C43,5.895,42.105,5,41,5L41,5z"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -215,11 +215,6 @@ async function loadBootstrap() {
router.beforeEach(async (to, from) => { router.beforeEach(async (to, from) => {
commonStore.routerLoading = true commonStore.routerLoading = true
// -> Init Auth Token
// if (userStore.token && !userStore.authenticated) {
// userStore.loadToken()
// }
/* /*
-> Site info, system flags and the session -> 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, One request for the three of them: none touches the database, so what they cost is the round trip,

@ -1,51 +1,20 @@
import ky from 'ky' import ky from 'ky'
import { useUserStore } from '@/stores/user' /**
* The HTTP client every call to the API goes through, exposed as the `API_CLIENT` global.
export function initializeApi(store) { *
const userStore = useUserStore(store) * 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
let refreshPromise = null * that refreshed a JWT and set an `Authorization` header a leftover from when 3.x authenticated
let fetching = false * 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({ const client = ky.create({
prefix: '/_api', prefix: '/_api',
credentials: 'same-origin', credentials: 'same-origin',
throwHttpErrors: (statusNumber) => statusNumber > 400, // Don't throw for 400 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}` : '')
}
]
}
}) })
if (import.meta.env.SSR) { if (import.meta.env.SSR) {

@ -1390,16 +1390,35 @@
/* /*
An Iconify icon, drawn into the page as an `<svg>` when it was saved -- see `inlineIcons` in An Iconify icon, drawn into the page as an `<svg>` when it was saved -- see `inlineIcons` in
`models/rendering.ts`. Sized and coloured by the markup itself (`1em`, `currentColor`), so all it `models/rendering.ts`. Coloured by the markup itself (`currentColor`), so what it needs from here
needs from here is to be part of the line: Preflight makes every `svg` a block, which would put is to be part of the line -- Preflight makes every `svg` a block, which would put an icon written
an icon written mid-sentence on a line of its own. mid-sentence on a line of its own -- and to be big enough to read beside the text it sits in.
An `<iconify-icon>` that has not been through that yet -- a page saved before this, or an icon 1.4em rather than the 1em Iconify draws at: an icon is read as a glyph, and a glyph the same
whose set could not be resolved at the time -- carries the same rule from its own `:host`, so the nominal size as the type around it looks smaller than it, because a letter fills a fraction of
two sit identically and the editor's preview matches the page. 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 { svg.icon {
display: inline-block; 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 `<svg width="1em">` 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:` */ /* Twemoji, which the renderer swaps in for `:shortcodes:` */

@ -26,7 +26,7 @@ app.use(store)
app.use(router) app.use(router)
initializeHairlines() initializeHairlines()
initializeApi(store) initializeApi()
initializeComponents(app) initializeComponents(app)
initializeEventBus() initializeEventBus()
initializeIconify() initializeIconify()

@ -113,22 +113,23 @@
}}</span> }}</span>
</w-item-label> </w-item-label>
</w-item-section> </w-item-section>
<!-- Revoked wins over expired: it is the state an operator acted on --> <!--
<w-item-section One state, in the order they explain the key best: revoked is what an operator did
v-if="key.isRevoked || isExpired(key)" to this key, invalidated is what happened to every key at once, expired is the key
side simply running its course.
style="flex-direction: row; align-items: center"> -->
<w-icon <w-item-section v-if="keyState(key)" side>
class="mr-2" <div class="flex items-center">
color="negative" <w-icon class="mr-2" color="negative" size="xs" name="la:exclamation-triangle" />
size="xs" <div class="text-caption text-negative">
name="la:exclamation-triangle" /> {{ t(`admin.api.${keyState(key)}`) }}
<div class="text-caption text-negative"> </div>
{{ key.isRevoked ? t('admin.api.revoked') : t('admin.api.expired') }} </div>
<!-- -> In the row rather than in a tooltip: it is the explanation of the state right
above it, and a tooltip here opened over the admin sidebar -->
<div class="text-caption text-grey mt-1 text-right" style="max-width: 340px">
{{ stateHint(key) }}
</div> </div>
<w-tooltip anchor="center left" self="center right">{{
key.isRevoked ? t('admin.api.revokedHint') : t('admin.api.expiredHint')
}}</w-tooltip>
</w-item-section> </w-item-section>
<w-separator class="ml-4" vertical /> <w-separator class="ml-4" vertical />
<w-item-section side style="flex-direction: row; align-items: center"> <w-item-section side style="flex-direction: row; align-items: center">
@ -196,7 +197,9 @@ const state = reactive({
loading: 0, loading: 0,
isToggleLoading: false, isToggleLoading: false,
keys: [], keys: [],
groups: [] groups: [],
/** When the signing keypair was generated — what an invalidated key is invalidated by. */
certificatesGeneratedAt: null
}) })
// METHODS // 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) { 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. */ /** 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++ state.loading++
loading.show() loading.show()
try { try {
// -> Groups are fetched alongside the keys so the list can name the permissions each key carries // -> Groups are fetched alongside the keys so the list can name the permissions each key carries,
const [keys, apiState, groups] = await Promise.all([ // 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('api-keys').json(),
API_CLIENT.get('system/api').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.keys = keys ?? []
state.groups = groups ?? [] state.groups = groups ?? []
state.enabled = apiState?.isEnabled === true state.enabled = apiState?.isEnabled === true
state.certificatesGeneratedAt = certs?.generatedAt ?? null
// -> Keeps the status light in the admin sidebar in step without another round trip // -> Keeps the status light in the admin sidebar in step without another round trip
adminStore.info.isApiEnabled = state.enabled adminStore.info.isApiEnabled = state.enabled
} catch (err) { } catch (err) {

@ -411,7 +411,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog' import { confirm } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
@ -706,20 +706,13 @@ function confirmDelete() {
state.strategy = state.activeStrategies[0] ?? { strategy: {} } state.strategy = state.activeStrategies[0] ?? { strategy: {} }
return return
} }
dialog({ confirm({
title: t('admin.auth.deleteStrategy'), title: t('admin.auth.deleteStrategy'),
message: t('admin.auth.deleteConfirm', { strategy: strategy.displayName }), message: t('admin.auth.deleteConfirm', { strategy: strategy.displayName }),
persistent: true, persistent: true,
ok: { cancel: true,
label: t('common.actions.delete'), color: 'negative',
color: 'negative', okLabel: t('common.actions.delete')
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(async () => { }).onOk(async () => {
state.loading++ state.loading++
try { try {

@ -110,7 +110,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog' import { confirm } from '@/composables/dialog'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
@ -213,7 +213,7 @@ function addBlock() {
function deleteBlock(id) { function deleteBlock(id) {
const block = state.blocks.find((bl) => bl.id === id) const block = state.blocks.find((bl) => bl.id === id)
dialog({ confirm({
title: t('admin.blocks.delete'), title: t('admin.blocks.delete'),
message: t('admin.blocks.deleteConfirm', { blockName: block?.name ?? '' }), message: t('admin.blocks.deleteConfirm', { blockName: block?.name ?? '' }),
cancel: true, cancel: true,

@ -281,7 +281,7 @@ import { computed, onMounted, reactive } from 'vue'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { dialog } from '@/composables/dialog' import { confirm } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
@ -487,20 +487,13 @@ async function setSetState(set, isEnabled) {
} }
function confirmDeleteSet(set) { function confirmDeleteSet(set) {
dialog({ confirm({
title: t('admin.icons.deleteSet'), title: t('admin.icons.deleteSet'),
message: t('admin.icons.deleteSetConfirm', { set: set.name, count: set.iconCount }), message: t('admin.icons.deleteSetConfirm', { set: set.name, count: set.iconCount }),
persistent: true, persistent: true,
ok: { cancel: true,
label: t('common.actions.delete'), color: 'negative',
color: 'negative', okLabel: t('common.actions.delete')
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(async () => { }).onOk(async () => {
state.loading++ state.loading++
try { try {
@ -530,20 +523,13 @@ function confirmDeleteSet(set) {
} }
function purgeCache() { function purgeCache() {
dialog({ confirm({
title: t('admin.icons.purgeCache'), title: t('admin.icons.purgeCache'),
message: t('admin.icons.purgeCacheConfirm'), message: t('admin.icons.purgeCacheConfirm'),
persistent: true, persistent: true,
ok: { cancel: true,
label: t('admin.icons.purgeCache'), color: 'negative',
color: 'negative', okLabel: t('admin.icons.purgeCache')
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(async () => { }).onOk(async () => {
state.loading++ state.loading++
try { try {

@ -406,56 +406,6 @@
</w-item> </w-item>
</template> </template>
</w-card> </w-card>
<!-- ----------------------- -->
<!-- JWT -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.security.jwt') }}</w-card-header>
<w-item>
<blueprint-icon icon="ticket" />
<w-item-section>
<w-item-label>{{ t(`admin.security.jwtAudience`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.jwtAudienceHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 250px">
<w-input
outlined
v-model="state.config.authJwtAudience"
dense
:aria-label="t(`admin.security.jwtAudience`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="expired" />
<w-item-section>
<w-item-label>{{ t(`admin.security.tokenExpiration`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.tokenExpirationHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 140px">
<w-input
outlined
v-model="state.config.authJwtExpiration"
dense
:aria-label="t(`admin.security.tokenExpiration`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="future" />
<w-item-section>
<w-item-label>{{ t(`admin.security.tokenRenewalPeriod`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.security.tokenRenewalPeriodHint`) }}</w-item-label>
</w-item-section>
<w-item-section style="flex: 0 0 140px">
<w-input
outlined
v-model="state.config.authJwtRenewablePeriod"
dense
:aria-label="t(`admin.security.tokenRenewalPeriod`)" />
</w-item-section>
</w-item>
</w-card>
</div> </div>
</div> </div>
</w-page> </w-page>
@ -510,9 +460,6 @@ const state = reactive({
authRateLimitMax: 10, authRateLimitMax: 10,
authRateLimitWindow: '5m', authRateLimitWindow: '5m',
authRateLimitBan: '15m', authRateLimitBan: '15m',
authJwtAudience: 'urn:wiki.js',
authJwtExpiration: '30m',
authJwtRenewablePeriod: '14d',
uploadMaxFileSize: 0, uploadMaxFileSize: 0,
uploadMaxFiles: 0, uploadMaxFiles: 0,
uploadScanSVG: false uploadScanSVG: false

@ -750,7 +750,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog' import { confirm, dialog } from '@/composables/dialog'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' 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 // -> An action that declares a warning destroys something, so it is never run on a single click
if (act.warn) { if (act.warn) {
dialog({ confirm({
title: act.label, title: act.label,
message: act.warn, message: act.warn,
persistent: true, persistent: true,
ok: { cancel: true,
label: t('common.actions.proceed'), color: 'negative',
color: 'negative', okLabel: t('common.actions.proceed')
unelevated: true
},
cancel: {
label: t('common.actions.cancel'),
color: 'grey',
flat: true
}
}).onOk(run) }).onOk(run)
} else { } else {
await run() await run()
@ -1154,7 +1147,7 @@ async function handleSetupCallback() {
} }
async function setupDestroy() { async function setupDestroy() {
dialog({ confirm({
title: t('admin.storage.destroyConfirm'), title: t('admin.storage.destroyConfirm'),
message: t('admin.storage.destroyConfirmInfo'), message: t('admin.storage.destroyConfirmInfo'),
cancel: true, cancel: true,

@ -57,6 +57,7 @@
flat flat
icon="la:arrow-circle-right" icon="la:arrow-circle-right"
color="primary" color="primary"
disabled
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -72,6 +73,7 @@
flat flat
icon="la:arrow-circle-right" icon="la:arrow-circle-right"
color="primary" color="primary"
@click="flushCache"
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -87,15 +89,16 @@
flat flat
icon="la:arrow-circle-right" icon="la:arrow-circle-right"
color="primary" color="primary"
disabled
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-item> <w-item>
<blueprint-icon icon="matches" :hue-rotate="45" /> <blueprint-icon icon="matches" :hue-rotate="45" />
<w-item-section> <w-item-section>
<w-item-label>{{ t(`admin.utilities.invalidAuthCertificates`) }}</w-item-label> <w-item-label>{{ t(`admin.utilities.invalidApiCertificates`) }}</w-item-label>
<w-item-label caption>{{ <w-item-label caption>{{
t(`admin.utilities.invalidAuthCertificatesHint`) t(`admin.utilities.invalidApiCertificatesHint`)
}}</w-item-label> }}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section side> <w-item-section side>
@ -104,6 +107,25 @@
flat flat
icon="la:arrow-circle-right" icon="la:arrow-circle-right"
color="primary" color="primary"
@click="invalidateApiCertificates"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="key" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.invalidSessionSecret`) }}</w-item-label>
<w-item-label caption>{{
t(`admin.utilities.invalidSessionSecretHint`)
}}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
@click="invalidateSessionSecret"
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -131,6 +153,23 @@
flat flat
icon="la:arrow-circle-right" icon="la:arrow-circle-right"
color="primary" color="primary"
@click="purgeHistory"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="trash" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.purgeRevokedKeys`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.utilities.purgeRevokedKeysHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
@click="purgeRevokedKeys"
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -146,6 +185,7 @@
flat flat
icon="la:arrow-circle-right" icon="la:arrow-circle-right"
color="primary" color="primary"
disabled
:label="t(`common.actions.proceed`)" /> :label="t(`common.actions.proceed`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
@ -162,6 +202,8 @@ import { useI18n } from 'vue-i18n'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { confirm } from '@/composables/dialog'
import { apiErrorMessage } from '@/helpers/apiError'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -198,35 +240,223 @@ const purgeHistoryTimeframes = computed(() => [
// METHODS // METHODS
async function disconnectWS() { /**
loading.show() * Close every websocket the wiki holds the editors of anyone collaborating on a page, and any open
try { * admin terminal. Confirmed first because it interrupts people who are working: their clients
const resp = await APOLLO_CLIENT.mutate({ * reconnect on their own, but an editor is briefly cut off from the others in its room.
mutation: ` *
mutation disconnectWS { * Both this and {@link flushCache} reach every instance: the one answering the request acts on itself
disconnectWS { * and publishes the same instruction to the others. `count` in the response is therefore only what
operation { * this one closed, which is why it is not reported.
succeeded */
message function disconnectWS() {
} confirm({
} title: t('admin.utilities.disconnectWS'),
} message: t('admin.utilities.disconnectWSConfirm'),
`, cancel: true,
fetchPolicy: 'network-only' color: 'negative',
}) okLabel: t('common.actions.proceed')
if (resp?.data?.disconnectWS?.operation?.succeeded) { }).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({ notify({
type: 'positive', type: 'positive',
message: t('admin.utilities.disconnectWSSuccess') message: t('admin.utilities.disconnectWSSuccess')
}) })
} else { } catch (err) {
throw new Error(resp?.data?.disconnectWS?.operation?.succeeded) 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) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
message: 'Failed to disconnect WS connections.', message: t('admin.utilities.flushCacheFailed'),
caption: err.message caption: apiErrorMessage(err)
}) })
} }
loading.hide() loading.hide()

Loading…
Cancel
Save