const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const https = require('https'); require('dotenv').config(); // Setup logging with levels // Levels: debug (0), info (1), warn (2), error (3), silent (4) const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 }; const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1; // Log file lives in DATA_DIR so the non-root container user can write to it const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data'); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); const LOG_PATH = path.join(DATA_DIR, 'server.log'); const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file const LOG_KEEP = 3; // keep 3 rotated files function rotateLogIfNeeded() { try { const stat = fs.statSync(LOG_PATH); if (stat.size < LOG_MAX_BYTES) return; for (let i = LOG_KEEP - 1; i >= 1; i--) { const src = `${LOG_PATH}.${i}`; const dst = `${LOG_PATH}.${i + 1}`; if (fs.existsSync(src)) fs.renameSync(src, dst); } fs.renameSync(LOG_PATH, `${LOG_PATH}.1`); } catch { /* ignore rotation errors — don't crash the server */ } } rotateLogIfNeeded(); const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' }); const originalConsoleLog = console.log; const originalConsoleError = console.error; const originalConsoleWarn = console.warn; const originalConsoleDebug = console.debug; function shouldLog(level) { return level >= currentLevel; } console.debug = function(...args) { if (!shouldLog(LOG_LEVELS.debug)) return; const message = args.join(' '); originalConsoleDebug.apply(console, args); logFile.write(`[${new Date().toISOString()}] DEBUG: ${message}\n`); }; console.log = function(...args) { if (!shouldLog(LOG_LEVELS.info)) return; const message = args.join(' '); originalConsoleLog.apply(console, args); logFile.write(`[${new Date().toISOString()}] ${message}\n`); }; console.warn = function(...args) { if (!shouldLog(LOG_LEVELS.warn)) return; const message = args.join(' '); originalConsoleWarn.apply(console, args); logFile.write(`[${new Date().toISOString()}] WARN: ${message}\n`); }; console.error = function(...args) { if (!shouldLog(LOG_LEVELS.error)) return; const message = args.join(' '); originalConsoleError.apply(console, args); logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`); }; 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'); const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'); // --------------------------------------------------------------------------- // Startup environment validation // --------------------------------------------------------------------------- const cookieSecret = process.env.COOKIE_SECRET; if (!cookieSecret && process.env.NODE_ENV === 'production') { console.error('[Security] COOKIE_SECRET is not set in production — aborting.'); process.exit(1); } else if (!cookieSecret) { console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)'); } if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') { console.error('[Config] EMBY_URL is required'); process.exit(1); } const app = express(); const PORT = process.env.PORT || 3001; // Resolve TLS_ENABLED early — used in Helmet CSP and server startup const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false'; // --------------------------------------------------------------------------- // Trust proxy — required when behind Nginx/Caddy/Traefik so that // req.ip reflects the real client IP (not 127.0.0.1) and // req.secure is true when the upstream TLS is terminated by the proxy. // Set TRUST_PROXY=1 (or a specific IP/CIDR) via env. // --------------------------------------------------------------------------- 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); } // --------------------------------------------------------------------------- // Helmet v7 — security response headers // CSP uses a per-request nonce injected into index.html so inline scripts // and styles are allowed only with a valid nonce, not blanket unsafe-inline. // --------------------------------------------------------------------------- app.use((req, res, next) => { // Generate a fresh nonce for every request 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}'`], imgSrc: ["'self'", 'data:', 'blob:'], fontSrc: ["'self'", 'data:'], connectSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"], formAction: ["'self'"], upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null } }, hsts: { maxAge: 31536000, // 1 year includeSubDomains: true, preload: true }, referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, crossOriginEmbedderPolicy: false // not needed for this SPA })(req, res, next); }); // Permissions-Policy — disable powerful browser features not needed by the app app.use((req, res, next) => { res.setHeader( 'Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()' ); next(); }); // --------------------------------------------------------------------------- // General API rate limiter — applies to all /api/* routes // More specific limiters (e.g. login) apply on top of this. // --------------------------------------------------------------------------- const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 300, // 300 requests per IP per window (generous for polling) standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later' } }); // --------------------------------------------------------------------------- // Body parsing & cookies // --------------------------------------------------------------------------- app.use(cookieParser(cookieSecret || undefined)); app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads // --------------------------------------------------------------------------- // Health / readiness endpoints (no auth, no rate-limit) // Used by Docker HEALTHCHECK and orchestrators. // --------------------------------------------------------------------------- app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); }); app.get('/ready', (req, res) => { // Confirm critical config is present 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' }); } }); // --------------------------------------------------------------------------- // Static files — served before API routes // index.html is served manually so we can inject the CSP nonce // --------------------------------------------------------------------------- const PUBLIC_DIR = path.join(__dirname, '../public'); const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html'); // Serve all static assets (js, css, images, icons) except index.html. // JS and CSS get no-cache so browsers revalidate on every load (ETag still // avoids re-downloading unchanged files; only a deploy changes the ETag). app.use(express.static(PUBLIC_DIR, { index: false, setHeaders(res, filePath) { if (filePath.endsWith('.js') || filePath.endsWith('.css')) { res.setHeader('Cache-Control', 'no-cache'); } } })); // Serve index.html with CSP nonce injected into