You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/system.ts

938 lines
28 KiB

import path from 'node:path'
import os from 'node:os'
import { filesize } from 'filesize'
import { isNil } from 'es-toolkit/predicate'
import { gte, sql } from 'drizzle-orm'
import {
groups as groupsTable,
hooks as hooksTable,
pages as pagesTable,
tags as tagsTable,
users as usersTable
} from '../db/schema.ts'
import type { FastifyInstance } from 'fastify'
/**
* Every instance connected to this database, with how it is using the connection pool.
*
* There is no instance registry: an instance is only known by the connections it holds, which it
* labels `Wiki.js - <instance id>:<purpose>`. Two of those purposes hold a listener rather than
* doing query work, so they are counted apart.
*
* Shared by the list route and the dashboard count, so that the number on the dashboard is the
* number of rows the instances page shows.
*/
async function getInstances(): Promise<Record<string, any>[]> {
const instRaw = await WIKI.db.execute(
sql`SELECT usename, client_addr, application_name, backend_start, state_change FROM pg_stat_activity WHERE datname = ${WIKI.dbManager.dbName} AND application_name LIKE 'Wiki.js%'`
)
const insts: Record<string, any> = {}
for (const inst of instRaw.rows as any[]) {
const instId = inst.application_name.substring(10, 20)
const conType = [':MAIN', ':WORKER'].some((ct) => inst.application_name.endsWith(ct))
? 'main'
: 'sub'
// -> `db.execute()` with a raw SQL template returns timestamps as postgres-format strings
// (e.g. `2026-07-25 13:17:36.230177+00`) rather than Dates, which is what the previous
// `DateTime.fromSQL()` call was for. Temporal.Instant.from parses that format as-is,
// including the space separator and the hour-only `+00` offset. Rendered with
// millisecond precision to match the timestamps produced elsewhere.
inst.backend_start = Temporal.Instant.from(inst.backend_start).toString({
smallestUnit: 'millisecond'
})
inst.state_change = Temporal.Instant.from(inst.state_change).toString({
smallestUnit: 'millisecond'
})
const curInst = insts[instId] ?? {
activeConnections: 0,
activeListeners: 0,
dbFirstSeen: inst.backend_start,
dbLastSeen: inst.state_change
}
insts[instId] = {
id: instId,
activeConnections:
conType === 'main' ? curInst.activeConnections + 1 : curInst.activeConnections,
activeListeners: conType === 'sub' ? curInst.activeListeners + 1 : curInst.activeListeners,
dbUser: inst.usename,
dbFirstSeen:
curInst.dbFirstSeen > inst.backend_start ? inst.backend_start : curInst.dbFirstSeen,
dbLastSeen: curInst.dbLastSeen < inst.state_change ? inst.state_change : curInst.dbLastSeen,
ip: inst.client_addr
}
}
return Object.values(insts)
}
/**
* System API Routes
*/
async function routes(app: FastifyInstance) {
/**
* SYSTEM INFO
*/
app.get(
'/info',
{
config: {
permissions: ['access:admin']
},
schema: {
summary: 'System Info',
tags: ['System'],
response: {
200: {
description: 'System Info',
type: 'object',
properties: {
activeWorkers: {
type: 'number',
description:
'Jobs running right now on every instance combined, one worker slot each.'
},
configFile: {
type: 'string'
},
cpuCores: {
type: 'number'
},
currentVersion: {
type: 'string'
},
dbHost: {
type: 'string'
},
groupsTotal: {
type: 'number'
},
hostname: {
type: 'string'
},
httpPort: {
type: 'number'
},
instancesTotal: {
type: 'number',
description: 'Instances currently connected to this database.'
},
isMailConfigured: {
type: 'boolean'
},
isApiEnabled: {
type: 'boolean',
description: 'Whether API keys are accepted.'
},
isMetricsEnabled: {
type: 'boolean',
description: 'Whether the Prometheus metrics endpoint is turned on.'
},
isSchedulerHealthy: {
type: 'boolean',
description:
'False when no instance has refreshed the scheduler cron lock recently, i.e. scheduled jobs are no longer being queued.'
},
latestVersion: {
type: 'string'
},
latestVersionReleaseDate: {
type: 'string',
format: 'date-time'
},
loginsPastDay: {
type: 'number'
},
nodeVersion: {
type: 'string'
},
operatingSystem: {
type: 'string'
},
pagesTotal: {
type: 'number'
},
platform: {
type: 'string'
},
ramTotal: {
type: 'string'
},
tagsTotal: {
type: 'string'
},
upgradeCapable: {
type: 'boolean'
},
usersTotal: {
type: 'number'
},
webhooksTotal: {
type: 'number'
},
workingDirectory: {
type: 'string'
}
}
}
}
}
},
async () => {
return {
activeWorkers: await WIKI.models.jobs.countActive(),
configFile: path.join(process.cwd(), 'config.yml'),
cpuCores: os.cpus().length,
currentVersion: WIKI.version,
dbHost: WIKI.config.db.host,
dbVersion: WIKI.dbManager.VERSION,
groupsTotal: await WIKI.db.$count(groupsTable),
hostname: os.hostname(),
httpPort: 0,
instancesTotal: (await getInstances()).length,
isApiEnabled: WIKI.config.api.isEnabled === true,
isMailConfigured: WIKI.config?.mail?.host?.length > 2,
isMetricsEnabled: WIKI.config.metrics.isEnabled === true,
isSchedulerHealthy: await WIKI.models.jobs.isHealthy(),
latestVersion: WIKI.config.update.version,
latestVersionReleaseDate: WIKI.config.update.versionDate,
loginsPastDay: await WIKI.db.$count(
usersTable,
gte(usersTable.lastLoginAt, sql`NOW() - INTERVAL '1 DAY'`)
),
nodeVersion: process.version.substring(1),
operatingSystem: `${os.type()} (${os.platform()}) ${os.release()} ${os.arch()}`,
pagesTotal: await WIKI.db.$count(pagesTable),
platform: os.platform(),
ramTotal: filesize(os.totalmem()),
tagsTotal: await WIKI.db.$count(tagsTable),
upgradeCapable: !isNil(process.env.UPGRADE_COMPANION),
usersTotal: await WIKI.db.$count(usersTable),
webhooksTotal: await WIKI.db.$count(hooksTable),
workingDirectory: process.cwd()
}
}
)
/**
* SYSTEM FLAGS
*/
app.get(
'/flags',
{
config: {
publicAccess: true
},
schema: {
summary: 'System Flags',
description:
'Readable without authentication: the frontend needs `experimental` before anyone has logged in, to know which unfinished features to reveal. A flag must therefore never carry anything sensitive.',
tags: ['System'],
response: {
200: { $ref: 'SystemFlags#' }
}
}
},
async () => {
return WIKI.models.flags.getFlags()
}
)
/**
* UPDATE SYSTEM FLAGS
*/
app.put<{ Body: Record<string, any> }>(
'/flags',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the system flags',
description:
'Accepts any subset of the flags. All of them take effect immediately, without a restart: `authDebug` and `sqlLog` write to the server log at info level, and `experimental` is picked up by the frontend on its next load.',
tags: ['System'],
body: { $ref: 'SystemFlags#' },
response: {
200: {
description: 'System flags updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch = WIKI.models.flags.pickFlags(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No system flags provided to update.')
}
if (!(await WIKI.models.flags.updateFlags(patch))) {
return reply.internalServerError('Failed to save the system flags.')
}
return {
ok: true,
message: 'System flags updated successfully.'
}
}
)
/**
* GET SECURITY CONFIGURATION
*/
app.get(
'/security',
{
config: {
permissions: ['manage:system']
},
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.',
tags: ['System'],
response: {
200: { $ref: 'SecurityConfig#' }
}
}
},
async () => {
return WIKI.models.security.getConfig()
}
)
/**
* UPDATE SECURITY CONFIGURATION
*/
app.put<{ Body: Record<string, any> }>(
'/security',
{
config: {
permissions: ['manage:system']
},
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.',
tags: ['System'],
body: { $ref: 'SecurityConfig#' },
response: {
200: {
description: 'Security configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const patch = WIKI.models.security.pickFields(req.body)
if (Object.keys(patch).length < 1) {
return reply.badRequest('No security settings provided to update.')
}
const invalid = WIKI.models.security.validate(patch)
if (invalid) {
return reply.badRequest(invalid)
}
if (!(await WIKI.models.security.updateConfig(patch))) {
return reply.internalServerError('Failed to save the security configuration.')
}
return {
ok: true,
message: 'Security configuration updated successfully.'
}
}
)
/**
* GET SEARCH CONFIGURATION
*/
app.get(
'/search',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the search configuration',
description:
'Search is postgres full-text. `availableDictionaries` lists the text search configurations this database has, which is what a locale may be mapped to.',
tags: ['System'],
response: {
200: {
description: 'Search configuration',
type: 'object',
properties: {
termHighlighting: {
type: 'boolean'
},
dictOverrides: {
type: 'object',
description:
'Locale code to postgres dictionary, e.g. `{ "en": "english" }`. Overrides the built-in mapping.',
additionalProperties: { type: 'string' }
},
availableDictionaries: {
type: 'array',
description: 'Dictionary names this postgres installation knows.',
items: { type: 'string' }
}
}
}
}
}
},
async () => {
return {
...WIKI.models.search.getConfig(),
availableDictionaries: await WIKI.models.search.getAvailableDictionaries()
}
}
)
/**
* UPDATE SEARCH CONFIGURATION
*/
app.put<{ Body: { termHighlighting?: boolean; dictOverrides?: Record<string, string> } }>(
'/search',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Update the search configuration',
description:
'Every dictionary named in `dictOverrides` must exist in this database, otherwise indexing would fail later, long after the setting was accepted. Changing a mapping affects pages the next time they are indexed — rebuild the index to apply it to existing content.',
tags: ['System'],
body: {
type: 'object',
properties: {
termHighlighting: {
type: 'boolean'
},
dictOverrides: {
type: 'object',
description: 'Locale code to postgres dictionary. Replaces the stored mapping.',
additionalProperties: { type: 'string' }
}
}
},
response: {
200: {
description: 'Search configuration updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
if (req.body.termHighlighting === undefined && req.body.dictOverrides === undefined) {
return reply.badRequest('No search settings provided to update.')
}
if (req.body.dictOverrides) {
const available = await WIKI.models.search.getAvailableDictionaries()
for (const [locale, dictionary] of Object.entries(req.body.dictOverrides)) {
if (!/^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/.test(locale)) {
return reply.badRequest(`"${locale}" is not a valid locale code.`)
}
if (!available.includes(dictionary)) {
return reply.badRequest(
`"${dictionary}" is not a text search dictionary in this database.`
)
}
}
}
const previousConfig = WIKI.config.search
WIKI.config.search = {
...previousConfig,
...(req.body.termHighlighting !== undefined && {
termHighlighting: req.body.termHighlighting
}),
...(req.body.dictOverrides !== undefined && { dictOverrides: req.body.dictOverrides })
}
if (!(await WIKI.configSvc.saveToDb(['search']))) {
WIKI.config.search = previousConfig
return reply.internalServerError('Failed to save the search configuration.')
}
return {
ok: true,
message: 'Search configuration updated successfully.'
}
}
)
/**
* REBUILD SEARCH INDEX
*/
app.post(
'/search/rebuild',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Rebuild the search index',
description:
'Queues a job that recomputes the search vector of every page from its stored content, using the dictionary mapping in force. Runs in the background: the response only says the job was queued.',
tags: ['System'],
response: {
200: {
description: 'Rebuild queued successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid',
description: 'ID of the queued job, which the scheduler view lists.'
}
}
}
}
}
},
async (req, reply) => {
const added = await WIKI.scheduler.addJob({ task: 'rebuildSearchIndex' })
if (!added?.id) {
return reply.internalServerError('The scheduler could not queue the rebuild.')
}
return {
ok: true,
message: 'Search index rebuild queued successfully.',
id: added.id
}
}
)
/**
* LIST EXTENSIONS
*/
app.get(
'/extensions',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List optional extensions',
description:
'Third-party tooling that unlocks extra functionality, with whether each one is present on this system. Detection runs per request, so installing a tool shows up without a restart.',
tags: ['System'],
response: {
200: {
description: 'List of extensions',
type: 'array',
items: { $ref: 'Extension#' }
}
}
}
},
async () => {
return WIKI.models.extensions.getExtensions()
}
)
/**
* INSTALL EXTENSION
*/
app.post<{ Params: { extensionKey: string } }>(
'/extensions/:extensionKey/install',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Install or reinstall an extension',
description:
'Only extensions flagged `isInstallable` can be installed from here — the npm packages, which are Sharp and Puppeteer. For Sharp this is mostly a repair: it already ships as an optional dependency, and refetching it replaces a prebuilt binary that is missing or does not match this OS and architecture. Puppeteer is not shipped at all, so this is a first install, and it fetches a Chromium build of a few hundred megabytes unless the server points at one it already has through `PUPPETEER_EXECUTABLE_PATH`. Git and Pandoc come from the operating system and answer 409 pointing at the documentation. Runs npm and can take minutes — allow the request a correspondingly long timeout.',
tags: ['System'],
params: {
type: 'object',
properties: {
extensionKey: {
type: 'string',
maxLength: 255
}
},
required: ['extensionKey']
},
response: {
200: {
description: 'Extension installed successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
restartRequired: {
type: 'boolean',
description:
'True when this server already tried and failed to load the module. Node replays a failed module load for the life of the process, so the repaired files cannot be used until the server restarts.'
}
}
}
}
}
},
async (req, reply) => {
const definition = WIKI.models.extensions.getDefinition(req.params.extensionKey)
if (!definition) {
return reply.notFound('Extension does not exist.')
}
if (!WIKI.models.extensions.isCompatible(definition)) {
return reply.conflict('This extension is not compatible with this system.')
}
if (definition.isInstallable !== true) {
return reply.conflict(
`${definition.title} must be installed manually. See the documentation for instructions.`
)
}
try {
await WIKI.models.extensions.install(definition)
} catch (err: any) {
// -> The message carries npm's own output, which is the only thing that explains a failure
// like a missing build toolchain. An administrator is the only caller.
return reply.internalServerError(err.message)
}
// -> A fresh install is usable at once, since nothing has tried to load it yet. Repairing one this
// process already choked on is a different story, and saying so beats leaving an administrator
// to wonder why nothing changed.
const restartRequired = WIKI.models.extensions.hasLoadFailed(definition)
return {
ok: true,
message: restartRequired
? `${definition.title} was reinstalled, but this server has to be restarted before it can use it.`
: `${definition.title} installed successfully.`,
restartRequired
}
}
)
/**
* GET API STATE
*/
app.get(
'/api',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the API state',
description:
'Whether API keys are accepted. While this is off, every request presenting a key is rejected, no matter how valid the key is.',
tags: ['System'],
response: {
200: {
description: 'API state',
type: 'object',
properties: {
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
async () => {
return { isEnabled: WIKI.config.api.isEnabled === true }
}
)
/**
* SET API STATE
*/
app.put<{ Body: { isEnabled: boolean } }>(
'/api',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Turn the API on or off',
description:
'Turning it off stops every API key from authenticating, without revoking any of them. Session-authenticated requests, i.e. the admin area itself, are unaffected.',
tags: ['System'],
body: {
type: 'object',
required: ['isEnabled'],
properties: {
isEnabled: {
type: 'boolean'
}
}
},
response: {
200: {
description: 'API state updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
const previousConfig = WIKI.config.api
WIKI.config.api = { ...previousConfig, isEnabled: req.body.isEnabled }
if (!(await WIKI.configSvc.saveToDb(['api']))) {
WIKI.config.api = previousConfig
return reply.internalServerError('Failed to save the API state.')
}
return {
ok: true,
message: req.body.isEnabled ? 'API enabled successfully.' : 'API disabled successfully.',
isEnabled: req.body.isEnabled
}
}
)
/**
* GET METRICS ENDPOINT STATE
*/
app.get(
'/metrics',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Get the metrics endpoint state',
description:
'Whether the Prometheus metrics endpoint is turned on. The endpoint itself is not implemented yet — see the description of the PUT counterpart.',
tags: ['System'],
response: {
200: {
description: 'Metrics endpoint state',
type: 'object',
properties: {
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
async () => {
return { isEnabled: WIKI.config.metrics.isEnabled === true }
}
)
/**
* SET METRICS ENDPOINT STATE
*/
app.put<{ Body: { isEnabled: boolean } }>(
'/metrics',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Turn the metrics endpoint on or off',
description:
'Stores the state and nothing more, for now: the `/metrics` endpoint it governs is not implemented, and its documented `read:metrics` bearer authentication depends on API keys, which are not implemented either.',
tags: ['System'],
body: {
type: 'object',
required: ['isEnabled'],
properties: {
isEnabled: {
type: 'boolean'
}
}
},
response: {
200: {
description: 'Metrics endpoint state updated successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
isEnabled: {
type: 'boolean'
}
}
}
}
}
},
async (req, reply) => {
const previousConfig = WIKI.config.metrics
WIKI.config.metrics = { ...previousConfig, isEnabled: req.body.isEnabled }
if (!(await WIKI.configSvc.saveToDb(['metrics']))) {
WIKI.config.metrics = previousConfig
return reply.internalServerError('Failed to save the metrics endpoint state.')
}
return {
ok: true,
message: req.body.isEnabled
? 'Metrics endpoint enabled successfully.'
: 'Metrics endpoint disabled successfully.',
isEnabled: req.body.isEnabled
}
}
)
/**
* LIST SYSTEM INSTANCES
*/
app.get(
'/instances',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List System Instances',
tags: ['System'],
response: {
200: {
description: 'List of all system instances',
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string'
},
activeConnections: {
type: 'number'
},
activeListeners: {
type: 'number'
},
dbUser: {
type: 'string'
},
dbFirstSeen: {
type: 'string',
format: 'date-time'
},
dbLastSeen: {
type: 'string',
format: 'date-time'
},
ip: {
type: 'string'
}
}
}
}
}
}
},
async () => {
return getInstances()
}
)
/**
* CHECK FOR UPDATE
*/
app.post(
'/checkForUpdate',
{
config: {
permissions: ['access:admin']
},
schema: {
summary: 'Check for Updates',
tags: ['System'],
response: {
200: {
description: 'Update Info',
type: 'object',
properties: {
current: {
type: 'string'
},
latest: {
type: 'string'
},
latestDate: {
type: 'string',
format: 'date-time'
}
}
}
}
}
},
async () => {
const renderJob = await WIKI.scheduler.addJob({
task: 'checkVersion',
maxRetries: 0,
promise: true
})
// NOTE: `addJob` resolves to undefined if enqueueing failed, in which case this throws —
// preserving the existing behavior.
await renderJob!.promise
return {
current: WIKI.version,
latest: WIKI.config.update.version,
latestDate: WIKI.config.update.versionDate
}
}
)
}
export default routes