de9a9284dc
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m43s
CI / Security audit (push) Successful in 3m15s
Build and Push Docker Image / build (push) Successful in 4m6s
CI / Swagger Validation & Coverage (push) Successful in 4m14s
CI / Tests & coverage (push) Successful in 4m32s
- Change swaggerUi.setup to pass null and fetch spec from /api/swagger.json
- Update /api/swagger.json handler to dynamically set server URL based on request
- Remove dead client-side detection script (swagger-server-detection.js)
- Server-side detection respects TRUST_PROXY for reverse proxy scenarios
- req.protocol and req.get('host') automatically use X-Forwarded headers when configured
- Fixes issue where placeholder URL was never replaced due to window.ui being unavailable
236 lines
7.5 KiB
JavaScript
236 lines
7.5 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
/**
|
|
* 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 swaggerUi = require('swagger-ui-express');
|
|
const swaggerJsdoc = require('swagger-jsdoc');
|
|
const YAML = require('yamljs');
|
|
const path = require('path');
|
|
|
|
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 statusRoutes = require('./routes/status');
|
|
const historyRoutes = require('./routes/history');
|
|
const authRoutes = require('./routes/auth');
|
|
const webhookRoutes = require('./routes/webhook');
|
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
|
|
function createApp({ skipRateLimits = false } = {}) {
|
|
const app = express();
|
|
|
|
// Load OpenAPI spec from YAML
|
|
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
|
|
|
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
|
const swaggerOptions = {
|
|
definition: {
|
|
...openapiSpec,
|
|
openapi: '3.1.0'
|
|
},
|
|
apis: [
|
|
path.join(__dirname, 'routes/*.js'),
|
|
path.join(__dirname, 'app.js'),
|
|
path.join(__dirname, 'index.js')
|
|
]
|
|
};
|
|
|
|
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
|
|
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)
|
|
/**
|
|
* @openapi
|
|
* /health:
|
|
* get:
|
|
* tags: [Health]
|
|
* summary: Health check
|
|
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
|
* security: []
|
|
* responses:
|
|
* '200':
|
|
* description: Server is healthy
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* status:
|
|
* type: string
|
|
* example: "ok"
|
|
* uptime:
|
|
* type: number
|
|
* description: Server uptime in seconds
|
|
* example: 3600.5
|
|
* x-code-samples:
|
|
* - lang: curl
|
|
* label: cURL
|
|
* source: curl http://localhost:3001/health
|
|
*/
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok', uptime: process.uptime() });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /ready:
|
|
* get:
|
|
* tags: [Health]
|
|
* summary: Readiness check
|
|
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
|
* security: []
|
|
* responses:
|
|
* '200':
|
|
* description: Server is ready
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* status:
|
|
* type: string
|
|
* example: "ready"
|
|
* '503':
|
|
* description: Server not ready
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* status:
|
|
* type: string
|
|
* example: "not ready"
|
|
* reason:
|
|
* type: string
|
|
* example: "EMBY_URL not configured"
|
|
* x-code-samples:
|
|
* - lang: curl
|
|
* label: cURL
|
|
* source: curl http://localhost:3001/ready
|
|
*/
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Swagger UI - publicly accessible API documentation
|
|
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
|
customSiteTitle: 'sofarr API Documentation',
|
|
customCss: '.swagger-ui .topbar { display: none }',
|
|
customJs: [
|
|
'/swagger-auth-banner.js'
|
|
],
|
|
swaggerOptions: {
|
|
url: '/api/swagger.json'
|
|
}
|
|
}));
|
|
|
|
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
|
app.get('/api/swagger.json', (req, res) => {
|
|
// Clone the spec to avoid modifying the original
|
|
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
|
|
|
// Replace the server URL with the current request's origin
|
|
if (specCopy.servers && specCopy.servers.length > 0) {
|
|
const protocol = req.protocol;
|
|
const host = req.get('host');
|
|
specCopy.servers[0].url = `${protocol}://${host}`;
|
|
}
|
|
|
|
res.json(specCopy);
|
|
});
|
|
|
|
// API routes
|
|
app.use('/api', apiLimiter);
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/webhook', webhookRoutes);
|
|
|
|
// 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);
|
|
app.use('/api/status', statusRoutes);
|
|
app.use('/api/history', historyRoutes);
|
|
|
|
// 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 };
|