Files
sofarr/server/index.js
Gronod 3c3382401c
All checks were successful
Build and Push Docker Image / build (push) Successful in 22s
CI / Security audit (push) Successful in 49s
CI / Tests & coverage (push) Successful in 1m4s
fix: remove nonce from <link> tags — breaks CSS on mobile/caching proxies
style-src 'self' already permits same-origin stylesheets without a nonce.
Injecting a nonce onto <link rel=stylesheet> causes silent CSS failure on
mobile Safari and any setup where a caching proxy serves stale HTML (the
nonce in the HTML no longer matches the per-request CSP header nonce).

Nonce injection is now limited to <script> tags only, where it is
actually required to permit the same-origin app.js.
2026-05-17 09:28:44 +01:00

271 lines
11 KiB
JavaScript

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');
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 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;
// ---------------------------------------------------------------------------
// 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.NODE_ENV === 'production' ? [] : 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 <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// ---------------------------------------------------------------------------
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
// CSRF protection applies to all state-changing /api/* requests except
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
// ---------------------------------------------------------------------------
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
// All routes below this point require CSRF validation on mutating methods
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);
// SPA catch-all — serve index.html for any unmatched path
app.get('*', serveIndex);
// ---------------------------------------------------------------------------
// Global error handler — never leak stack traces to clients
// ---------------------------------------------------------------------------
// 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' });
});
app.listen(PORT, () => {
console.log(`=================================`);
console.log(` sofarr - Your Downloads Dashboard`);
console.log(` Server running on port ${PORT}`);
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();
});