const express = require('express') const path = require('path') const fs = require('fs') const cors = require('cors') const helmet = require('helmet') const morgan = require('morgan') const cookieParser = require('cookie-parser') require('dotenv').config() const swaggerUi = require('swagger-ui-express') const apiRouter = require('./router/api.router') const wellKnown = require('./router/wellKnown.controller') const brand = require('./config/brand') const createLogger = require('./utils/logger') const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy') const botScore = require('./middleware/botScore') const httpLog = createLogger('http') const errLog = createLogger('error') const app = express() // Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and // req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client // from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and // never a blanket `true` (which would let clients spoof their IP). Must run // before any middleware that reads req.ip. applyTrustProxy(app) // Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before // the bot guard so it logs scanner/junk source IPs too. app.use(trustProxyDebug) // Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and // obvious scanner probes are 404'd immediately without reaching real handlers. app.use(botScore.guard) // Security headers, including a Content-Security-Policy tuned for the built React // SPA. Notes on each non-'self' allowance: // • style-src 'unsafe-inline' — React renders pervasive inline `style={{…}}` // attributes, and CSP style *attributes* cannot be nonce'd; this is required. // Also whitelists the Google Fonts stylesheet host. // • font-src — Google Fonts (Cinzel) serves the font files from gstatic. // • img-src https:/data: — uploaded images are same-origin, but wiki/news bodies // (sanitizeHtml allows over http/https) and BRAND_* logo/hero/favicon may // point at external https images. http images are blocked by mixed-content on // the https site anyway. // • connect-src 'self' — the REST API and SSE streams are same-origin. // • upgrade-insecure-requests is intentionally dropped: TLS is terminated at the // proxy, there are no mixed-content subresources to upgrade, and leaving it on // breaks a local `npm start` served over plain http. // The interactive API docs at /api/docs get their own looser policy below. app.use( helmet({ contentSecurityPolicy: { useDefaults: true, directives: { 'default-src': ["'self'"], 'script-src': ["'self'"], 'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], 'font-src': ["'self'", 'https://fonts.gstatic.com'], 'img-src': ["'self'", 'data:', 'https:'], 'connect-src': ["'self'"], 'frame-ancestors': ["'self'"], 'object-src': ["'none'"], 'base-uri': ["'self'"], 'upgrade-insecure-requests': null, }, }, crossOriginResourcePolicy: { policy: 'cross-origin' }, }), ) // CORS only when a separate client origin is configured (local Vite dev). In // production the SPA is same-origin, so no CORS is needed. if (process.env.CLIENT_ORIGIN) { app.use(cors({ origin: process.env.CLIENT_ORIGIN, credentials: true })) } // Access logs: real client IP (via trust proxy), the authenticated admin (if any), // method, URL, status, response time, and size. Bodies/credentials are never logged. morgan.token('user', (req) => (req.user && req.user.username) || '-') const accessFormat = ':remote-addr :user :method :url :status :response-time ms - :res[content-length] bytes' app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim()) } })) app.use(express.json({ limit: '2mb' })) app.use(cookieParser()) // ── Paths ───────────────────────────────────────────────────────────── const SERVER_ROOT = path.join(__dirname, '..') const REPO_ROOT = path.join(SERVER_ROOT, '..') const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads') const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist') const BRAND_DIR = process.env.BRAND_DIR || path.join(REPO_ROOT, 'brand') fs.mkdirSync(UPLOAD_DIR, { recursive: true }) // Escape user/brand text for safe interpolation into the HTML shell. const htmlEscape = (s) => String(s).replace( /[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]), ) // Template the built index.html with instance branding (title, meta // description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env, // so the prebuilt SPA image serves per-instance metadata without a rebuild. function renderIndexHtml(html) { const title = htmlEscape(brand.name) const desc = htmlEscape(brand.description) const tags = [ ``, ``, '', brand.url ? `` : '', brand.logo ? `` : '', '', ``, ``, brand.favicon ? `` : '', ] .filter(Boolean) .join('\n ') return html .replace(/[\s\S]*?<\/title>/i, `<title>${title}`) .replace(/()/i, `$1${desc}$2`) .replace(/<\/head>/i, ` ${tags}\n `) } // Uploaded images — always served, even during maintenance. Force nosniff so a // stored file is never interpreted as anything other than its declared type // (defense in depth alongside helmet's global X-Content-Type-Options, and in // case that global config is ever changed). app.use( '/uploads', express.static(UPLOAD_DIR, { setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'), }), ) // ── API docs (Swagger UI) ───────────────────────────────────────────── // Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec // is generated from route annotations by `npm run swagger` (server/swagger/). // Loaded lazily and guarded so a missing spec never crashes the server. try { // eslint-disable-next-line global-require const swaggerSpec = require('../swagger/swagger-output.json') app.get('/api/docs.json', (req, res) => { // #swagger.ignore = true res.json(swaggerSpec) }) // swagger-ui-express injects an inline bootstrap script and inline styles, which // the global 'self'-only script-src would block — relax CSP for this route only. const swaggerCsp = helmet.contentSecurityPolicy({ useDefaults: true, directives: { 'script-src': ["'self'", "'unsafe-inline'"], 'style-src': ["'self'", "'unsafe-inline'"], 'img-src': ["'self'", 'data:', 'https:'], 'upgrade-insecure-requests': null, }, }) app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customSiteTitle: `${brand.name} API docs`, swaggerOptions: { persistAuthorization: true }, })) } catch (err) { errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', { message: err.message, }) } // ── API ─────────────────────────────────────────────────────────────── app.get( '/api/health', // #swagger.tags = ['Health'] // #swagger.summary = 'Liveness probe' /* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */ (req, res) => res.json({ status: 'ok' }), ) app.use('/api', apiRouter) app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' })) // ── /.well-known ────────────────────────────────────────────────────── // Android App Links verification file at the web root (M9 follow-up). Mounted // before the SPA catch-all so it returns JSON, not the index shell. 404s unless // the admin has enabled App Links for this shard (docs/android/APP_LINKS.md). app.get('/.well-known/assetlinks.json', wellKnown.assetlinks) // ── Client SPA ──────────────────────────────────────────────────────── // Serve the built React app if present; otherwise show a placeholder so the // server is usable API-only before the frontend phase. // Brand assets (logo/hero/favicon) from a mounted directory, used when BRAND_* // paths point at /brand/*. Optional — the defaults live under the SPA's /assets, // so this only matters for a custom mount. if (fs.existsSync(BRAND_DIR)) { app.use( '/brand', express.static(BRAND_DIR, { setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'), }), ) } if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) { // Serve a branded copy of the index.html shell for every SPA route; assets keep // their own cache-friendly static handler. const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8')) app.use(express.static(CLIENT_DIST, { index: false })) app.get('*', (req, res) => res.type('html').send(indexHtml)) } else { app.get('*', (req, res) => res .type('html') .send( `

${htmlEscape(brand.name)} API

The web client has not been built yet. ` + 'The API is available under /api/v1.

', ), ) } // ── Error handler ───────────────────────────────────────────────────── // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.status || (err.name === 'MulterError' ? 400 : 500) // Log the stack for server faults; client (4xx) errors stay terse. errLog.error( `${req.method} ${req.originalUrl} -> ${status} ${err.message}`, status >= 500 ? { stack: err.stack } : undefined, ) res.status(status).json({ message: err.message || 'Internal Server Error' }) }) module.exports = app