refactor: fastify + drizzle exploration

vega
NGPixel 1 day ago
parent 05fe495772
commit dc25db1139
No known key found for this signature in database

@ -0,0 +1,4 @@
audit = false
fund = false
save-exact = true
save-prefix = ""

@ -0,0 +1,14 @@
/**
* API Routes
*/
async function routes (app, options) {
app.register(import('./sites.mjs'), { prefix: '/sites' })
app.register(import('./system.mjs'), { prefix: '/system' })
app.register(import('./users.mjs'), { prefix: '/users' })
app.get('/', async (req, reply) => {
return { ok: true }
})
}
export default routes

@ -0,0 +1,85 @@
/**
* Sites API Routes
*/
async function routes (app, options) {
app.get('/', {
schema: {
summary: 'List all sites',
tags: ['Sites']
}
}, async (req, reply) => {
return { hello: 'world' }
})
app.get('/:siteId', {
schema: {
summary: 'Get site info',
tags: ['Sites']
}
}, async (req, reply) => {
return { hello: 'world' }
})
app.post('/', {
schema: {
summary: 'Create a new site',
tags: ['Sites'],
body: {
type: 'object',
required: ['name', 'hostname'],
properties: {
name: { type: 'string' },
hostname: { type: 'string' }
}
}
}
}, async (req, reply) => {
return { hello: 'world' }
})
app.put('/:siteId', {
schema: {
summary: 'Update a site',
tags: ['Sites']
}
}, async (req, reply) => {
return { hello: 'world' }
})
/**
* DELETE SITE
*/
app.delete('/:siteId', {
schema: {
summary: 'Delete a site',
tags: ['Sites'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId']
},
response: {
204: {
description: 'Site deleted successfully'
}
}
}
}, async (req, reply) => {
try {
if (await WIKI.models.sites.deleteSite(req.params.siteId)) {
reply.code(204)
} else {
reply.badRequest('Site does not exist.')
}
} catch (err) {
reply.send(err)
}
})
}
export default routes

@ -0,0 +1,15 @@
/**
* System API Routes
*/
async function routes (app, options) {
app.get('/info', {
schema: {
summary: 'System Info',
tags: ['System']
}
}, async (request, reply) => {
return { hello: 'world' }
})
}
export default routes

@ -0,0 +1,51 @@
/**
* Users API Routes
*/
async function routes (app, options) {
app.get('/', {
schema: {
summary: 'List all users',
tags: ['Users']
}
}, async (request, reply) => {
return { hello: 'world' }
})
app.get('/:userId', {
schema: {
summary: 'Get user info',
tags: ['Users']
}
}, async (request, reply) => {
return { hello: 'world' }
})
app.post('/', {
schema: {
summary: 'Create a new user',
tags: ['Users']
}
}, async (request, reply) => {
return { hello: 'world' }
})
app.put('/:userId', {
schema: {
summary: 'Update a user',
tags: ['Users']
}
}, async (request, reply) => {
return { hello: 'world' }
})
app.delete('/:userId', {
schema: {
summary: 'Delete a user',
tags: ['Users']
}
}, async (request, reply) => {
return { hello: 'world' }
})
}
export default routes

@ -0,0 +1,160 @@
# ---------------------------------
# DO NOT EDIT THIS FILE!
# This is reserved for system use!
# ---------------------------------
name: Wiki.js
defaults:
config:
# File defaults
port: 80
db:
host: localhost
port: 5432
user: wikijs
pass: wikijsrocks
db: wiki
ssl: false
sslOptions:
auto: true
schema: wiki
ssl:
enabled: false
pool:
min: 1
bindIP: 0.0.0.0
logLevel: info
logFormat: default
offline: false
dataPath: ./data
bodyParserLimit: 5mb
scheduler:
workers: 3
pollingCheck: 5
scheduledCheck: 300
maxRetries: 2
retryBackoff: 60
historyExpiration: 90000
# DB defaults
api:
isEnabled: false
mail:
host: ''
secure: true
verifySSL: true
metrics:
isEnabled: false
auth:
autoLogin: false
enforce2FA: false
hideLocal: false
loginBgUrl: ''
audience: 'urn:wiki.js'
tokenExpiration: '30m'
tokenRenewal: '14d'
secret: 'abcdef1234567890abcdef1234567890abcdef'
security:
corsMode: 'OFF'
corsConfig: ''
enforceCsp: false
trustProxy: false
enforceHsts: false
disallowFloc: true
hstsDuration: 0
cspDirectives: ''
uploadScanSVG: true
disallowIframe: true
uploadMaxFiles: 20
authJwtAudience: 'urn:wiki.js'
authJwtExpiration: '30m'
uploadMaxFileSize: 10485760
forceAssetDownload: true
disallowOpenRedirect: true
authJwtRenewablePeriod: '14d'
enforceSameOriginReferrerPolicy: true
flags:
experimental: false
authDebug: false
sqlLog: false
userDefaults:
timezone: 'America/New_York'
dateFormat: 'YYYY-MM-DD'
timeFormat: '12h'
# System defaults
channel: NEXT
cors:
credentials: true
maxAge: 600
methods: 'GET,POST'
origin: true
maintainerEmail: security@requarks.io
tsDictMappings:
ar: arabic
hy: armenian
eu: basque
ca: catalan
da: danish
nl: dutch
en: english
fi: finnish
fr: french
de: german
el: greek
hi: hindi
hu: hungarian
id: indonesian
ga: irish
it: italian
lt: lithuanian
ne: nepali
no: norwegian
pt: portuguese
ro: romanian
ru: russian
sr: serbian
es: spanish
sv: swedish
ta: tamil
tr: turkish
yi: yiddish
editors:
asciidoc:
contentType: html
config: {}
markdown:
contentType: markdown
config:
allowHTML: true
linkify: true
lineBreaks: true
typographer: false
underline: false
tabWidth: 2
latexEngine: katex
kroki: true
plantuml: true
multimdTable: true
wysiwyg:
contentType: html
config: {}
systemIds:
localAuthId: '5a528c4c-0a82-4ad2-96a5-2b23811e6588'
guestsGroupId: '10000000-0000-4000-8000-000000000001'
usersGroupId: '20000000-0000-4000-8000-000000000002'
groups:
defaultPermissions:
- 'read:pages'
- 'read:assets'
- 'read:comments'
- 'write:comments'
defaultRules:
- name: Default Rule
mode: ALLOW
match: START
roles:
- 'read:pages'
- 'read:assets'
- 'read:comments'
- 'write:comments'
path: ''
locales: []
sites: []

