// Copyright (c) 2026 Gordon Bolton. MIT License. /** * 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 swaggerUi = require('swagger-ui-express'); const swaggerJsdoc = require('swagger-jsdoc'); const YAML = require('yamljs'); const path = require('path'); 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 statusRoutes = require('./routes/status'); const historyRoutes = require('./routes/history'); const authRoutes = require('./routes/auth'); const webhookRoutes = require('./routes/webhook'); const verifyCsrf = require('./middleware/verifyCsrf'); function createApp({ skipRateLimits = false } = {}) { const app = express(); // Load OpenAPI spec from YAML const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml')); // Configure swagger-jsdoc to merge JSDoc comments from route files const swaggerOptions = { definition: { ...openapiSpec, openapi: '3.1.0' }, apis: [ path.join(__dirname, 'routes/*.js'), path.join(__dirname, 'app.js'), path.join(__dirname, 'index.js') ] }; const swaggerSpec = swaggerJsdoc(swaggerOptions); 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) /** * @openapi * /health: * get: * tags: [Health] * summary: Health check * description: Returns server uptime and status. No authentication required. Used for liveness probes. * security: [] * responses: * '200': * description: Server is healthy * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: "ok" * uptime: * type: number * description: Server uptime in seconds * example: 3600.5 * x-code-samples: * - lang: curl * label: cURL * source: curl http://localhost:3001/health */ app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); }); /** * @openapi * /ready: * get: * tags: [Health] * summary: Readiness check * description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required. * security: [] * responses: * '200': * description: Server is ready * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: "ready" * '503': * description: Server not ready * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: "not ready" * reason: * type: string * example: "EMBY_URL not configured" * x-code-samples: * - lang: curl * label: cURL * source: curl http://localhost:3001/ready */ 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' }); } }); // Swagger UI - publicly accessible API documentation app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, { customSiteTitle: 'sofarr API Documentation', customCss: '.swagger-ui .topbar { display: none }', customJs: [ '/swagger-auth-banner.js' ], swaggerOptions: { url: '/api/swagger.json' } })); // Serve the raw OpenAPI spec as JSON with dynamic server URL app.get('/api/swagger.json', (req, res) => { // Clone the spec to avoid modifying the original const specCopy = JSON.parse(JSON.stringify(swaggerSpec)); // Replace the server URL with the current request's origin if (specCopy.servers && specCopy.servers.length > 0) { const protocol = req.protocol; const host = req.get('host'); specCopy.servers[0].url = `${protocol}://${host}`; } res.json(specCopy); }); // API routes app.use('/api', apiLimiter); app.use('/api/auth', authRoutes); app.use('/api/webhook', webhookRoutes); // 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/status', statusRoutes); 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 };