391 lines
12 KiB
JavaScript
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 }
|