189 lines
7.3 KiB
JavaScript
189 lines
7.3 KiB
JavaScript
// 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'));
|