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
The web client has not been built yet. ` +
'The API is available under /api/v1.