@ -0,0 +1,152 @@
import { defaultsDeep, get, isPlainObject } from 'lodash-es'
import chalk from 'chalk'
import cfgHelper from '../helpers/config.mjs'
import fs from 'node:fs/promises'
import path from 'node:path'
import yaml from 'js-yaml'
import { v4 as uuid } from 'uuid'
export default {
/**
* Load root config from disk
*/
async init (silent = false) {
const confPaths = {
config: path.join(WIKI.ROOTPATH, 'config.yml'),
data: path.join(WIKI.SERVERPATH, 'base.yml')
}
if (process.env.CONFIG_FILE) {
confPaths.config = path.resolve(WIKI.ROOTPATH, process.env.CONFIG_FILE)
}
if (!silent) {
process.stdout.write(chalk.blue(`Loading configuration from ${confPaths.config}... `))
}
let appconfig = {}
let appdata = {}
try {
appconfig = yaml.load(
cfgHelper.parseConfigValue(
await fs.readFile(confPaths.config, 'utf8')
)
)
appdata = yaml.load(await fs.readFile(confPaths.data, 'utf8'))
if (!silent) {
console.info(chalk.green.bold('OK'))
}
} catch (err) {
console.error(chalk.red.bold('FAILED'))
console.error(err.message)
console.error(chalk.red.bold('>>> Unable to read configuration file! Did you create the config.yml file?'))
process.exit(1)
}
// Merge with defaults
appconfig = defaultsDeep(appconfig, appdata.defaults.config)
// Override port
if (appconfig.port < 1) {
appconfig.port = process.env.PORT || 80
}
if (process.env.WIKI_PORT) {
appconfig.port = process.env.WIKI_PORT || 80
}
// Load package info
const packageInfo = JSON.parse(await fs.readFile(path.join(WIKI.SERVERPATH, 'package.json'), 'utf-8'))
// Load DB Password from Docker Secret File
if (process.env.DB_PASS_FILE) {
if (!silent) {
console.info(chalk.blue('DB_PASS_FILE is defined. Will use secret from file.'))
}
try {
appconfig.db.pass = await fs.readFile(process.env.DB_PASS_FILE, 'utf8').trim()
} catch (err) {
console.error(chalk.red.bold('>>> Failed to read Docker Secret File using path defined in DB_PASS_FILE env variable!'))
console.error(err.message)
process.exit(1)
}
}
WIKI.config = appconfig
WIKI.data = appdata
WIKI.version = packageInfo.version
WIKI.releaseDate = packageInfo.releaseDate
WIKI.devMode = (packageInfo.dev === true)
},
/**
* Load config from DB
*/
async loadFromDb () {
WIKI.logger.info('Loading settings from DB...')
const conf = await WIKI.models.settings.getConfig()
if (conf) {
WIKI.config = defaultsDeep(conf, WIKI.config)
return true
} else {
return false
}
},
/**
* Save config to DB
*
* @param {Array} keys Array of keys to save
* @returns Promise
*/
async saveToDb (keys, propagate = true) {
try {
for (const key of keys) {
let value = get(WIKI.config, key, null)
if (!isPlainObject(value)) {
value = { v: value }
}
await WIKI.models.settings.updateConfig(key, value)
}
if (propagate) {
WIKI.events.outbound.emit('reloadConfig')
}
} catch (err) {
WIKI.logger.error(`Failed to save configuration to DB: ${err.message}`)
return false
}
return true
},
/**
* Initialize DB tables with default values
*/
async initDbValues () {
const ids = {
groupAdminId: uuid(),
groupUserId: WIKI.data.systemIds.usersGroupId,
groupGuestId: WIKI.data.systemIds.guestsGroupId,
siteId: uuid(),
authModuleId: WIKI.data.systemIds.localAuthId,
userAdminId: uuid(),
userGuestId: uuid()
}
await WIKI.models.settings.init(ids)
await WIKI.models.sites.init(ids)
await WIKI.models.groups.init(ids)
await WIKI.models.authentication.init(ids)
},
/**
* Subscribe to HA propagation events
*/
subscribeToEvents () {
WIKI.events.inbound.on('reloadConfig', async () => {
await WIKI.configSvc.loadFromDb()
})
}
}

