/** * Express application factory — imported by both server/index.js (production) * and the test suite. Keeping app creation separate from app.listen() means * tests can import a fresh instance without starting a real server or * triggering the side-effects in index.js (log files, process.exit, poller). */ const express = require('express'); const cookieParser = require('cookie-parser'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); const sabnzbdRoutes = require('./routes/sabnzbd'); const sonarrRoutes = require('./routes/sonarr'); const radarrRoutes = require('./routes/radarr'); const embyRoutes = require('./routes/emby'); const dashboardRoutes = require('./routes/dashboard'); const historyRoutes = require('./routes/history'); const authRoutes = require('./routes/auth'); const verifyCsrf = require('./middleware/verifyCsrf'); function createApp({ skipRateLimits = false } = {}) { const app = express(); if (process.env.TRUST_PROXY) { const trustValue = /^\d+$/.test(process.env.TRUST_PROXY) ? parseInt(process.env.TRUST_PROXY, 10) : process.env.TRUST_PROXY; app.set('trust proxy', trustValue); } // Per-request CSP nonce app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); next(); }); app.use((req, res, next) => { helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`], styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`], styleSrcAttr: ["'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'blob:'], fontSrc: ["'self'", 'data:'], connectSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"], formAction: ["'self'"], upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null } }, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, crossOriginEmbedderPolicy: false })(req, res, next); }); app.use((req, res, next) => { res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'); next(); }); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later' } }); app.use(cookieParser(process.env.COOKIE_SECRET || undefined)); app.use(express.json({ limit: '64kb' })); // Health / readiness (no auth, no rate-limit) app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); }); app.get('/ready', (req, res) => { const ready = !!(process.env.EMBY_URL); if (ready) { res.json({ status: 'ready' }); } else { res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' }); } }); // API routes app.use('/api', apiLimiter); app.use('/api/auth', authRoutes); // CSRF protection for all state-changing API requests below app.use('/api', verifyCsrf); app.use('/api/sabnzbd', sabnzbdRoutes); app.use('/api/sonarr', sonarrRoutes); app.use('/api/radarr', radarrRoutes); app.use('/api/emby', embyRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/history', historyRoutes); // Global error handler // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { console.error('[Server] Unhandled error:', err.message); res.status(500).json({ error: 'Internal server error' }); }); return app; } module.exports = { createApp };