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 cspReport = require('./router/cspReport.controller') const brand = require('./config/brand') const csp = require('./config/csp') const { cspReportLimiter } = require('./middleware/rateLimit') 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. The policies themselves (and the reasoning behind every non-'self' allowance) // live in config/csp.js. The interactive API docs at /api/docs get their own looser // policy below. app.use( helmet({ contentSecurityPolicy: { useDefaults: true, directives: csp.enforced }, crossOriginResourcePolicy: { policy: 'cross-origin' }, }), ) // The tightened policy rides alongside on Content-Security-Policy-Report-Only for one // release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1). // Both headers are served at once on purpose: the live policy keeps protecting users // while anything the tightened version would have broken shows up as a report at // /api/csp-report instead of as a broken page. Reports are same-origin — they // describe attacks on this site and are not handed to a third party. app.use(csp.reportingEndpoints) app.use( helmet.contentSecurityPolicy({ useDefaults: true, reportOnly: true, directives: csp.reportOnly, }), ) // 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(/The web client has not been built yet. ` +
'The API is available under /api/v1.