Re_Backend/src/app.ts

296 lines
11 KiB
TypeScript

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<void> => {
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<void> => {
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;