2025-12-17 00:42:43 +00:00
|
|
|
const express = require('express');
|
|
|
|
|
const helmet = require('helmet');
|
|
|
|
|
const cors = require('cors');
|
|
|
|
|
const compression = require('compression');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const dotenv = require('dotenv');
|
|
|
|
|
const fileUpload = require('express-fileupload');
|
|
|
|
|
const crypto = require('crypto');
|
|
|
|
|
const logger = require('./utils/logger');
|
|
|
|
|
const { errorMiddleware, ErrorResponses } = require('./utils/errorHandler');
|
|
|
|
|
const db = require('./database/db');
|
|
|
|
|
const logManagement = require('./jobs/logManagement');
|
|
|
|
|
|
|
|
|
|
dotenv.config();
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
const PORT = process.env.PORT || 12345;
|
|
|
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
|
|
|
|
|
|
// Generate nonce for inline scripts
|
|
|
|
|
app.use((req, res, next) => {
|
|
|
|
|
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Security middleware with comprehensive CSP
|
|
|
|
|
app.use(helmet({
|
|
|
|
|
contentSecurityPolicy: {
|
|
|
|
|
directives: {
|
|
|
|
|
defaultSrc: ["'self'"],
|
|
|
|
|
scriptSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"'unsafe-inline'", // Required for React/Vite inline scripts
|
|
|
|
|
"'unsafe-eval'", // Required for React DevTools and some libraries
|
|
|
|
|
"https://www.gstatic.com", // Google Cast SDK
|
|
|
|
|
"https://cdn.jsdelivr.net", // HLS.js library
|
|
|
|
|
"blob:", // Required for HLS.js Web Workers
|
|
|
|
|
(req, res) => `'nonce-${res.locals.nonce}'`
|
|
|
|
|
],
|
|
|
|
|
workerSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"blob:" // Required for HLS.js Web Workers
|
|
|
|
|
],
|
|
|
|
|
styleSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"'unsafe-inline'", // Required for MUI and inline styles
|
|
|
|
|
"https://fonts.googleapis.com"
|
|
|
|
|
],
|
|
|
|
|
fontSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"data:",
|
|
|
|
|
"https://fonts.gstatic.com"
|
|
|
|
|
],
|
|
|
|
|
imgSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"data:",
|
|
|
|
|
"blob:",
|
|
|
|
|
"https:",
|
|
|
|
|
"http:" // Allow external logo URLs
|
|
|
|
|
],
|
|
|
|
|
mediaSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"blob:",
|
|
|
|
|
"data:",
|
|
|
|
|
"mediastream:",
|
|
|
|
|
"https:",
|
|
|
|
|
"http:", // Required for IPTV streams
|
|
|
|
|
"*" // Allow all media sources for streaming
|
|
|
|
|
],
|
|
|
|
|
connectSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"https:",
|
|
|
|
|
"http:",
|
|
|
|
|
"ws:",
|
|
|
|
|
"wss:", // WebSocket support
|
|
|
|
|
"blob:",
|
|
|
|
|
"*" // Required for external APIs and streams
|
|
|
|
|
],
|
|
|
|
|
frameSrc: [
|
|
|
|
|
"'self'",
|
|
|
|
|
"https://www.youtube.com", // For embedded players
|
|
|
|
|
"https://player.vimeo.com"
|
|
|
|
|
],
|
|
|
|
|
objectSrc: ["'none'"],
|
|
|
|
|
baseUri: ["'self'"],
|
|
|
|
|
formAction: ["'self'"],
|
|
|
|
|
frameAncestors: ["'self'"]
|
|
|
|
|
// upgradeInsecureRequests disabled for HTTP-only deployments
|
|
|
|
|
},
|
|
|
|
|
reportOnly: !isProduction, // Report-only mode in development
|
|
|
|
|
useDefaults: false
|
|
|
|
|
},
|
|
|
|
|
originAgentCluster: false, // Disable to avoid agent cluster warnings
|
|
|
|
|
crossOriginEmbedderPolicy: false,
|
|
|
|
|
crossOriginOpenerPolicy: false,
|
|
|
|
|
crossOriginResourcePolicy: { policy: "cross-origin" },
|
|
|
|
|
hsts: isProduction ? {
|
|
|
|
|
maxAge: 31536000,
|
|
|
|
|
includeSubDomains: true,
|
|
|
|
|
preload: true
|
|
|
|
|
} : false,
|
|
|
|
|
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
|
|
|
|
|
noSniff: true,
|
|
|
|
|
xssFilter: true,
|
|
|
|
|
hidePoweredBy: true
|
|
|
|
|
}));
|
|
|
|
|
|
2025-12-24 23:57:41 +00:00
|
|
|
// CORS configuration to allow local network and custom domain
|
2025-12-17 00:42:43 +00:00
|
|
|
const allowedOrigins = [
|
|
|
|
|
'http://localhost:12345',
|
|
|
|
|
'http://localhost:9000',
|
|
|
|
|
/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/, // Local network 192.168.x.x
|
|
|
|
|
/^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/, // Local network 10.x.x.x
|
|
|
|
|
/^http:\/\/172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}(:\d+)?$/ // Local network 172.16-31.x.x
|
|
|
|
|
];
|
|
|
|
|
|
2025-12-24 23:57:41 +00:00
|
|
|
// Add custom domain origins from environment variable
|
|
|
|
|
if (process.env.ALLOWED_ORIGIN) {
|
|
|
|
|
const customOrigins = process.env.ALLOWED_ORIGIN.split(',').map(o => o.trim());
|
|
|
|
|
allowedOrigins.push(...customOrigins);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 00:42:43 +00:00
|
|
|
// Mount logo-proxy BEFORE global CORS to handle public image serving
|
|
|
|
|
app.use('/api/logo-proxy', require('./routes/logo-proxy'));
|
|
|
|
|
|
|
|
|
|
app.use(cors({
|
|
|
|
|
origin: function (origin, callback) {
|
|
|
|
|
// Allow requests with no origin (mobile apps, curl, etc.)
|
|
|
|
|
if (!origin) return callback(null, true);
|
|
|
|
|
|
|
|
|
|
// Check if origin matches allowed patterns
|
|
|
|
|
const isAllowed = allowedOrigins.some(allowed => {
|
|
|
|
|
if (typeof allowed === 'string') {
|
|
|
|
|
return origin === allowed;
|
|
|
|
|
} else if (allowed instanceof RegExp) {
|
|
|
|
|
return allowed.test(origin);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isAllowed) {
|
|
|
|
|
callback(null, true);
|
|
|
|
|
} else {
|
|
|
|
|
console.warn(`[CORS] Rejected origin: ${origin}`);
|
|
|
|
|
callback(new Error('Not allowed by CORS'));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
credentials: true, // Allow cookies and authentication headers
|
|
|
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
|
|
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
app.use(compression());
|
|
|
|
|
app.use(express.json({ limit: '10mb' }));
|
|
|
|
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
|
|
|
app.use(fileUpload({
|
|
|
|
|
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB max file size
|
|
|
|
|
useTempFiles: true,
|
|
|
|
|
tempFileDir: '/tmp/'
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Initialize database
|
|
|
|
|
db.initialize();
|
|
|
|
|
|
|
|
|
|
// Serve static files (uploaded logos)
|
|
|
|
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
|
|
|
|
|
|
|
|
|
// Serve cached logos from IPTV-org
|
|
|
|
|
app.use('/logos', express.static(path.join('/app', 'data', 'logo-cache')));
|
|
|
|
|
|
|
|
|
|
// Health check endpoint (no rate limiting)
|
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
|
|
|
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// API Routes
|
|
|
|
|
app.use('/api/auth', require('./routes/auth'));
|
|
|
|
|
app.use('/api/users', require('./routes/users'));
|
|
|
|
|
app.use('/api/playlists', require('./routes/playlists'));
|
|
|
|
|
app.use('/api/channels', require('./routes/channels'));
|
|
|
|
|
app.use('/api/recordings', require('./routes/recordings'));
|
|
|
|
|
app.use('/api/two-factor', require('./routes/twoFactor'));
|
|
|
|
|
app.use('/api/profiles', require('./routes/profiles'));
|
|
|
|
|
app.use('/api/radio', require('./routes/radio'));
|
|
|
|
|
app.use('/api/groups', require('./routes/groups'));
|
|
|
|
|
app.use('/api/settings', require('./routes/settings'));
|
|
|
|
|
app.use('/api/stream', require('./routes/stream'));
|
|
|
|
|
app.use('/api/stats', require('./routes/stats'));
|
|
|
|
|
app.use('/api/m3u-files', require('./routes/m3u-files'));
|
|
|
|
|
app.use('/api/vpn-configs', require('./routes/vpn-configs'));
|
|
|
|
|
app.use('/api/search', require('./routes/search'));
|
|
|
|
|
app.use('/api/favorites', require('./routes/favorites'));
|
|
|
|
|
app.use('/api/backup', require('./routes/backup'));
|
|
|
|
|
app.use('/api/metadata', require('./routes/metadata'));
|
|
|
|
|
app.use('/api/history', require('./routes/history'));
|
|
|
|
|
app.use('/api/logo-cache', require('./routes/logo-cache'));
|
|
|
|
|
app.use('/api/sessions', require('./routes/sessions'));
|
|
|
|
|
app.use('/api/csp', require('./routes/csp'));
|
|
|
|
|
app.use('/api/rbac', require('./routes/rbac'));
|
|
|
|
|
app.use('/api/security-monitor', require('./routes/security-monitor'));
|
|
|
|
|
app.use('/api/security-headers', require('./routes/security-headers'));
|
|
|
|
|
app.use('/api/security-testing', require('./routes/security-testing'));
|
|
|
|
|
app.use('/api/security-config', require('./routes/security-config'));
|
|
|
|
|
app.use('/api/siem', require('./routes/siem'));
|
|
|
|
|
app.use('/api/log-management', require('./routes/log-management'));
|
|
|
|
|
app.use('/api/encryption', require('./routes/encryption-management'));
|
|
|
|
|
|
|
|
|
|
// Serve static files from frontend
|
|
|
|
|
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
|
|
|
|
|
|
|
|
|
// Serve uploaded files
|
|
|
|
|
app.use('/uploads', express.static(path.join(__dirname, '../data/uploads')));
|
|
|
|
|
app.use('/logos', express.static(path.join(__dirname, '../data/logos')));
|
|
|
|
|
app.use('/data/logo-cache', express.static(path.join(__dirname, '../data/logo-cache')));
|
|
|
|
|
|
|
|
|
|
// Handle SPA routing (must be before error handler)
|
|
|
|
|
app.get('*', (req, res) => {
|
|
|
|
|
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Secure error handling middleware (CWE-209 protection)
|
|
|
|
|
// This MUST be the last middleware - catches all errors and sanitizes them
|
|
|
|
|
app.use(errorMiddleware);
|
|
|
|
|
|
|
|
|
|
// Reset VPN states on startup (connections are lost on restart)
|
|
|
|
|
db.db.run('UPDATE vpn_configs SET is_active = 0', (err) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
logger.error('Failed to reset VPN states on startup:', err);
|
|
|
|
|
} else {
|
|
|
|
|
logger.info('Reset all VPN connection states on startup');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Start background jobs
|
|
|
|
|
require('./jobs/channelHealth');
|
|
|
|
|
require('./jobs/recordingScheduler');
|
|
|
|
|
require('./jobs/logoCacher');
|
|
|
|
|
|
|
|
|
|
// Process-level error handlers (CWE-391 protection)
|
|
|
|
|
// Handle uncaught exceptions
|
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
|
|
|
logger.error('UNCAUGHT EXCEPTION - Process will exit', {
|
|
|
|
|
error: error.message,
|
|
|
|
|
stack: error.stack,
|
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
|
});
|
|
|
|
|
// Give logger time to write
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}, 1000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle unhandled promise rejections
|
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
|
|
|
logger.error('UNHANDLED PROMISE REJECTION', {
|
|
|
|
|
reason: reason instanceof Error ? reason.message : reason,
|
|
|
|
|
stack: reason instanceof Error ? reason.stack : undefined,
|
|
|
|
|
promise: promise.toString(),
|
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle SIGTERM gracefully
|
|
|
|
|
process.on('SIGTERM', () => {
|
|
|
|
|
logger.info('SIGTERM received, closing server gracefully');
|
|
|
|
|
// Close database connections, clear intervals, etc.
|
|
|
|
|
db.db.close((err) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
logger.error('Error closing database:', err);
|
|
|
|
|
}
|
|
|
|
|
process.exit(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle SIGINT (Ctrl+C) gracefully
|
|
|
|
|
process.on('SIGINT', () => {
|
|
|
|
|
logger.info('SIGINT received, closing server gracefully');
|
|
|
|
|
process.exit(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Start server
|
|
|
|
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
|
|
|
|
logger.info(`StreamFlow server running on port ${PORT}`);
|
|
|
|
|
console.log(`StreamFlow server running on port ${PORT}`);
|
|
|
|
|
|
|
|
|
|
// Initialize log management system (CWE-53 compliance)
|
|
|
|
|
logManagement.initialize().catch(err => {
|
|
|
|
|
logger.error('Failed to initialize log management:', err);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle server errors
|
|
|
|
|
server.on('error', (error) => {
|
|
|
|
|
logger.error('Server error:', {
|
|
|
|
|
error: error.message,
|
|
|
|
|
code: error.code,
|
|
|
|
|
stack: error.stack
|
|
|
|
|
});
|
|
|
|
|
if (error.code === 'EADDRINUSE') {
|
|
|
|
|
logger.error(`Port ${PORT} is already in use`);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
module.exports = app;
|