@ -0,0 +1,223 @@
import { get, has, isEmpty, isPlainObject } from 'lodash-es'
import path from 'node:path'
import fs from 'node:fs/promises'
import { setTimeout } from 'node:timers/promises'
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import PGPubSub from 'pg-pubsub'
import semver from 'semver'
import { createDeferred } from '../helpers/common.mjs'
// import migrationSource from '../db/migrator-source.mjs'
// const migrateFromLegacy = require('../db/legacy')
/**
* ORM DB module
*/
export default {
pool: null,
listener: null,
config: null,
VERSION: null,
LEGACY: false,
onReady: createDeferred(),
connectAttempts: 0,
/**
* Initialize DB
*/
async init (workerMode = false) {
WIKI.logger.info('Checking DB configuration...')
// Fetch DB Config
this.config = (!isEmpty(process.env.DATABASE_URL))
? {
connectionString: process.env.DATABASE_URL
}
: {
host: WIKI.config.db.host.toString(),
user: WIKI.config.db.user.toString(),
password: WIKI.config.db.pass.toString(),
database: WIKI.config.db.db.toString(),
port: WIKI.config.db.port
}
// Handle SSL Options
let dbUseSSL = (WIKI.config.db.ssl === true || WIKI.config.db.ssl === 'true' || WIKI.config.db.ssl === 1 || WIKI.config.db.ssl === '1')
let sslOptions = null
if (dbUseSSL && isPlainObject(this.config) && get(WIKI.config.db, 'sslOptions.auto', null) === false) {
sslOptions = WIKI.config.db.sslOptions
sslOptions.rejectUnauthorized = sslOptions.rejectUnauthorized !== false
if (sslOptions.ca && sslOptions.ca.indexOf('-----') !== 0) {
sslOptions.ca = await fs.readFile(path.resolve(WIKI.ROOTPATH, sslOptions.ca), 'utf-8')
}
if (sslOptions.cert) {
sslOptions.cert = await fs.readFile(path.resolve(WIKI.ROOTPATH, sslOptions.cert), 'utf-8')
}
if (sslOptions.key) {
sslOptions.key = await fs.readFile(path.resolve(WIKI.ROOTPATH, sslOptions.key), 'utf-8')
}
if (sslOptions.pfx) {
sslOptions.pfx = await fs.readFile(path.resolve(WIKI.ROOTPATH, sslOptions.pfx), 'utf-8')
}
} else {
sslOptions = true
}
// Handle inline SSL CA Certificate mode
if (!isEmpty(process.env.DB_SSL_CA)) {
const chunks = []
for (let i = 0, charsLength = process.env.DB_SSL_CA.length; i < charsLength; i += 64) {
chunks.push(process.env.DB_SSL_CA.substring(i, i + 64))
}
dbUseSSL = true
sslOptions = {
rejectUnauthorized: true,
ca: '-----BEGIN CERTIFICATE-----\n' + chunks.join('\n') + '\n-----END CERTIFICATE-----\n'
}
}
if (dbUseSSL && isPlainObject(this.config)) {
this.config.ssl = (sslOptions === true) ? { rejectUnauthorized: true } : sslOptions
}
// Initialize Postgres Pool
this.pool = new Pool({
application_name: 'Wiki.js',
...this.config,
...workerMode ? { min: 0, max: 1 } : WIKI.config.pool,
options: `-c search_path=${WIKI.config.db.schema}`
})
const db = drizzle({ client: this.pool })
// Connect
await this.connect(db)
// Check DB Version
const resVersion = await db.execute('SHOW server_version;')
const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true })
this.VERSION = dbVersion.version
if (dbVersion.major < 16) {
WIKI.logger.error(`Your PostgreSQL database version (${dbVersion.major}) is too old and unsupported by Wiki.js. Requires >= 16. Exiting...`)
process.exit(1)
}
WIKI.logger.info(`Using PostgreSQL v${dbVersion.version} [ OK ]`)
// DEV - Drop schema
if (WIKI.config.dev?.dropSchema) {
WIKI.logger.warn(`DEV MODE - Dropping schema ${WIKI.config.db.schema}...`)
await db.execute(`DROP SCHEMA IF EXISTS ${WIKI.config.db.schema} CASCADE;`)
}
// Run Migrations
if (!workerMode) {
await this.syncSchemas(db)
}
return db
},
/**
* Subscribe to database LISTEN / NOTIFY for multi-instances events
*/
async subscribeToNotifications () {
let connSettings = this.knex.client.connectionSettings
if (typeof connSettings === 'string') {
const encodedName = encodeURIComponent(`Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`)
if (connSettings.indexOf('?') > 0) {
connSettings = `${connSettings}&ApplicationName=${encodedName}`
} else {
connSettings = `${connSettings}?ApplicationName=${encodedName}`
}
} else {
connSettings.application_name = `Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`
}
this.listener = new PGPubSub(connSettings, {
log (ev) {
WIKI.logger.debug(ev)
}
})
// -> Outbound events handling
this.listener.addChannel('wiki', payload => {
if (has(payload, 'event') && payload.source !== WIKI.INSTANCE_ID) {
WIKI.logger.info(`Received event ${payload.event} from instance ${payload.source}: [ OK ]`)
WIKI.events.inbound.emit(payload.event, payload.value)
}
})
WIKI.events.outbound.onAny(this.notifyViaDB)
// -> Listen to inbound events
WIKI.auth.subscribeToEvents()
WIKI.configSvc.subscribeToEvents()
WIKI.db.pages.subscribeToEvents()
WIKI.logger.info('PG PubSub Listener initialized successfully: [ OK ]')
},
/**
* Unsubscribe from database LISTEN / NOTIFY
*/
async unsubscribeToNotifications () {
if (this.listener) {
WIKI.events.outbound.offAny(this.notifyViaDB)
WIKI.events.inbound.removeAllListeners()
this.listener.close()
}
},
/**
* Publish event via database NOTIFY
*
* @param {string} event Event fired
* @param {object} value Payload of the event
*/
notifyViaDB (event, value) {
WIKI.db.listener.publish('wiki', {
source: WIKI.INSTANCE_ID,
event,
value
})
},
/**
* Attempt initial connection
*/
async connect (db) {
try {
WIKI.logger.info('Connecting to database...')
await db.execute('SELECT 1 + 1;')
WIKI.logger.info('Database connection successful [ OK ]')
} catch (err) {
WIKI.logger.debug(err)
if (this.connectAttempts < 10) {
if (err.code) {
WIKI.logger.error(`Database connection error: ${err.code} ${err.address}:${err.port}`)
} else {
WIKI.logger.error(`Database connection error: ${err.message}`)
}
WIKI.logger.warn(`Will retry in 3 seconds... [Attempt ${++this.connectAttempts} of 10]`)
await setTimeout(3000)
await this.connect(db)
} else {
throw err
}
}
},
/**
* Migrate DB Schemas
*/
async syncSchemas (db) {
WIKI.logger.info('Ensuring DB schema exists...')
await db.execute(`CREATE SCHEMA IF NOT EXISTS ${WIKI.config.db.schema}`)
WIKI.logger.info('Ensuring DB migrations have been applied...')
return migrate(db, {
migrationsFolder: path.join(WIKI.SERVERPATH, 'db/migrations'),
migrationsSchema: WIKI.config.db.schema,
migrationsTable: 'migrations'
})
}
}

@ -0,0 +1,119 @@
import { padEnd } from 'lodash-es'
import eventemitter2 from 'eventemitter2'
import NodeCache from 'node-cache'
import asar from './asar.mjs'
import db from './db.mjs'
import extensions from './extensions.mjs'
import scheduler from './scheduler.mjs'
import servers from './servers.mjs'
import metrics from './metrics.mjs'
let isShuttingDown = false
export default {
async init () {
WIKI.logger.info('=======================================')
WIKI.logger.info(`= Wiki.js ${padEnd(WIKI.version + ' ', 29, '=')}`)
WIKI.logger.info('=======================================')
WIKI.logger.info('Initializing...')
WIKI.logger.info(`Running node.js ${process.version}`)
WIKI.db = await db.init()
try {
await WIKI.configSvc.loadFromDb()
await WIKI.configSvc.applyFlags()
} catch (err) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {
WIKI.logger.error(err)
}
process.exit(1)
}
this.bootWeb()
},
/**
* Pre-Web Boot Sequence
*/
async preBootWeb () {
try {
WIKI.cache = new NodeCache({ checkperiod: 0 })
WIKI.scheduler = await scheduler.init()
WIKI.servers = servers
WIKI.events = {
inbound: new eventemitter2.EventEmitter2(),
outbound: new eventemitter2.EventEmitter2()
}
WIKI.extensions = extensions
WIKI.asar = asar
WIKI.metrics = await metrics.init()
} catch (err) {
WIKI.logger.error(err)
process.exit(1)
}
},
/**
* Boot Web Process
*/
async bootWeb () {
try {
await this.preBootWeb()
await (await import('../web.mjs')).init()
this.postBootWeb()
} catch (err) {
WIKI.logger.error(err)
process.exit(1)
}
},
/**
* Post-Web Boot Sequence
*/
async postBootWeb () {
await WIKI.db.locales.refreshFromDisk()
await WIKI.db.analytics.refreshProvidersFromDisk()
await WIKI.db.authentication.refreshStrategiesFromDisk()
await WIKI.db.commentProviders.refreshProvidersFromDisk()
await WIKI.db.renderers.refreshRenderersFromDisk()
await WIKI.db.storage.refreshTargetsFromDisk()
await WIKI.extensions.init()
await WIKI.auth.activateStrategies()
await WIKI.db.commentProviders.initProvider()
await WIKI.db.locales.reloadCache()
await WIKI.db.sites.reloadCache()
await WIKI.db.storage.initTargets()
await WIKI.db.subscribeToNotifications()
await WIKI.scheduler.start()
},
/**
* Graceful shutdown
*/
async shutdown (devMode = false) {
if (isShuttingDown) { return }
isShuttingDown = true
if (WIKI.servers) {
await WIKI.servers.stopServers()
}
if (WIKI.scheduler) {
await WIKI.scheduler.stop()
}
if (WIKI.models) {
await WIKI.db.unsubscribeToNotifications()
if (WIKI.db.knex) {
await WIKI.db.knex.destroy()
}
}
if (WIKI.asar) {
await WIKI.asar.unload()
}
if (!devMode) {
WIKI.logger.info('Terminating process...')
process.exit(0)
}
}
}

