Files
wiki-print/src/server.js
T
2026-11-13 11:50:48 +01:00

391 lines
12 KiB
JavaScript

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 }