import dotenv from 'dotenv'; import path from 'path'; // Load environment variables from .env file (check parent directory for monorepo setup) dotenv.config({ path: path.resolve(__dirname, '../../.env') }); dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); dotenv.config(); import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import config, { validateConfig } from './config'; import { initializeDatabase, closeDatabase } from './db'; import routes from './routes'; import logger, { morganStream } from './utils/logger'; import { apiAuthMiddleware, ensureApiKey } from './middleware/apiAuth'; import { initializeServices } from './services/init'; import { applyEnvSettings } from './services/envSettings'; import { getScheduler } from './scheduler'; import { sendStartupHeartbeat } from './services/telemetry'; import { refreshAnnouncementsOnStartup } from './services/announcements'; // Create Express application const app = express(); // Validate configuration on startup const configValidation = validateConfig(); if (!configValidation.valid) { logger.warn('Configuration warnings:'); configValidation.errors.forEach((err) => logger.warn(` - ${err}`)); } // Security middleware // Disable HSTS and CSP as app runs on HTTP in home server environments app.use( helmet({ contentSecurityPolicy: false, hsts: false, }) ); // CORS configuration app.use( cors({ origin: config.nodeEnv === 'production' ? process.env['CORS_ORIGIN'] || true : true, credentials: true, }) ); // Request logging app.use( morgan(config.nodeEnv === 'production' ? 'combined' : 'dev', { stream: morganStream, skip: (req: Request) => req.path === '/api/health/ping', }) ); // Body parsing middleware app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Serve static files in production // In Docker: /app/public (copied from client build) // In local prod: ../../client/dist (relative to server/dist) if (config.nodeEnv === 'production') { const dockerPath = path.join(__dirname, '../public'); const localPath = path.join(__dirname, '../../client/dist'); const clientPath = require('fs').existsSync(dockerPath) ? dockerPath : localPath; app.use(express.static(clientPath)); logger.info(`Serving static files from: ${clientPath}`); } // API key authentication middleware app.use('/api', apiAuthMiddleware); // API routes app.use('/api', routes); // Root health check redirect (convenience) app.get('/health', (_req: Request, res: Response) => { res.redirect('/api/health'); }); // 404 handler for API routes app.use('/api', (_req: Request, res: Response) => { res.status(404).json({ success: false, error: 'Endpoint not found', }); }); // Serve client app for any other routes in production (SPA routing) if (config.nodeEnv === 'production') { const dockerIndex = path.join(__dirname, '../public/index.html'); const localIndex = path.join(__dirname, '../../client/dist/index.html'); const indexPath = require('fs').existsSync(dockerIndex) ? dockerIndex : localIndex; // Catch-all for SPA routing - must be after API routes app.use((_req: Request, res: Response) => { res.sendFile(indexPath); }); } // Global error handler interface ErrorWithStatus extends Error { status?: number; statusCode?: number; } app.use((err: ErrorWithStatus, _req: Request, res: Response, _next: NextFunction) => { const statusCode = err.status || err.statusCode || 500; const message = config.nodeEnv === 'production' ? 'Internal server error' : err.message; logger.error('Unhandled error:', { message: err.message, stack: err.stack, statusCode, }); res.status(statusCode).json({ success: false, error: message, ...(config.nodeEnv !== 'production' && { stack: err.stack }), }); }); // Start server async function startServer(): Promise { try { // Initialize database logger.info('Initializing database...'); initializeDatabase(); // Ensure API key exists in settings ensureApiKey(); // Service connections given as environment variables (see CONFIG_FROM_ENV) applyEnvSettings(); // Initialize services (DeletionService, Sonarr, Radarr, Overseerr) await initializeServices(); // Start the scheduler const scheduler = getScheduler(); scheduler.start(); logger.info('Scheduler started'); // Start listening const server = app.listen(config.port, () => { logger.info(`Server started successfully`); logger.info(` Environment: ${config.nodeEnv}`); logger.info(` Port: ${config.port}`); logger.info(` API: http://localhost:${config.port}/api`); logger.info(` Health: http://localhost:${config.port}/api/health`); // Anonymous install-count heartbeat. Fired after the server is already // listening and never awaited, so an unreachable endpoint costs startup // nothing. No-ops when telemetry is off or not yet due. sendStartupHeartbeat(); // Same rules for the "What's new" feed: after listen, never awaited. refreshAnnouncementsOnStartup(); }); // Graceful shutdown handlers const shutdown = async (signal: string): Promise => { logger.info(`Received ${signal}, shutting down gracefully...`); // Stop the scheduler scheduler.stop(); logger.info('Scheduler stopped'); server.close(() => { logger.info('HTTP server closed'); closeDatabase(); logger.info('Database connection closed'); process.exit(0); }); // Force shutdown after 10 seconds setTimeout(() => { logger.error('Forced shutdown after timeout'); process.exit(1); }, 10000); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); } catch (error) { logger.error('Failed to start server:', error); process.exit(1); } } // Start the server startServer(); export default app;