321 lines
13 KiB
JavaScript
321 lines
13 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');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
require('dotenv').config();
|
|
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 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 <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);
|
|
app.use('/api/history', historyRoutes);
|
|
|
|
// 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' });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
});
|