@ -0,0 +1,61 @@
import chalk from 'chalk'
import EventEmitter from 'node:events'
const LEVELS = ['error', 'warn', 'info', 'debug']
const LEVELSIGNORED = ['verbose', 'silly']
const LEVELCOLORS = {
error: 'red',
warn: 'yellow',
info: 'green',
debug: 'cyan'
}
class Logger extends EventEmitter {}
export default {
loggers: {},
init () {
const primaryLogger = new Logger()
let ignoreNextLevels = false
primaryLogger.ws = new EventEmitter()
LEVELS.forEach(lvl => {
primaryLogger[lvl] = (...args) => {
primaryLogger.emit(lvl, ...args)
}
if (!ignoreNextLevels) {
primaryLogger.on(lvl, (msg) => {
let formatted = ''
if (WIKI.config.logFormat === 'json') {
formatted = JSON.stringify({
timestamp: new Date().toISOString(),
instance: WIKI.INSTANCE_ID,
level: lvl,
message: msg
})
} else {
if (msg instanceof Error) {
msg = msg.stack
}
formatted = `${new Date().toISOString()} ${chalk.dim('[' + WIKI.INSTANCE_ID + ']')} ${chalk[LEVELCOLORS[lvl]].bold(lvl)}: ${msg}`
}
console.log(formatted)
primaryLogger.ws.emit('log', formatted)
})
}
if (lvl === WIKI.config.logLevel) {
ignoreNextLevels = true
}
})
LEVELSIGNORED.forEach(lvl => {
primaryLogger[lvl] = () => {}
})
return primaryLogger
}
}

@ -0,0 +1,5 @@
-- Create PG Extensions --
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

