NODE_ENV=production enabled upgrade-insecure-requests unconditionally, which instructed browsers to upgrade HTTP subresource requests to HTTPS. When sofarr is accessed directly over HTTP (no reverse proxy), this silently blocks all CSS, JS, and image loads — the page renders unstyled with no functionality. The correct signal for 'we are behind HTTPS' is TRUST_PROXY, not NODE_ENV. upgrade-insecure-requests is now only emitted when a TLS-terminating reverse proxy is confirmed to be in front.
115 lines
3.7 KiB
JavaScript
115 lines
3.7 KiB
JavaScript
/**
|
|
* 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 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');
|
|
|
|
function createApp({ skipRateLimits = false } = {}) {
|
|
const app = express();
|
|
|
|
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)
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok', uptime: process.uptime() });
|
|
});
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// API routes
|
|
app.use('/api', apiLimiter);
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// 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);
|
|
|
|
// 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 };
|