require('dotenv').config(); const express = require('express'); const path = require('path'); const helmet = require('helmet'); const compression = require('compression'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const rateLimit = require('express-rate-limit'); const bcrypt = require('bcryptjs'); const pinoHttp = require('pino-http'); const config = require('./config/env'); const supabase = require('./services/supabase'); const logger = require('./services/logger'); const metrics = require('./services/metrics'); const { setupCSRF, validateCSRF, sanitizeBody, asyncHandler } = require('./middleware/security'); const { requireAuth } = require('./middleware/auth'); const { formatINR, getStatusColor } = require('./lib/india'); const app = express(); // Trust proxy app.set('trust proxy', 1); // Security headers app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://unpkg.com"], fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdn.jsdelivr.net"], imgSrc: ["'self'", "data:", "https:"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://react.dev"], connectSrc: ["'self'"], }, }, crossOriginEmbedderPolicy: false, })); app.use(compression()); // Pino HTTP logger (replaces requestLogger) app.use(pinoHttp({ logger })); // Rate limiting app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false, message: 'Too many requests, please try again later.', })); // Body parsing app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(cookieParser()); // Static files (ETag + 1day cache in production) app.use(express.static(path.join(__dirname, 'public'), { maxAge: config.nodeEnv === 'production' ? '1d' : 0, etag: true, lastModified: true, })); // View engine app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // Cache-busting asset version (changes on restart) const ASSET_VERSION = Date.now(); // Session app.use(session({ secret: config.session.secret, resave: false, saveUninitialized: false, cookie: { secure: config.nodeEnv === 'production', httpOnly: true, sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000, }, name: 'fd.sid', })); // CSRF app.use(setupCSRF); app.use(sanitizeBody); // Make helpers available to all views app.use((req, res, next) => { res.locals.user = req.session.user || null; res.locals.appName = 'FreightDesk'; res.locals.appNameHi = 'फ्रेटडेस्क'; res.locals.formatINR = formatINR; res.locals.getStatusColor = getStatusColor; res.locals.year = new Date().getFullYear(); res.locals._csrf = req.session._csrf; res.locals.assetVersion = ASSET_VERSION; next(); }); // CSRF validation for POST/PUT/DELETE app.use(validateCSRF); // ============================================================ // AUTH ROUTES (public) // ============================================================ app.get('/login', (req, res) => { if (req.session.user) return res.redirect('/'); res.render('pages/login', { error: null }); }); app.post('/login', asyncHandler(async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.render('pages/login', { error: 'Username and password required' }); } const { data: user } = await supabase .from('portal_users') .select('*') .eq('username', username) .eq('is_active', true) .single(); if (!user) { return res.render('pages/login', { error: 'Invalid username or password' }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return res.render('pages/login', { error: 'Invalid username or password' }); } req.session.user = { id: user.id, username: user.username, role: user.role, entity_id: user.entity_id, }; await supabase.from('portal_users').update({ last_login: new Date().toISOString() }).eq('id', user.id); // Redirect based on role if (user.role === 'admin') return res.redirect('/'); return res.redirect('/'); })); app.get('/logout', (req, res) => { req.session.destroy(); res.redirect('/login'); }); // ============================================================ // API ROUTES (for React dashboard + WhatsApp parser) // ============================================================ // WhatsApp parser API app.post('/api/parse-whatsapp', requireAuth, asyncHandler(async (req, res) => { const { parseWhatsAppMessage } = require('./services/parser'); const { message } = req.body; if (!message) return res.json({ error: 'No message provided' }); const result = parseWhatsAppMessage(message); res.json(result); })); // Dashboard stats API app.get('/api/stats', requireAuth, asyncHandler(async (req, res) => { const { data: loads } = await supabase.from('loads').select('*'); const allLoads = loads || []; const monthly = {}; for (const l of allLoads) { if (!l.date) continue; const d = new Date(l.date); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (!monthly[key]) monthly[key] = { freight: 0, commission: 0, count: 0 }; monthly[key].freight += l.freight_charged || 0; monthly[key].commission += l.commission || 0; monthly[key].count++; } res.json({ totalFreight: allLoads.reduce((s, l) => s + (l.freight_charged || 0), 0), totalCommission: allLoads.reduce((s, l) => s + (l.commission || 0), 0), totalPending: allLoads.reduce((s, l) => s + (l.pending_from_shipper || 0), 0), settledCount: allLoads.filter(l => ['settled', 'completed', 'commission received', 'reconciled'].includes(l.status)).length, totalLoads: allLoads.length, monthly: Object.entries(monthly).sort(([a], [b]) => a.localeCompare(b)), }); })); // ============================================================ // PAGE ROUTES (protected) // ============================================================ app.use('/', require('./routes/dashboard')); app.use('/setup', require('./routes/setup')); app.use('/loads', require('./routes/loads')); app.use('/shippers', require('./routes/shippers')); app.use('/vehicles', require('./routes/vehicles')); app.use('/payments', require('./routes/payments')); app.use('/reports', require('./routes/reports')); app.use('/audit-logs', require('./routes/audit')); app.use('/portal', require('./routes/portal')); // Health check app.get('/health', (req, res) => res.json({ status: 'ok', ts: Date.now() })); // Prometheus metrics app.get('/metrics', async (req, res) => { try { res.set('Content-Type', metrics.register.contentType); res.end(await metrics.register.metrics()); } catch (err) { logger.error({ err }, 'Failed to collect metrics'); res.status(500).end('Internal Server Error'); } }); // 404 app.use((req, res) => { res.status(404); res.render('pages/404'); }); // Error handler app.use((err, req, res, next) => { req.log.error({ err, url: req.url, method: req.method }, 'Unhandled error'); res.status(err.status || 500); res.render('pages/500', { error: config.nodeEnv === 'development' ? err.message : null }); }); const server = app.listen(config.port, '::', () => { logger.info({ port: config.port, env: config.nodeEnv }, '🚛 FreightDesk started'); console.log(` Press Ctrl+C to stop\n`); }); process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully...'); server.close(() => { console.log('Server closed.'); process.exit(0); }); }); module.exports = app;