import { randomUUID, timingSafeEqual } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' import { JobQueue } from './jobs.js' import { logger } from './logger.js' import { closeBrowser } from './pdf-renderer.js' import { WikiClient } from './wiki-client.js' import { AppError, comparePagePaths, isPlainObject, normalizeApiKey, normalizeBaseUrl, toPositiveInt, } from './utils.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') const PORT = toPositiveInt(process.env.PORT, 8080, { min: 1, max: 65535 }) const HOST = process.env.HOST || '0.0.0.0' const MAX_PAGES = toPositiveInt(process.env.MAX_PAGES, 500, { min: 1, max: 5000 }) const MAX_QUEUE = toPositiveInt(process.env.MAX_QUEUE, 5, { min: 1, max: 100 }) const JOB_TTL_MS = toPositiveInt(process.env.JOB_TTL_MINUTES, 120, { min: 1, max: 1440 }) * 60 * 1000 const EXPORT_CONCURRENCY = toPositiveInt(process.env.EXPORT_CONCURRENCY, 1, { min: 1, max: 3 }) const PAGE_FETCH_CONCURRENCY = toPositiveInt(process.env.PAGE_FETCH_CONCURRENCY, 4, { min: 1, max: 12 }) const AUTH_USER = process.env.AUTH_USER || '' const AUTH_PASSWORD = process.env.AUTH_PASSWORD || '' // The wiki connection is configured once on the server, never sent by the // browser and never included in job status responses or logs. const WIKI_API_KEY = normalizeApiKey(process.env.WIKI_API_KEY) const WIKI_URL_INPUT = String(process.env.WIKI_URL || '').trim() let WIKI_URL = null let WIKI_URL_ERROR = null if (WIKI_URL_INPUT) { try { WIKI_URL = normalizeBaseUrl(WIKI_URL_INPUT) } catch (error) { WIKI_URL_ERROR = error.message } } function requireWikiConfig() { if (!WIKI_URL) { throw new AppError( WIKI_URL_ERROR ? `The wiki URL from WIKI_URL is invalid: ${WIKI_URL_ERROR}` : 'The Wiki.js URL is not configured on the server. Set WIKI_URL and restart the container.', { status: 503, code: 'WIKI_URL_MISSING' }, ) } if (!WIKI_API_KEY) { throw new AppError( 'The Wiki.js API key is not configured on the server. Set WIKI_API_KEY and restart the container.', { status: 503, code: 'WIKI_API_KEY_MISSING' }, ) } } const app = express() app.disable('x-powered-by') const jobs = new JobQueue({ concurrency: EXPORT_CONCURRENCY, maxQueue: MAX_QUEUE, ttlMs: JOB_TTL_MS, }) app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('Referrer-Policy', 'no-referrer') res.setHeader('X-Frame-Options', 'DENY') res.setHeader( 'Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'", ) next() }) app.use((req, res, next) => { const requestId = randomUUID() const startedAt = Date.now() req.requestId = requestId req.requestStartedAt = startedAt res.setHeader('X-Request-Id', requestId) res.on('finish', () => { if (res.locals.errorLogged) return const fields = { requestId, method: req.method, path: req.path, status: res.statusCode, durationMs: Date.now() - startedAt, } // The UI polls the job status every second. Keep those lines out of the // info log so a single export does not flood the output. const isJobStatusPoll = req.method === 'GET' && /^\/api\/exports\/[0-9a-f-]+$/i.test(req.path) if (res.statusCode >= 500) { logger.error('request failed', fields) } else if (res.statusCode >= 400) { logger.warn('request rejected', fields) } else if (!isJobStatusPoll && (req.path === '/' || req.path.startsWith('/api/'))) { logger.info('request', fields) } else { logger.debug('request', fields) } }) next() }) function safeEqual(left, right) { const leftBuffer = Buffer.from(String(left)) const rightBuffer = Buffer.from(String(right)) if (leftBuffer.length !== rightBuffer.length) return false return timingSafeEqual(leftBuffer, rightBuffer) } function basicAuth(req, res, next) { if (!AUTH_USER || !AUTH_PASSWORD) return next() const header = req.headers.authorization || '' if (header.startsWith('Basic ')) { const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8') const separator = decoded.indexOf(':') const user = separator >= 0 ? decoded.slice(0, separator) : decoded const password = separator >= 0 ? decoded.slice(separator + 1) : '' if (safeEqual(user, AUTH_USER) && safeEqual(password, AUTH_PASSWORD)) { return next() } } res.setHeader('WWW-Authenticate', 'Basic realm="wiki-print", charset="UTF-8"') return res.status(401).send('Authentication required.') } app.get('/healthz', (req, res) => { res.json({ status: 'ok' }) }) app.use(basicAuth) app.use(express.json({ limit: '256kb' })) app.get('/api/config', (req, res) => { res.setHeader('Cache-Control', 'no-store') res.json({ wikiUrl: WIKI_URL?.href || null, wikiUrlConfigured: Boolean(WIKI_URL), maxPages: MAX_PAGES, maxQueue: MAX_QUEUE, authEnabled: Boolean(AUTH_USER && AUTH_PASSWORD), apiKeyConfigured: Boolean(WIKI_API_KEY), }) }) app.get('/api/pages', async (req, res, next) => { const startedAt = Date.now() try { requireWikiConfig() const client = new WikiClient({ baseUrl: WIKI_URL, apiKey: WIKI_API_KEY, logger: logger.child({ requestId: req.requestId }), }) const [listedPages, localization] = await Promise.all([ client.listPages(), client.getLocalization(), ]) const pages = listedPages .map((page) => ({ path: String(page.path || ''), locale: String(page.locale || ''), title: page.title || '', description: page.description || '', isPublished: page.isPublished !== false, })) .filter((page) => page.path && page.locale) .sort( (a, b) => a.locale.localeCompare(b.locale, 'en') || comparePagePaths(a.path, b.path), ) logger.info('page list loaded', { requestId: req.requestId, pageCount: pages.length, publishedCount: pages.filter((page) => page.isPublished).length, locales: [...new Set(pages.map((page) => page.locale))], defaultLocale: localization?.locale || null, durationMs: Date.now() - startedAt, }) res.setHeader('Cache-Control', 'no-store') res.json({ baseUrl: WIKI_URL.href, defaultLocale: localization?.locale || null, namespacing: localization?.namespacing ?? null, pages, }) } catch (error) { next(error) } }) app.post('/api/exports', (req, res, next) => { try { const body = req.body if (!isPlainObject(body)) { throw new AppError('Expected a JSON request body.') } requireWikiConfig() const startPage = String(body.startPage ?? '').trim() if (!startPage) throw new AppError('The starting page is required.') if (startPage.length > 2048) throw new AppError('The starting page is too long.') const locale = body.locale ? String(body.locale).trim().toLowerCase() : null if (locale && !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/.test(locale)) { throw new AppError('The locale is not a valid language tag (for example "en" or "de-ch").') } const maxPages = toPositiveInt(body.maxPages, MAX_PAGES, { min: 1, max: 5000 }) const job = jobs.enqueue({ baseUrl: WIKI_URL.href, apiKey: WIKI_API_KEY, startPage, locale, includeUnpublished: body.includeUnpublished === true, includeToc: body.includeToc !== false, inlineImages: body.inlineImages !== false, maxPages, concurrency: PAGE_FETCH_CONCURRENCY, }) logger.info('export requested', { requestId: req.requestId, jobId: job.id, startPage, locale, maxPages, includeUnpublished: body.includeUnpublished === true, includeToc: body.includeToc !== false, inlineImages: body.inlineImages !== false, }) res.setHeader('Cache-Control', 'no-store') res.status(202).json({ job }) } catch (error) { next(error) } }) app.get('/api/exports/:id', (req, res) => { const job = jobs.get(req.params.id) if (!job) { return res.status(404).json({ error: 'Export not found.' }) } res.setHeader('Cache-Control', 'no-store') return res.json({ job }) }) app.get('/api/exports/:id/pdf', (req, res) => { const job = jobs.getInternal(req.params.id) if (!job) { return res.status(404).json({ error: 'Export not found.' }) } if (job.status !== 'done' || !job.filePath) { return res.status(409).json({ error: 'The PDF is not ready yet.' }) } res.setHeader('Cache-Control', 'no-store') res.setHeader('Content-Type', 'application/pdf') return res.download(job.filePath, job.result?.filename || 'wiki-export.pdf', (error) => { if (error && !res.headersSent) { res.status(404).json({ error: 'The PDF file is no longer available.' }) } }) }) // The UI files have no content hashes in their names, so caching them for an // hour means a browser can keep running an old app.js after a rebuild (for // example one that did not load the page list). Always revalidate the HTML, // JavaScript and CSS; static images may still be cached. app.use( express.static(publicDir, { index: 'index.html', etag: true, lastModified: true, setHeaders(res, filePath) { if (/\.(?:html|js|css)$/i.test(filePath)) { res.setHeader('Cache-Control', 'no-cache') } else { res.setHeader('Cache-Control', 'public, max-age=3600') } }, }), ) app.use('/api', (req, res) => { res.status(404).json({ error: 'Not found.' }) }) app.use((req, res) => { res.status(404).send('Not found.') }) app.use((error, req, res, next) => { if (res.headersSent) return next(error) const status = Number.isInteger(error.status) ? error.status : 500 res.locals.errorLogged = true const fields = { requestId: req.requestId, method: req.method, path: req.path, status, durationMs: Date.now() - (req.requestStartedAt || Date.now()), code: error.code || (status >= 500 ? 'INTERNAL_ERROR' : 'BAD_REQUEST'), error, } if (status >= 500) { logger.error('request error', fields) } else { logger.warn('request rejected', { ...fields, error: error.message }) } res.status(status).json({ error: error.message || 'Internal server error.', code: error.code || 'INTERNAL_ERROR', details: error.details, }) }) const server = app.listen(PORT, HOST, () => { logger.info('server started', { host: HOST, port: PORT, maxPages: MAX_PAGES, maxQueue: MAX_QUEUE, exportConcurrency: EXPORT_CONCURRENCY, pageFetchConcurrency: PAGE_FETCH_CONCURRENCY, jobTtlMinutes: JOB_TTL_MS / 60_000, authEnabled: Boolean(AUTH_USER && AUTH_PASSWORD), apiKeyConfigured: Boolean(WIKI_API_KEY), wikiUrlConfigured: Boolean(WIKI_URL), }) if (WIKI_URL) { logger.info('wiki configured', { wikiUrl: WIKI_URL.href }) } else { logger.warn('wiki URL is not configured', { reason: WIKI_URL_ERROR || 'WIKI_URL is not set', hint: 'Set WIKI_URL and restart the container.', }) } if (!WIKI_API_KEY) { logger.warn('wiki API key is not configured', { hint: 'Set WIKI_API_KEY and restart the container.', }) } }) let shuttingDown = false async function shutdown(signal) { if (shuttingDown) return shuttingDown = true logger.info('shutting down', { signal }) const forceTimer = setTimeout(() => process.exit(1), 15_000) forceTimer.unref?.() server.close() await jobs.close().catch(() => {}) await closeBrowser().catch(() => {}) clearTimeout(forceTimer) process.exit(0) } process.on('SIGTERM', () => shutdown('SIGTERM')) process.on('SIGINT', () => shutdown('SIGINT')) process.on('unhandledRejection', (error) => { logger.error('unhandled rejection', { error }) }) export { app, server }