@ -0,0 +1,57 @@
CREATE TABLE "authentication" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"module" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT false NOT NULL,
"displayName" varchar(255) DEFAULT '' NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"registration" boolean DEFAULT false NOT NULL,
"allowedEmailRegex" varchar(255) DEFAULT '' NOT NULL,
"autoEnrollGroups" uuid[] DEFAULT '{}'
);
--> statement-breakpoint
CREATE TABLE "groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"permissions" jsonb NOT NULL,
"rules" jsonb NOT NULL,
"redirectOnLogin" varchar(255) DEFAULT '' NOT NULL,
"redirectOnFirstLogin" varchar(255) DEFAULT '' NOT NULL,
"redirectOnLogout" varchar(255) DEFAULT '' NOT NULL,
"isSystem" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "settings" (
"key" varchar(255) PRIMARY KEY NOT NULL,
"value" jsonb DEFAULT '{}'::jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"hostname" varchar(255) NOT NULL,
"isEnabled" boolean DEFAULT false NOT NULL,
"config" jsonb NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "sites_hostname_unique" UNIQUE("hostname")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"auth" jsonb DEFAULT '{}'::jsonb NOT NULL,
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
"passkeys" jsonb DEFAULT '{}'::jsonb NOT NULL,
"prefs" jsonb DEFAULT '{}'::jsonb NOT NULL,
"hasAvatar" boolean DEFAULT false NOT NULL,
"isActive" boolean DEFAULT false NOT NULL,
"isSystem" boolean DEFAULT false NOT NULL,
"isVerified" boolean DEFAULT false NOT NULL,
"lastLoginAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE INDEX "lastLoginAt_idx" ON "users" USING btree ("lastLoginAt");

@ -0,0 +1,18 @@
{
"id": "061e8c84-e05e-40b0-a074-7a56bd794fc7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {},
"enums": {},
"schemas": {},
"views": {},
"sequences": {},
"roles": {},
"policies": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

@ -0,0 +1,379 @@
{
"id": "8e212503-f07f-43d5-8e3d-9a3b9869b3cb",
"prevId": "061e8c84-e05e-40b0-a074-7a56bd794fc7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.authentication": {
"name": "authentication",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"module": {
"name": "module",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"isEnabled": {
"name": "isEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"displayName": {
"name": "displayName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"registration": {
"name": "registration",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"allowedEmailRegex": {
"name": "allowedEmailRegex",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"autoEnrollGroups": {
"name": "autoEnrollGroups",
"type": "uuid[]",
"primaryKey": false,
"notNull": false,
"default": "'{}'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.groups": {
"name": "groups",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"permissions": {
"name": "permissions",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"rules": {
"name": "rules",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"redirectOnLogin": {
"name": "redirectOnLogin",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"redirectOnFirstLogin": {
"name": "redirectOnFirstLogin",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"redirectOnLogout": {
"name": "redirectOnLogout",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"isSystem": {
"name": "isSystem",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"value": {
"name": "value",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sites": {
"name": "sites",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"hostname": {
"name": "hostname",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"isEnabled": {
"name": "isEnabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sites_hostname_unique": {
"name": "sites_hostname_unique",
"nullsNotDistinct": false,
"columns": [
"hostname"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"auth": {
"name": "auth",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"meta": {
"name": "meta",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"passkeys": {
"name": "passkeys",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"prefs": {
"name": "prefs",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"hasAvatar": {
"name": "hasAvatar",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"isSystem": {
"name": "isSystem",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"isVerified": {
"name": "isVerified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"lastLoginAt": {
"name": "lastLoginAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"lastLoginAt_idx": {
"name": "lastLoginAt_idx",
"columns": [
{
"expression": "lastLoginAt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1768857200779,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1769316465987,
"tag": "0001_main",
"breakpoints": true
}
]
}

@ -0,0 +1,62 @@
import { boolean, index, jsonb, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'
// AUTHENTICATION ----------------------
export const authenticationTable = pgTable('authentication', {
id: uuid().primaryKey().defaultRandom(),
module: varchar({ length: 255 }).notNull(),
isEnabled: boolean().notNull().default(false),
displayName: varchar({ length: 255 }).notNull().default(''),
config: jsonb().notNull().default({}),
registration: boolean().notNull().default(false),
allowedEmailRegex: varchar({ length: 255 }).notNull().default(''),
autoEnrollGroups: uuid().array().default([])
})
// GROUPS ------------------------------
export const groupsTable = pgTable('groups', {
id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 255 }).notNull(),
permissions: jsonb().notNull(),
rules: jsonb().notNull(),
redirectOnLogin: varchar({ length: 255 }).notNull().default(''),
redirectOnFirstLogin: varchar({ length: 255 }).notNull().default(''),
redirectOnLogout: varchar({ length: 255 }).notNull().default(''),
isSystem: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow()
})
// SETTINGS ----------------------------
export const settingsTable = pgTable('settings', {
key: varchar({ length: 255 }).notNull().primaryKey(),
value: jsonb().notNull().default({})
})
// SITES -------------------------------
export const sitesTable = pgTable('sites', {
id: uuid().primaryKey().defaultRandom(),
hostname: varchar({ length: 255 }).notNull().unique(),
isEnabled: boolean().notNull().default(false),
config: jsonb().notNull(),
createdAt: timestamp().notNull().defaultNow()
})
// USERS -------------------------------
export const usersTable = pgTable('users', {
id: uuid().primaryKey().defaultRandom(),
email: varchar({ length: 255 }).notNull().unique(),
name: varchar({ length: 255 }).notNull(),
auth: jsonb().notNull().default({}),
meta: jsonb().notNull().default({}),
passkeys: jsonb().notNull().default({}),
prefs: jsonb().notNull().default({}),
hasAvatar: boolean().notNull().default(false),
isActive: boolean().notNull().default(false),
isSystem: boolean().notNull().default(false),
isVerified: boolean().notNull().default(false),
lastLoginAt: timestamp(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow()
}, (table) => [
index('lastLoginAt_idx').on(table.lastLoginAt)
])

@ -0,0 +1,19 @@
import neostandard from 'neostandard'
export default neostandard({
globals: {
document: false,
navigator: false,
window: false,
WIKI: true
},
ignores: [
'**/node_modules/**',
'**/*.min.js',
'coverage/**',
'repo/**',
'data/**',
'logs/**',
'locales/**'
]
})

@ -0,0 +1,112 @@
import { isNil, isPlainObject, set, startCase, transform } from 'lodash-es'
import crypto from 'node:crypto'
/* eslint-disable promise/param-names */
export function createDeferred () {
let result, resolve, reject
return {
resolve: function (value) {
if (resolve) {
resolve(value)
} else {
result = result || new Promise(function (r) { r(value) })
}
},
reject: function (reason) {
if (reject) {
reject(reason)
} else {
result = result || new Promise(function (x, j) { j(reason) })
}
},
promise: new Promise(function (r, j) {
if (result) {
r(result)
} else {
resolve = r
reject = j
}
})
}
}
/**
* Decode a tree path
*
* @param {string} str String to decode
* @returns Decoded tree path
*/
export function decodeTreePath (str) {
return str?.replaceAll('.', '/')
}
/**
* Encode a tree path
*
* @param {string} str String to encode
* @returns Encoded tree path
*/
export function encodeTreePath (str) {
return str?.toLowerCase()?.replaceAll('/', '.') || ''
}
/**
* Generate SHA-1 Hash of a string
*
* @param {string} str String to hash
* @returns Hashed string
*/
export function generateHash (str) {
return crypto.createHash('sha1').update(str).digest('hex')
}
/**
* Get default value of type
*
* @param {any} type primitive type name
* @returns Default value
*/
export function getTypeDefaultValue (type) {
switch (type.toLowerCase()) {
case 'string':
return ''
case 'number':
return 0
case 'boolean':
return false
}
}
export function parseModuleProps (props) {
return transform(props, (result, value, key) => {
let defaultValue = ''
if (isPlainObject(value)) {
defaultValue = !isNil(value.default) ? value.default : getTypeDefaultValue(value.type)
} else {
defaultValue = getTypeDefaultValue(value)
}
set(result, key, {
default: defaultValue,
type: (value.type || value).toLowerCase(),
title: value.title || startCase(key),
hint: value.hint || '',
enum: value.enum || false,
enumDisplay: value.enumDisplay || 'select',
multiline: value.multiline || false,
sensitive: value.sensitive || false,
icon: value.icon || 'rename',
order: value.order || 100,
if: value.if ?? []
})
return result
}, {})
}
export function getDictNameFromLocale (locale) {
const loc = locale.length > 2 ? locale.substring(0, 2) : locale
if (loc in WIKI.config.search.dictOverrides) {
return WIKI.config.search.dictOverrides[loc]
} else {
return WIKI.data.tsDictMappings[loc] ?? 'simple'
}
}

@ -0,0 +1,27 @@
import { replace } from 'lodash-es'
const isoDurationReg = /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/
export default {
/**
* Parse configuration value for environment vars
*
* Replaces `$(ENV_VAR_NAME)` with value of `ENV_VAR_NAME` environment variable.
*
* Also supports defaults by if provided as `$(ENV_VAR_NAME:default)`
*
* @param {any} cfg Configuration value
* @returns Parse configuration value
*/
parseConfigValue (cfg) {
return replace(
cfg,
/\$\(([A-Z0-9_]+)(?::(.+))?\)/g,
(fm, m, d) => { return process.env[m] || d }
)
},
isValidDurationString (val) {
return isoDurationReg.test(val)
}
}

@ -0,0 +1,391 @@
// ===========================================
// Wiki.js Server
// Licensed under AGPLv3
// ===========================================
import { existsSync } from 'node:fs'
import path from 'node:path'
import { DateTime } from 'luxon'
import semver from 'semver'
import { customAlphabet } from 'nanoid'
import { padEnd } from 'lodash-es'
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 { Authenticator } from '@fastify/passport'
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 fastifyView from '@fastify/view'
import gracefulServer from '@gquittet/graceful-server'
import ajvFormats from 'ajv-formats'
import pug from 'pug'
import configSvc from './core/config.mjs'
import dbManager from './core/db.mjs'
import logger from './core/logger.mjs'
const nanoid = customAlphabet('1234567890abcdef', 10)
if (!semver.satisfies(process.version, '>=24')) {
console.error('ERROR: Node.js 24.x or later required!')
process.exit(1)
}
if (existsSync('./package.json')) {
console.error('ERROR: Must run server from the parent directory!')
process.exit(1)
}
const WIKI = {
IS_DEBUG: process.env.NODE_ENV === 'development',
ROOTPATH: process.cwd(),
INSTANCE_ID: nanoid(10),
SERVERPATH: path.join(process.cwd(), 'backend'),
configSvc,
sites: {},
sitesMappings: {},
startedAt: DateTime.utc(),
storage: {
defs: [],
modules: []
}
}
global.WIKI = WIKI
if (WIKI.IS_DEBUG) {
process.on('warning', (warning) => {
console.log(warning.stack)
})
}
await WIKI.configSvc.init()
// ----------------------------------------
// Init Logger
// ----------------------------------------
WIKI.logger = logger.init()
// ----------------------------------------
// Init Server
// ----------------------------------------
WIKI.logger.info('=======================================')
WIKI.logger.info(`= Wiki.js ${padEnd(WIKI.version + ' ', 29, '=')}`)
WIKI.logger.info('=======================================')
WIKI.logger.info('Initializing...')
WIKI.logger.info(`Running node.js ${process.version} [ OK ]`)
WIKI.dbManager = (await import('./core/db.mjs')).default
WIKI.db = await dbManager.init()
WIKI.models = (await import('./models/index.mjs')).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) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {
WIKI.logger.error(err)
}
process.exit(1)
}
// ----------------------------------------
// 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: {
plugins: [
[ajvFormats, {}]
]
},
bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb
logger: true,
trustProxy: WIKI.config.security.securityTrustProxy ?? 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 })
// ----------------------------------------
// Handle graceful server shutdown
// ----------------------------------------
WIKI.server.on(gracefulServer.SHUTTING_DOWN, () => {
WIKI.logger.info('Shutting down HTTP Server... [ PENDING ]')
})
WIKI.server.on(gracefulServer.SHUTDOWN, (err) => {
WIKI.logger.info(`HTTP Server has exited: [ STOPPED ] (${err.message})`)
})
// ----------------------------------------
// Security
// ----------------------------------------
app.register(fastifyHelmet, {
contentSecurityPolicy: false, // TODO: Make it configurable
strictTransportSecurity: WIKI.config.security.securityHSTS
? {
maxAge: WIKI.config.security.securityHSTSDuration,
includeSubDomains: true
}
: false
})
app.register(fastifyCors, {
origin: '*', // TODO: Make it configurable
methods: ['GET', 'HEAD', 'POST', 'OPTIONS']
})
// ----------------------------------------
// 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: '7d'
})
// ----------------------------------------
// Passport Authentication
// ----------------------------------------
app.register(fastifyCookie, {
secret: WIKI.config.auth.secret,
hook: 'onRequest'
})
app.register(fastifySession, {
secret: WIKI.config.auth.secret,
saveUninitialized: false,
store: {
get (sessionId, clb) {
},
set (sessionId, clb) {
},
destroy (sessionId, clb) {
}
}
})
const fastifyPassport = new Authenticator()
app.register(fastifyPassport.initialize())
app.register(fastifyPassport.secureSession())
// app.use(WIKI.auth.passport.initialize())
// app.use(WIKI.auth.authenticate)
// ----------------------------------------
// API Routes
// ----------------------------------------
app.register(fastifySwagger, {
hideUntagged: true,
openapi: {
openapi: '3.0.0',
info: {
title: 'Wiki.js API',
version: WIKI.config.version
},
components: {
securitySchemes: {
apiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key'
},
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
}
},
security: [
{ apiKeyAuth: [] },
{ bearerAuth: [] }
]
}
})
app.register(fastifySwaggerUi, {
routePrefix: '/_swagger'
})
// ----------------------------------------
// SEO
// ----------------------------------------
app.addHook('onRequest', (req, reply, done) => {
console.log(req.raw.url)
const [urlPath, urlQuery] = req.raw.url.split('?')
if (urlPath.length > 1 && urlPath.endsWith('/')) {
const newPath = urlPath.slice(0, -1)
reply.redirect(urlQuery ? `${newPath}?${urlQuery}` : newPath, 301)
return
}
done()
})
// ----------------------------------------
// View Engine Setup
// ----------------------------------------
app.register(fastifyView, {
engine: {
pug
}
})
app.register(fastifyFormBody, {
bodyLimit: 1048576 // 1mb
})
// ----------------------------------------
// View accessible data
// ----------------------------------------
// app.locals.analyticsCode = {}
// app.locals.basedir = WIKI.ROOTPATH
// app.locals.config = WIKI.config
// app.locals.pageMeta = {
// title: '',
// description: WIKI.config.description,
// image: '',
// url: '/'
// }
// app.locals.devMode = WIKI.devMode
// ----------------------------------------
// 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.mjs'), { prefix: '/_api' })
// ----------------------------------------
// Error handling
// ----------------------------------------
app.setErrorHandler((error, req, reply) => {
if (error instanceof fastify.errorCodes.FST_ERR_BAD_STATUS_CODE) {
WIKI.logger.warn(error)
reply.status(500).send({ ok: false })
} 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) {
app.log.error(err)
process.exit(1)
}
}
// ----------------------------------------
// Register exit handler
// ----------------------------------------
// process.on('SIGINT', () => {
// WIKI.kernel.shutdown()
// })
// process.on('message', (msg) => {
// if (msg === 'shutdown') {
// WIKI.kernel.shutdown()
// }
// })
// ----------------------------------------
// Start HTTP Server
// ----------------------------------------
initHTTPServer()

@ -0,0 +1,55 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import yaml from 'js-yaml'
import { parseModuleProps } from '../helpers/common.mjs'
import { authenticationTable } from '../db/schema.mjs'
/**
* Authentication model
*/
class Authentication {
async getStrategy (module) {
return WIKI.db.authentication.query().findOne({ module })
}
async getStrategies ({ enabledOnly = false } = {}) {
return WIKI.db.authentication.query().where(enabledOnly ? { isEnabled: true } : {})
}
async refreshStrategiesFromDisk () {
try {
// -> Fetch definitions from disk
const authenticationDirs = await fs.readdir(path.join(WIKI.SERVERPATH, 'modules/authentication'))
WIKI.data.authentication = []
for (const dir of authenticationDirs) {
const def = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'), 'utf8')
const defParsed = yaml.load(def)
if (!defParsed.isAvailable) { continue }
defParsed.key = dir
defParsed.props = parseModuleProps(defParsed.props)
WIKI.data.authentication.push(defParsed)
WIKI.logger.debug(`Loaded authentication module definition ${dir}: [ OK ]`)
}
WIKI.logger.info(`Loaded ${WIKI.data.authentication.length} authentication module definitions: [ OK ]`)
} catch (err) {
WIKI.logger.error('Failed to scan or load authentication providers: [ FAILED ]')
WIKI.logger.error(err)
}
}
async init (ids) {
await WIKI.db.insert(authenticationTable).values({
id: ids.authModuleId,
module: 'local',
isEnabled: true,
displayName: 'Local Authentication',
config: {
emailValidation: true,
enforceTfa: false
}
})
}
}
export const authentication = new Authentication()

@ -0,0 +1,59 @@
import { v4 as uuid } from 'uuid'
import { groupsTable } from '../db/schema.mjs'
/**
* Groups model
*/
class Groups {
async init (ids) {
WIKI.logger.info('Inserting default groups...')
await WIKI.db.insert(groupsTable).values([
{
id: ids.groupAdminId,
name: 'Administrators',
permissions: ['manage:system'],
rules: [],
isSystem: true
},
{
id: ids.groupUserId,
name: 'Users',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'ALLOW',
path: '',
locales: [],
sites: []
}
],
isSystem: true
},
{
id: ids.groupGuestId,
name: 'Guests',
permissions: ['read:pages', 'read:assets', 'read:comments'],
rules: [
{
id: uuid(),
name: 'Default Rule',
roles: ['read:pages', 'read:assets', 'read:comments'],
match: 'START',
mode: 'DENY',
path: '',
locales: [],
sites: []
}
],
isSystem: true
}
])
}
}
export const groups = new Groups()

@ -0,0 +1,11 @@
import { authentication } from './authentication.mjs'
import { groups } from './groups.mjs'
import { settings } from './settings.mjs'
import { sites } from './sites.mjs'
export default {
authentication,
groups,
settings,
sites
}

@ -0,0 +1,182 @@
import { settingsTable } from '../db/schema.mjs'
import { has, reduce, set } from 'lodash-es'
import { pem2jwk } from 'pem-jwk'
import crypto from 'node:crypto'
/**
* Settings model
*/
class Settings {
/**
* Fetch settings from DB
* @returns {Promise<Object>} Settings
*/
async getConfig () {
const settings = await WIKI.db.select().from(settingsTable)
if (settings.length > 0) {
return reduce(settings, (res, val, key) => {
set(res, val.key, (has(val.value, 'v')) ? val.value.v : val.value)
return res
}, {})
} else {
return false
}
}
/**
* Apply settings to DB
* @param {string} key Setting key
* @param {Object} value Setting value object
*/
async updateConfig (key, value) {
await WIKI.models.insert(settingsTable)
.values({ key, value })
.onConflictDoUpdate({ target: settingsTable.key, set: { value } })
}
/**
* Initialize settings table
* @param {Object} ids Generated IDs
*/
async init (ids) {
WIKI.logger.info('Generating certificates...')
const secret = crypto.randomBytes(32).toString('hex')
const certs = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'pkcs1',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase: secret
}
})
WIKI.logger.info('Inserting default settings...')
await WIKI.db.insert(settingsTable).values([
{
key: 'api',
value: {
isEnabled: false
}
},
{
key: 'auth',
value: {
audience: 'urn:wiki.js',
tokenExpiration: '30m',
tokenRenewal: '14d',
certs: {
jwk: pem2jwk(certs.publicKey),
public: certs.publicKey,
private: certs.privateKey
},
secret,
rootAdminGroupId: ids.groupAdminId,
rootAdminUserId: ids.userAdminId,
guestUserId: ids.userGuestId
}
},
{
key: 'flags',
value: {
experimental: false,
authDebug: false,
sqlLog: false
}
},
{
key: 'icons',
value: {
fa: {
isActive: true,
config: {
version: 6,
license: 'free',
token: ''
}
},
la: {
isActive: true
}
}
},
{
key: 'mail',
value: {
senderName: '',
senderEmail: '',
defaultBaseURL: 'https://wiki.example.com',
host: '',
port: 465,
name: '',
secure: true,
verifySSL: true,
user: '',
pass: '',
useDKIM: false,
dkimDomainName: '',
dkimKeySelector: '',
dkimPrivateKey: ''
}
},
{
key: 'metrics',
value: {
isEnabled: false
}
},
{
key: 'search',
value: {
termHighlighting: true,
dictOverrides: {}
}
},
{
key: 'security',
value: {
corsConfig: '',
corsMode: 'OFF',
cspDirectives: '',
disallowFloc: true,
disallowIframe: true,
disallowOpenRedirect: true,
enforceCsp: false,
enforceHsts: false,
enforceSameOriginReferrerPolicy: true,
forceAssetDownload: true,
hstsDuration: 0,
trustProxy: false,
authJwtAudience: 'urn:wiki.js',
authJwtExpiration: '30m',
authJwtRenewablePeriod: '14d',
uploadMaxFileSize: 10485760,
uploadMaxFiles: 20,
uploadScanSVG: true
}
},
{
key: 'update',
value: {
lastCheckedAt: null,
version: WIKI.version,
versionDate: WIKI.releaseDate
}
},
{
key: 'userDefaults',
value: {
timezone: 'America/New_York',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h'
}
}
])
}
}
export const settings = new Settings()

@ -0,0 +1,277 @@
import { defaultsDeep, keyBy } from 'lodash-es'
import { sitesTable } from '../db/schema.mjs'
import { eq } from 'drizzle-orm'
/**
* Sites model
*/
class Sites {
async getSiteByHostname ({ hostname, forceReload = false }) {
if (forceReload) {
await WIKI.db.sites.reloadCache()
}
const siteId = WIKI.sitesMappings[hostname] || WIKI.sitesMappings['*']
if (siteId) {
return WIKI.sites[siteId]
}
return null
}
async reloadCache () {
WIKI.logger.info('Reloading site configurations...')
const sites = await WIKI.db.sites.query().orderBy('id')
WIKI.sites = keyBy(sites, 'id')
WIKI.sitesMappings = {}
for (const site of sites) {
WIKI.sitesMappings[site.hostname] = site.id
}
WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`)
}
async createSite (hostname, config) {
const newSite = await WIKI.db.sites.query().insertAndFetch({
hostname,
isEnabled: true,
config: defaultsDeep(config, {
title: 'My Wiki Site',
description: '',
company: '',
contentLicense: '',
footerExtra: '',
pageExtensions: ['md', 'html', 'txt'],
pageCasing: true,
discoverable: false,
defaults: {
tocDepth: {
min: 1,
max: 2
}
},
features: {
browse: true,
ratings: false,
ratingsMode: 'off',
comments: false,
contributions: false,
profile: true,
search: true
},
logoUrl: '',
logoText: true,
sitemap: true,
robots: {
index: true,
follow: true
},
locales: {
primary: 'en',
active: ['en']
},
assets: {
logo: false,
logoExt: 'svg',
favicon: false,
faviconExt: 'svg',
loginBg: false
},
theme: {
dark: false,
codeBlocksTheme: 'github-dark',
colorPrimary: '#1976D2',
colorSecondary: '#02C39A',
colorAccent: '#FF9800',
colorHeader: '#000000',
colorSidebar: '#1976D2',
injectCSS: '',
injectHead: '',
injectBody: '',
contentWidth: 'full',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true,
baseFont: 'roboto',
contentFont: 'roboto'
},
editors: {
asciidoc: {
isActive: true,
config: {}
},
markdown: {
isActive: true,
config: {
allowHTML: true,
kroki: false,
krokiServerUrl: 'https://kroki.io',
latexEngine: 'katex',
lineBreaks: true,
linkify: true,
multimdTable: true,
plantuml: false,
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
quotes: 'english',
tabWidth: 2,
typographer: false,
underline: true
}
},
wysiwyg: {
isActive: true,
config: {}
}
},
uploads: {
conflictBehavior: 'overwrite',
normalizeFilename: true
}
})
})
WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`)
await WIKI.db.navigation.query().insert({
id: newSite.id,
siteId: newSite.id,
items: []
})
WIKI.logger.debug(`Creating new DB storage for site ${newSite.id}`)
await WIKI.db.storage.query().insert({
module: 'db',
siteId: newSite.id,
isEnabled: true,
contentTypes: {
activeTypes: ['pages', 'images', 'documents', 'others', 'large'],
largeThreshold: '5MB'
},
assetDelivery: {
streaming: true,
directAccess: false
},
state: {
current: 'ok'
}
})
return newSite
}
async updateSite (id, patch) {
return WIKI.db.sites.query().findById(id).patch(patch)
}
async deleteSite (id) {
// await WIKI.db.storage.query().delete().where('siteId', id)
const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id))
return Boolean(deletedResult.rowCount > 0)
}
async init (ids) {
WIKI.logger.info('Inserting default site...')
await WIKI.db.insert(sitesTable).values({
id: ids.siteId,
hostname: '*',
isEnabled: true,
config: {
title: 'Default Site',
description: '',
company: '',
contentLicense: '',
footerExtra: '',
pageExtensions: ['md', 'html', 'txt'],
pageCasing: true,
discoverable: false,
defaults: {
tocDepth: {
min: 1,
max: 2
}
},
features: {
browse: true,
ratings: false,
ratingsMode: 'off',
comments: false,
contributions: false,
profile: true,
reasonForChange: 'required',
search: true
},
logoText: true,
sitemap: true,
robots: {
index: true,
follow: true
},
authStrategies: [{ id: ids.authModuleId, order: 0, isVisible: true }],
locales: {
primary: 'en',
active: ['en']
},
assets: {
logo: false,
logoExt: 'svg',
favicon: false,
faviconExt: 'svg',
loginBg: false
},
editors: {
asciidoc: {
isActive: true,
config: {}
},
markdown: {
isActive: true,
config: {
allowHTML: true,
kroki: false,
krokiServerUrl: 'https://kroki.io',
latexEngine: 'katex',
lineBreaks: true,
linkify: true,
multimdTable: true,
plantuml: false,
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
quotes: 'english',
tabWidth: 2,
typographer: false,
underline: true
}
},
wysiwyg: {
isActive: true,
config: {}
}
},
theme: {
dark: false,
codeBlocksTheme: 'github-dark',
colorPrimary: '#1976D2',
colorSecondary: '#02C39A',
colorAccent: '#FF9800',
colorHeader: '#000000',
colorSidebar: '#1976D2',
injectCSS: '',
injectHead: '',
injectBody: '',
contentWidth: 'full',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true,
baseFont: 'roboto',
contentFont: 'roboto'
},
uploads: {
conflictBehavior: 'overwrite',
normalizeFilename: true
}
}
})
}
}
export const sites = new Sites()

File diff suppressed because it is too large Load Diff

@ -0,0 +1,83 @@
{
"name": "wiki-backend",
"version": "3.0.0",
"releaseDate": "2026-01-01T01:01:01.000Z",
"description": "The most powerful and extensible open source Wiki software",
"main": "index.mjs",
"type": "module",
"private": true,
"dev": true,
"scripts": {
"start": "cd .. && node backend",
"dev": "cd .. && nodemon backend --watch backend --ext mjs,js,json",
"ncu": "ncu -i",
"ncu-u": "ncu -u",
"db-generate": "drizzle-kit generate --dialect=postgresql --schema=./db/schema.mjs --out=./db/migrations --name=main"
},
"repository": {
"type": "git",
"url": "git+https://github.com/requarks/wiki.git"
},
"keywords": [
"wiki",
"wikis",
"docs",
"documentation",
"markdown",
"guides",
"knowledge base"
],
"author": "Nicolas Giard",
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/requarks/wiki/issues"
},
"homepage": "https://github.com/requarks/wiki#readme",
"engines": {
"node": ">=24.0"
},
"dependencies": {
"@fastify/compress": "8.3.1",
"@fastify/cookie": "11.0.2",
"@fastify/cors": "11.2.0",
"@fastify/formbody": "8.0.2",
"@fastify/helmet": "13.0.2",
"@fastify/passport": "3.0.2",
"@fastify/sensible": "6.0.4",
"@fastify/session": "11.1.1",
"@fastify/static": "9.0.0",
"@fastify/swagger": "9.6.1",
"@fastify/swagger-ui": "5.2.4",
"@fastify/view": "11.1.1",
"@gquittet/graceful-server": "6.0.2",
"ajv-formats": "3.0.1",
"bcryptjs": "3.0.3",
"drizzle-orm": "0.45.1",
"fastify": "5.7.1",
"fastify-favicon": "5.0.0",
"lodash-es": "4.17.22",
"luxon": "3.7.2",
"nanoid": "5.1.6",
"pem-jwk": "2.0.0",
"pg": "8.16.3",
"pg-pubsub": "0.8.1",
"pug": "3.0.3",
"semver": "7.7.3",
"uuid": "13.0.0"
},
"devDependencies": {
"drizzle-kit": "0.31.8",
"eslint": "9.32.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "7.2.1",
"neostandard": "0.12.2",
"nodemon": "3.1.10",
"npm-check-updates": "19.3.1"
},
"collective": {
"type": "opencollective",
"url": "https://opencollective.com/wikijs",
"logo": "https://opencollective.com/opencollective/logo.txt"
}
}
Loading…
Cancel
Save