// Copyright (c) 2026 Gordon Bolton. MIT License. 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'); const swaggerUi = require('swagger-ui-express'); const swaggerJsdoc = require('swagger-jsdoc'); const YAML = require('yamljs'); require('dotenv').config(); require('./utils/loadSecrets')(); const logCapture = require('./utils/logCapture'); logCapture.init(); const { version } = require('../package.json'); // 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 { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'); const { validateInstanceUrl } = require('./utils/config'); const { createApp } = require('./app'); // --------------------------------------------------------------------------- // 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)'); } else if (cookieSecret.length < 32) { console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32'); } if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') { console.error('[Config] EMBY_URL is required'); process.exit(1); } if (process.env.EMBY_URL) { validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL'); } const app = createApp(); const PORT = process.env.PORT || 3001; const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false'; // --------------------------------------------------------------------------- // TLS / HTTPS support // Set TLS_CERT and TLS_KEY to paths of your certificate and private key. // If unset, defaults to the bundled snakeoil self-signed certificate // (localhost/127.0.0.1 only — suitable for local testing). // Set TLS_ENABLED=false to force plain HTTP even if cert files exist. // --------------------------------------------------------------------------- const CERTS_DIR = path.join(__dirname, '../certs'); const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt'); const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key'); function loadTlsCredentials() { if (!TLS_ENABLED) return null; try { return { cert: fs.readFileSync(TLS_CERT_PATH), key: fs.readFileSync(TLS_KEY_PATH) }; } catch (err) { console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`); return null; } } const tlsCredentials = loadTlsCredentials(); const server = tlsCredentials ? https.createServer(tlsCredentials, app) : http.createServer(app); const protocol = tlsCredentials ? 'https' : 'http'; const isSnakeoil = TLS_ENABLED && (!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH); server.listen(PORT, () => { console.log(`=================================`); console.log(` sofarr v${version} - Your Downloads Dashboard`); console.log(` Server running on ${protocol}://localhost:${PORT}`); if (tlsCredentials && isSnakeoil) { console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`); console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`); console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`); } else if (tlsCredentials) { console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`); } else { console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`); } console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`); console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`); console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`); console.log(`=================================`); startPoller(); }); // --------------------------------------------------------------------------- // Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C) // Stop the poller, close the HTTP server (stops accepting new connections), // then let Node drain existing keep-alive connections and exit cleanly. // --------------------------------------------------------------------------- const { stopPoller } = require('./utils/poller'); function shutdown(signal) { console.log(`[Server] ${signal} received — shutting down gracefully`); stopPoller(); server.close(() => { console.log('[Server] HTTP server closed'); process.exit(0); }); // Force exit after 10 s if connections don't drain setTimeout(() => { console.error('[Server] Forced exit after 10 s timeout'); process.exit(1); }, 10000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));