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 - :`. 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[]> { 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 = {} 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 }>( '/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 }>( '/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 } }>( '/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