import express from 'express'; import helmet from 'helmet'; import morgan from 'morgan'; import dotenv from 'dotenv'; import cookieParser from 'cookie-parser'; import { UserService } from './services/user.service'; import { SSOUserData } from './types/auth.types'; import { sequelize } from './config/database'; import { corsMiddleware } from './middlewares/cors.middleware'; import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware'; import routes from './routes/index'; import { ensureUploadDir, UPLOAD_DIR } from './config/storage'; import { initializeGoogleSecretManager } from './services/googleSecretManager.service'; import path from 'path'; // Load environment variables from .env file first dotenv.config(); // Secrets are now initialized in server.ts before app is imported const app: express.Application = express(); // 1. Security middleware - Manual "Gold Standard" CSP to ensure it survives 301/404/etc. // This handles a specific Express/Helmet edge case where redirects lose headers. app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { const isDev = process.env.NODE_ENV !== 'production'; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; // Build connect-src dynamically const connectSrc = ["'self'", "blob:", "data:"]; if (isDev) { connectSrc.push("http://localhost:3000", "http://localhost:5000", "ws://localhost:3000", "ws://localhost:5000"); if (frontendUrl.includes('localhost')) connectSrc.push(frontendUrl); } else if (frontendUrl && frontendUrl !== '*') { const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean); connectSrc.push(...origins); } // Define strict CSP directives // CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance // even if the header is truncated in certain response types (like 301 redirects). const directives = [ "frame-ancestors 'self'", "form-action 'self'", "base-uri 'self'", "default-src 'none'", `connect-src ${connectSrc.join(' ')}`, "style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='", "style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='", "style-src-attr 'unsafe-inline'", "script-src 'self'", "script-src-elem 'self'", "script-src-attr 'none'", "img-src 'self' data: blob: https://*.royalenfield.com https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com", "frame-src 'self' blob: data:", "font-src 'self' https://fonts.gstatic.com data:", "object-src 'none'", "worker-src 'self' blob:", "manifest-src 'self'", !isDev ? "upgrade-insecure-requests" : "" ].filter(Boolean).join("; "); res.setHeader('Content-Security-Policy', directives); next(); }); // Configure other security headers via Helmet (with CSP disabled since we set it manually) app.use(helmet({ contentSecurityPolicy: false, // Handled manually above to ensure redirect compatibility crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: { policy: "cross-origin" }, xFrameOptions: { action: "sameorigin" }, })); // 2. CORS middleware - MUST be before other middleware app.use(corsMiddleware); // Handle /assets trailing slash redirect manually to avoid CSP truncation by express.static app.get('/assets', (req, res) => { res.redirect(301, '/assets/'); }); // 3. Cookie parser middleware - MUST be before routes app.use(cookieParser()); const userService = new UserService(); // Initializer for database connection (called from server.ts) export const initializeAppDatabase = async () => { try { await sequelize.authenticate(); console.log('✅ App database connection established'); } catch (error) { console.error('❌ App database connection failed:', error); throw error; } }; // Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.) // This allows Express to read X-Forwarded-* headers correctly // Set to true in production, false in development if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production') { app.set('trust proxy', true); } else { // In development, trust first proxy (useful for local testing with nginx) app.set('trust proxy', 1); } // Body parsing middleware app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Logging middleware app.use(morgan('combined')); // Prometheus metrics middleware - collect request metrics app.use(metricsMiddleware); // Prometheus metrics endpoint - expose metrics for scraping app.use(createMetricsRouter()); // Health check endpoint (before API routes) app.get('/health', (_req: express.Request, res: express.Response) => { res.status(200).json({ status: 'OK', timestamp: new Date(), uptime: process.uptime(), environment: process.env.NODE_ENV || 'development' }); }); // Mount API routes - MUST be before static file serving app.use('/api/v1', routes); // Serve uploaded files statically ensureUploadDir(); app.use('/uploads', express.static(UPLOAD_DIR)); // Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility) app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise => { try { const ssoData: SSOUserData = req.body; // Validate required fields - email and oktaSub are required if (!ssoData.email || !ssoData.oktaSub) { res.status(400).json({ success: false, message: 'Missing required fields: email and oktaSub are required', timestamp: new Date() }); return; } // Create or update user const user = await userService.createOrUpdateUser(ssoData); res.status(200).json({ success: true, message: 'User processed successfully', data: { user: { userId: user.userId, employeeId: user.employeeId || null, oktaSub: user.oktaSub, email: user.email, firstName: user.firstName || null, lastName: user.lastName || null, displayName: user.displayName || null, department: user.department || null, designation: user.designation || null, phone: user.phone || null, location: user.location || null, role: user.role, lastLogin: user.lastLogin }, isNewUser: user.createdAt.getTime() === user.updatedAt.getTime() }, timestamp: new Date() }); } catch (error) { console.error('❌ SSO Callback failed:', error); res.status(500).json({ success: false, message: 'Internal server error', timestamp: new Date() }); } }); // Get all users endpoint app.get('/api/v1/users', async (_req: express.Request, res: express.Response): Promise => { try { const users = await userService.getAllUsers(); res.status(200).json({ success: true, message: 'Users retrieved successfully', data: { users: users.map(user => ({ userId: user.userId, employeeId: user.employeeId || null, oktaSub: user.oktaSub, email: user.email, firstName: user.firstName || null, lastName: user.lastName || null, displayName: user.displayName || null, department: user.department || null, designation: user.designation || null, phone: user.phone || null, location: user.location || null, role: user.role, lastLogin: user.lastLogin, createdAt: user.createdAt })), total: users.length }, timestamp: new Date() }); } catch (error) { console.error('❌ Get Users failed:', error); res.status(500).json({ success: false, message: 'Internal server error', timestamp: new Date() }); } }); // Serve React build static files (only in production or when build folder exists) // Check for both 'build' (Create React App) and 'dist' (Vite) folders const buildPath = path.join(__dirname, "..", "build"); const distPath = path.join(__dirname, "..", "dist"); const fs = require('fs'); // Try to find React build directory let reactBuildPath: string | null = null; if (fs.existsSync(buildPath)) { reactBuildPath = buildPath; } else if (fs.existsSync(distPath)) { reactBuildPath = distPath; } // Serve static files if React build exists if (reactBuildPath && fs.existsSync(path.join(reactBuildPath, "index.html"))) { // Serve static assets (JS, CSS, images, etc.) - these will have CSP headers from Helmet app.use(express.static(reactBuildPath, { setHeaders: (res: express.Response, filePath: string) => { // Apply CSP headers to HTML files served as static files if (filePath.endsWith('.html')) { // CSP headers are already set by Helmet middleware, but ensure they're applied // The meta tag in index.html will also enforce CSP } } })); // Catch-all handler: serve React app for all non-API routes // This must be AFTER all API routes to avoid intercepting API requests app.get('*', (req: express.Request, res: express.Response): void => { // Don't serve React for API routes, uploads, or health check if (req.path.startsWith('/api/') || req.path.startsWith('/uploads/') || req.path === '/health') { res.status(404).json({ success: false, message: `Route ${req.originalUrl} not found`, timestamp: new Date(), }); return; } // Serve React app for all other routes (SPA routing) // This handles client-side routing in React Router // CSP headers from Helmet will be applied to this response res.sendFile(path.join(reactBuildPath!, "index.html")); }); } else { // No React build found - provide API info at root and use standard 404 handler app.get('/', (_req: express.Request, res: express.Response): void => { res.status(200).json({ message: 'Royal Enfield Workflow Management System API', version: '1.0.0', status: 'running', timestamp: new Date(), note: 'React build not found. API is available at /api/v1' }); }); // Standard 404 handler for non-existent routes app.use((req: express.Request, res: express.Response): void => { res.status(404).json({ success: false, message: `Route ${req.originalUrl} not found`, timestamp: new Date(), }); }); } export default app;