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/index.ts

781 lines
28 KiB

// ===========================================
// Wiki.js Server
// Licensed under AGPLv3
// ===========================================
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import semver from 'semver'
import { customAlphabet } from 'nanoid'
import { uniq } from 'es-toolkit/array'
import fastify from 'fastify'
import fastifyCompress from '@fastify/compress'
import fastifyCors from '@fastify/cors'
import fastifyCookie from '@fastify/cookie'
import fastifyFavicon from 'fastify-favicon'
import fastifyFormBody from '@fastify/formbody'
import fastifyHelmet from '@fastify/helmet'
import fastifySensible from '@fastify/sensible'
import fastifySession from '@fastify/session'
import fastifyStatic from '@fastify/static'
import fastifySwagger from '@fastify/swagger'
import fastifySwaggerUi from '@fastify/swagger-ui'
import fastifyWebsocket from '@fastify/websocket'
import gracefulServer from '@gquittet/graceful-server'
import ajvFormats from 'ajv-formats'
import Emittery from 'emittery'
import NodeCache from 'node-cache'
import collab from './core/collab.ts'
import configSvc from './core/config.ts'
import dbManager from './core/db.ts'
import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts'
import { stripPageExtension } from './helpers/common.ts'
import { corsOrigin, parseCspDirectives } from './helpers/security.ts'
const nanoid = customAlphabet('1234567890abcdef', 10)
/**
* Files a browser or a crawler asks for at the root by convention, rather than because the wiki has a
* page there. Kept out of the page URL rules below — `txt` is a page extension on a default site, and
* answering `/robots.txt` with a redirect to `/robots` would be answering the wrong question.
*/
const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'])
/**
* First path segments the SERVER itself answers — every prefix registered in `initHTTPServer`.
*
* Spelled out rather than tested with `isPageUrl`, because a leading underscore does not mean the
* server: the frontend router owns `/_admin`, `/_profile`, `/_inbox`, `/_search`, `/_create`, `/_edit`
* and `/_error` too, and those have to reach the app shell like any page path. The distinction the
* shell needs is "does something here serve this", which is this list, and it has to be kept in step
* with the registrations below.
*/
const SERVER_ROUTE_SEGMENTS = new Set([
'_api',
'_assets',
'_blocks',
'_collab',
'_files',
'_icons',
'_render',
'_site',
'_terminal',
'_thumb',
'_user'
])
/**
* Whether a URL addresses the page tree rather than the server itself.
*
* Everything the server mounts sits under a leading-underscore segment — `/_api`, `/_assets`,
* `/_files`, and the rest registered in `initHTTPServer` — which is what makes the distinction a
* prefix test rather than a list to keep in step with the routes.
*/
function isPageUrl(urlPath: string): boolean {
const firstSegment = urlPath.split('/')[1] ?? ''
return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
}
if (!semver.satisfies(process.version, '>=26')) {
console.error('ERROR: Node.js 26.x or later required!')
process.exit(1)
}
if (existsSync('./package.json')) {
console.error('ERROR: Must run server from the parent directory!')
process.exit(1)
}
// The global is assembled progressively: the literal below holds what is known at startup, and
// preBoot()/initHTTPServer() fill in db, models, cache, scheduler, events, app and server.
const WIKI = {
IS_DEBUG: process.env.NODE_ENV === 'development',
ROOTPATH: process.cwd(),
INSTANCE_ID: nanoid(10),
SERVERPATH: path.join(process.cwd(), 'backend'),
auth: {
groups: {},
strategies: {}
},
collab,
configSvc,
sites: {},
sitesMappings: {},
startedAt: Temporal.Now.instant()
} as unknown as WikiGlobal
global.WIKI = WIKI
if (WIKI.IS_DEBUG) {
process.on('warning', (warning: Error) => {
console.log(warning.stack)
})
}
await WIKI.configSvc.init()
// ----------------------------------------
// Init Logger
// ----------------------------------------
WIKI.logger = logger.init()
// ----------------------------------------
// Init Server
// ----------------------------------------
WIKI.logger.info('=======================================')
WIKI.logger.info(`= Wiki.js ${(WIKI.version + ' ').padEnd(29, '=')}`)
WIKI.logger.info('=======================================')
WIKI.logger.info('Initializing...')
WIKI.logger.info(`Running node.js ${process.version} [ OK ]`)
// ----------------------------------------
// Pre-Boot Sequence
// ----------------------------------------
async function preBoot() {
WIKI.dbManager = (await import('./core/db.ts')).default
WIKI.db = await dbManager.init()
WIKI.models = (await import('./models/index.ts')).default
try {
if (await WIKI.configSvc.loadFromDb()) {
WIKI.logger.info('Settings merged with DB successfully [ OK ]')
} else {
WIKI.logger.warn('No settings found in DB. Initializing with defaults...')
await WIKI.configSvc.initDbValues()
if (!(await WIKI.configSvc.loadFromDb())) {
throw new Error('Settings table is empty! Could not initialize [ ERROR ]')
}
}
} catch (err: any) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {
WIKI.logger.error(err)
}
process.exit(1)
}
WIKI.cache = new NodeCache({ checkperiod: 0 })
WIKI.scheduler = await scheduler.init()
WIKI.events = {
inbound: new Emittery(),
outbound: new Emittery()
}
}
// ----------------------------------------
// Post-Boot Sequence
// ----------------------------------------
async function postBoot() {
await WIKI.models.locales.refreshFromDisk()
await WIKI.models.authentication.refreshStrategiesFromDisk()
await WIKI.models.authentication.activateStrategies()
await WIKI.models.locales.reloadCache()
await WIKI.models.sites.reloadCache()
// -> Page access is decided from these on every request, so they are in memory from the start
await WIKI.models.groups.reloadCache()
// -> Likewise: every page view asks whether the page takes suggestions and who reviews it
await WIKI.models.approvals.reloadCache()
// -> Must follow the sites cache: every site gets a row per installed block
await WIKI.models.blocks.refreshFromDisk()
await WIKI.models.blocks.syncAllSites()
// -> Same: every site gets a row per installed storage module
await WIKI.models.storage.refreshFromDisk()
await WIKI.models.storage.syncAllSites()
// -> Optional third-party tooling: report what is available, since features silently degrade
// without it
await WIKI.models.extensions.refreshFromDisk()
await WIKI.models.extensions.logState()
// -> The icon cache is derived from the db and starts empty on a fresh instance
await WIKI.models.icons.ensureCacheDir()
await WIKI.dbManager.subscribeToNotifications()
// -> Its own postgres listener, on its own channel: collaboration traffic is far heavier than the
// event bus's and has nothing to do with it. Must follow the sites cache, which the websocket
// handshake reads the per-site feature toggle from.
await WIKI.collab.init()
await WIKI.scheduler.start()
// -> A page queued for rendering when this instance went down is still queued, and nothing looks at
// that table until somebody asks for another render. Costs one query when there is nothing to do.
await WIKI.scheduler.addJob({ task: 'renderPages', maxRetries: 0 })
}
// ----------------------------------------
// Init HTTP Server
// ----------------------------------------
async function initHTTPServer() {
// ----------------------------------------
// Load core modules
// ----------------------------------------
// WIKI.auth = auth.init()
// WIKI.mail = mail.init()
// WIKI.system = system.init()
// ----------------------------------------
// Initialize Fastify App
// ----------------------------------------
const app = fastify({
ajv: {
// -> `ajv-formats` is CJS: the default import resolves to `module.exports`, so the callable
// plugin is reached via `.default` (verified identical at runtime: `f === f.default`).
// The tuple assertion is load-bearing twice over: it stops the element from widening
// (which makes fastify's overload resolution fall through to the HTTP/2 signature), and
// it bridges an upstream variance mismatch — @fastify/ajv-compiler declares plugin
// options as `unknown`, while ajv-formats declares its own narrower options type, and the
// two are contravariantly incompatible. (`ajv` itself is only a nested dependency here, so
// its `Plugin` type is not importable to state this more precisely.)
plugins: [[ajvFormats.default, {}] as any],
onCreate: (ajv: any) => {
// -> Accepts the shorthand, alpha and full forms a color picker can produce:
// #RGB, #RGBA, #RRGGBB and #RRGGBBAA
ajv.addFormat('hexcolor', (data: unknown) => {
return (
typeof data === 'string' &&
/^#(?:[a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/.test(data)
)
})
}
},
bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb
logger: {
level: 'error'
},
// -> `securityTrustProxy` was the 2.x name: the setting is `trustProxy`, so this read never
// matched and the option was permanently off no matter what the admin area showed
trustProxy: WIKI.config.security.trustProxy ?? false,
routerOptions: {
ignoreTrailingSlash: true
}
})
WIKI.app = app
WIKI.server = gracefulServer(app.server, {
livenessEndpoint: '/_live',
readinessEndpoint: '/_ready',
kubernetes: Boolean(process.env.KUBERNETES_SERVICE_HOST)
})
app.register(fastifySensible)
app.register(fastifyCompress, { global: true })
/*
Websocket upgrades, for live collaborative editing (`controllers/collab.ts`) and the admin
terminal's log stream (`controllers/terminal.ts`). Registered on the root instance because the
upgrade handler is installed on the HTTP server itself, and before the routes below because a
route declaring `websocket: true` needs it already there.
`maxPayload` bounds a single frame: these carry keystrokes and cursor positions, and the largest
legitimate one is a client handing over a document it edited while offline.
*/
app.register(fastifyWebsocket, { options: { maxPayload: 5242880 } })
// ----------------------------------------
// Handle graceful server shutdown
// ----------------------------------------
WIKI.server.on(gracefulServer.SHUTTING_DOWN, () => {
WIKI.logger.info('Shutting down HTTP Server... [ STOPPING ]')
WIKI.dbManager.unsubscribeFromNotifications()
// -> Closes every editing socket with a going-away code, so the editors reconnect to whichever
// instance takes over rather than sitting on a dead connection
WIKI.collab.shutdown()
})
WIKI.server.on(gracefulServer.SHUTDOWN, (err: Error) => {
WIKI.logger.info(`HTTP Server has exited: [ STOPPED ] (${err.message})`)
if (err.message !== 'SIGINT') {
WIKI.logger.warn(err)
}
})
// ----------------------------------------
// Security
// ----------------------------------------
// -> Every setting below comes from the admin area's security view. They are read once, here, so a
// change takes effect on the next restart — the view says as much.
const security = WIKI.config.security
app.register(fastifyHelmet, {
contentSecurityPolicy:
security.enforceCsp && security.cspDirectives
? { directives: parseCspDirectives(security.cspDirectives), useDefaults: false }
: false,
strictTransportSecurity:
security.enforceHsts && security.hstsDuration > 0
? {
maxAge: security.hstsDuration,
includeSubDomains: true
}
: false,
// -> Helmet's own default is `sameorigin`, which is also what this setting turned off means
xFrameOptions: { action: security.disallowIframe ? 'deny' : 'sameorigin' },
referrerPolicy: security.enforceSameOriginReferrerPolicy
? { policy: 'same-origin' }
: { policy: 'no-referrer' }
})
app.register(fastifyCors, {
origin: corsOrigin(security),
methods: ['GET', 'HEAD', 'POST', 'OPTIONS']
})
if (security.disallowFloc) {
// -> Helmet dropped its FLoC helper once the proposal was withdrawn, but opting out still costs
// one header and the setting exists
app.addHook('onSend', (req, reply, payload, done) => {
reply.header('Permissions-Policy', 'interest-cohort=()')
done(null, payload)
})
}
// ----------------------------------------
// Public Assets
// ----------------------------------------
app.register(fastifyFavicon, {
path: path.join(WIKI.ROOTPATH, 'assets'),
name: 'favicon.ico'
})
app.register(fastifyStatic, {
prefix: '/_assets/',
root: path.join(WIKI.ROOTPATH, 'assets/_assets'),
index: false,
maxAge: '7d',
decorateReply: false
})
// ----------------------------------------
// Blocks
// ----------------------------------------
app.register(fastifyStatic, {
prefix: '/_blocks/',
root: path.join(WIKI.ROOTPATH, 'blocks/compiled'),
index: false,
maxAge: '1h'
})
// ----------------------------------------
// Sessions
// ----------------------------------------
app.register(fastifyCookie, {
secret: WIKI.config.auth.secret,
hook: 'onRequest'
})
app.register(fastifySession, {
secret: WIKI.config.auth.secret,
cookieName: 'wikiSession',
cookie: {
httpOnly: true,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
secure: 'auto'
},
saveUninitialized: false,
store: {
async get(sessionId: string, clb: (err: any, result?: any) => void) {
try {
clb(null, await WIKI.models.sessions.get(sessionId))
} catch (err: any) {
clb(err, null)
}
},
async set(sessionId: string, sessionData: any, clb: (err: any, result?: any) => void) {
try {
clb(null, await WIKI.models.sessions.set(sessionId, sessionData))
} catch (err: any) {
clb(err, null)
}
},
async destroy(sessionId: string, clb: (err: any, result?: any) => void) {
try {
clb(null, await WIKI.models.sessions.destroy(sessionId))
} catch (err: any) {
clb(err, null)
}
}
}
})
// ----------------------------------------
// API Routes
// ----------------------------------------
app.register(fastifySwagger, {
hideUntagged: true,
openapi: {
openapi: '3.1.0',
info: {
title: 'Wiki.js API',
version: WIKI.version
},
components: {
securitySchemes: {
apiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key'
},
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
}
},
security: [{ apiKeyAuth: [] }, { bearerAuth: [] }]
},
transform: ({ schema, url, route }: any) => {
// Add permissions to the route schema description
const permissions = route?.config?.permissions ?? []
const transformedSchema = { ...schema }
const currentDescription = transformedSchema.description || ''
if (permissions?.length > 0) {
const nestedPermissions: string[] = []
for (const perm of permissions) {
if (Array.isArray(perm)) {
nestedPermissions.push(`\`${perm.join(' + ')}\``)
} else {
nestedPermissions.push(`\`${perm}\``)
}
}
nestedPermissions.push('`manage:system`')
transformedSchema.description =
`${currentDescription}\n\n**Required Permissions:** ${uniq(nestedPermissions).join(' or ')}`.trim()
transformedSchema['x-permissions'] = permissions
} else if (route?.config?.publicAccess) {
transformedSchema.description =
`${currentDescription}\n\n**This API is public.** No special permissions required.`.trim()
} else {
/*
No fixed permission is not the same as public, and saying so was wrong for most of these.
A route without one is usually a route whose answer depends on the caller: the page rules of
their groups, their own account, or the queue they happen to be a reviewer for. What it
serves is scoped, not unrestricted.
*/
transformedSchema.description =
`${currentDescription}\n\n**No fixed permission.** What this returns, and what it acts on, is limited to what the caller is entitled to — their session, their groups' page rules, or their own account. A request that is entitled to nothing gets an empty answer or a refusal rather than an error about permissions.`.trim()
}
return { schema: transformedSchema, url }
}
})
app.register(fastifySwaggerUi, {
routePrefix: '/_api',
/*
Swagger UI's own sorters, applied in the browser: tags down the page, and the operations inside
each tag by path. Neither is on by default — the order is otherwise the order the routes were
registered in, which is meaningful to `api/index.ts` and arbitrary to anyone reading the docs.
`operationsSorter: 'alpha'` sorts on the path, not the summary, so the several methods of one
path stay together and keep their registration order relative to each other.
*/
uiConfig: {
tagsSorter: 'alpha',
operationsSorter: 'alpha'
},
// -> Left empty so the plugin inlines neither its own logo nor one of ours; the stylesheet below
// is what puts the site's logo in the topbar
logo: {} as any,
theme: {
css: [
{
filename: 'wiki.css',
/*
The site's own logo in the topbar, as a background on the link swagger draws its wordmark
in.
A stylesheet rather than the plugin's `logo` option, which takes a buffer and base64-inlines
it into the page when the server boots. This documentation is served for whichever site the
request arrived at, and an administrator can change that site's logo at any time — a URL
resolves both of those per request, and a buffer chosen at boot resolves neither.
`contain` in a box wider than it is tall, so a square mark and a wordmark both sit sensibly
without the logo being distorted to fit.
*/
content: `
.swagger-ui .topbar-wrapper a.link > * {
display: none;
}
.swagger-ui .topbar-wrapper a.link {
display: block;
width: 160px;
height: 40px;
background: url('/_site/current/logo') left center / contain no-repeat;
}
`
}
]
}
})
// ----------------------------------------
// API Key Authentication
// ----------------------------------------
app.decorateRequest('apiKey', null)
app.addHook('onRequest', async (req, reply) => {
// -> Bearer tokens authenticate API calls only; everything else is cookie-authenticated. Note
// that the session is deliberately left untouched: writing to it would have @fastify/session
// persist a session row for every scraped request.
if (!req.url.startsWith('/_api/')) {
return
}
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return
}
const token = header.slice('Bearer '.length).trim()
if (!token) {
return
}
try {
req.apiKey = await WIKI.models.apiKeys.verify(token)
} catch (err: any) {
// -> Say why: the caller holds the credential and can act on "revoked" or "expired"
WIKI.logger.debug(`Rejected an API key: ${err.message}`)
return reply.unauthorized(err.message)
}
})
// ----------------------------------------
// Permissions
// ----------------------------------------
app.addHook('preHandler', (req, reply, done) => {
const routePermissions = req.routeOptions.config?.permissions
if (routePermissions && routePermissions.length > 0) {
// -> A verified API key stands in for a session, carrying the permissions of the groups it was
// issued for
const permissions = req.apiKey
? req.apiKey.permissions
: req.session?.authenticated
? req.session.permissions
: null
// Unauthenticated / No Permissions
if (!permissions || permissions.length < 1) {
return reply.unauthorized()
}
// Is Root Admin?
if (!permissions.includes('manage:system')) {
// Check for at least 1 permission
const isAllowed = routePermissions.some((perms) => {
// Check for all permissions
if (Array.isArray(perms)) {
return perms.every((perm) => permissions.some((p) => p === perm))
} else {
return permissions.some((p) => p === perms)
}
})
// Forbidden
if (!isAllowed) {
return reply.forbidden()
}
}
}
done()
})
// ----------------------------------------
// SEO
// ----------------------------------------
app.addHook('onRequest', (req, reply, done) => {
const [urlPath, urlQuery] = req.raw.url!.split('?')
const withQuery = (newPath: string) => (urlQuery ? `${newPath}?${urlQuery}` : newPath)
const trimmed = urlPath!.length > 1 && urlPath!.endsWith('/') ? urlPath!.slice(0, -1) : urlPath!
if (isPageUrl(trimmed)) {
// -> Straight off the site caches rather than through the model: this runs on every request, and
// both lookups are the ones `getSiteByHostname` would do, minus its optional reload
const siteId = WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*']
const withoutExtension = stripPageExtension(
trimmed,
WIKI.sites[siteId]?.config?.pageExtensions
)
if (withoutExtension) {
// -> Answers a trailing slash as well, rather than sending the client back for a second
// round trip to be told about the extension.
//
// Not a 301: which extensions resolve this way is a setting, and a browser that cached a
// permanent redirect would go on applying it after an administrator had changed it
reply.redirect(withQuery(withoutExtension), 302)
return
}
}
if (trimmed !== urlPath) {
reply.redirect(withQuery(trimmed), 301)
return
}
done()
})
app.register(fastifyFormBody, {
bodyLimit: 1048576 // 1mb
})
// ----------------------------------------
// Routing
// ----------------------------------------
// app.addHook('onRequest', async (req, reply, done) => {
// const currentSite = await WIKI.db.sites.getSiteByHostname({ hostname: req.hostname })
// if (!currentSite) {
// return reply.code(404).send('Site Not Found')
// }
// req.locals.siteConfig = {
// id: currentSite.id,
// title: currentSite.config.title,
// darkMode: currentSite.config.theme.dark,
// lang: currentSite.config.locales.primary,
// rtl: false, // TODO: handle RTL
// company: currentSite.config.company,
// contentLicense: currentSite.config.contentLicense
// }
// req.locals.theming = {
// }
// req.locals.langs = await WIKI.db.locales.getNavLocales({ cache: true })
// req.locals.analyticsCode = await WIKI.db.analytics.getCode({ cache: true })
// done()
// })
app.register(import('./api/index.ts'), { prefix: '/_api' })
app.register(import('./controllers/collab.ts'), { prefix: '/_collab' })
app.register(import('./controllers/files.ts'), { prefix: '/_files' })
app.register(import('./controllers/site.ts'), { prefix: '/_site' })
app.register(import('./controllers/icons.ts'), { prefix: '/_icons' })
app.register(import('./controllers/render.ts'), { prefix: '/_render' })
app.register(import('./controllers/terminal.ts'), { prefix: '/_terminal' })
app.register(import('./controllers/thumb.ts'), { prefix: '/_thumb' })
app.register(import('./controllers/user.ts'), { prefix: '/_user' })
// ----------------------------------------
// App Shell
// ----------------------------------------
const appShellPath = path.join(WIKI.ROOTPATH, 'assets/index.html')
/*
The compiled SPA, for every path no route above claimed.
It has to be the fallback rather than a route of its own: a wiki page lives at any path a user cares
to give it, and the frontend's router -- not this server -- is what resolves one. Which is also why
the only paths held back are the segments the server itself mounts, so a mistyped `/_api/...` still
answers as the API rather than handing back a page of HTML, and the root files a crawler asks for by
convention, which are absent here rather than being the app.
`no-store`: the bundles this pulls in are hashed and immutable under `/_assets`, but the document
naming them must never be held, or a rebuilt frontend would keep booting the previous one. Read per
request for the same reason -- `npm run build` while the server is up should be enough.
*/
app.setNotFoundHandler(async (req, reply) => {
const urlPath = req.raw.url!.split('?')[0]!
const firstSegment = urlPath.split('/')[1] ?? ''
const isSystemPath = SERVER_ROUTE_SEGMENTS.has(firstSegment)
const isReservedRootFile = RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
// -> HEAD as well as GET: it has to answer what GET would, or a monitor pointed at the wiki reads a
// 404 for a page the browser beside it loads. Node drops the body for HEAD on its own.
const isReadRequest = req.method === 'GET' || req.method === 'HEAD'
if (!isReadRequest || isSystemPath || isReservedRootFile) {
return reply.notFound()
}
try {
const shell = await readFile(appShellPath, 'utf8')
return reply.header('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(shell)
} catch (err: any) {
// -> Nothing to serve means the frontend was never built, which is a setup step rather than a
// fault of this request: say which one, since a bare 500 sends people looking in the server
WIKI.logger.error(`Cannot serve the app shell from ${appShellPath}: ${err.message}`)
return reply
.code(503)
.type('text/plain; charset=utf-8')
.send('The frontend has not been built yet. Run `npm run build` in frontend/.\n')
}
})
// ----------------------------------------
// Error handling
// ----------------------------------------
app.setErrorHandler((error: any, req, reply) => {
if (req.url.includes('/_api/')) {
if (error.statusCode) {
reply.code(error.statusCode).type('application/json').send({
ok: false,
error: error.name,
statusCode: error.statusCode,
message: error.message
})
} else {
WIKI.logger.warn(error)
reply.code(500).type('application/json').send({
ok: false,
error: 'Internal Server Error',
statusCode: 500,
message: 'Internal Server error'
})
}
} else {
reply.send(error)
}
})
// ----------------------------------------
// Bind HTTP Server
// ----------------------------------------
try {
WIKI.logger.info(`Starting HTTP Server on port ${WIKI.config.port} [ STARTING ]`)
await app.listen({ port: WIKI.config.port, host: WIKI.config.bindIP })
WIKI.logger.info('HTTP Server: [ RUNNING ]')
WIKI.server.setReady()
} catch (err: any) {
WIKI.logger.error(err)
process.exit(1)
}
}
// ----------------------------------------
// Register exit handler
// ----------------------------------------
// process.on('SIGINT', () => {
// WIKI.kernel.shutdown()
// })
// process.on('message', (msg) => {
// if (msg === 'shutdown') {
// WIKI.kernel.shutdown()
// }
// })
// ----------------------------------------
// Initialization Sequence
// ----------------------------------------
await preBoot()
await initHTTPServer()
await postBoot()