'use strict'; // --------------------------------------------------------------------------- // --gen-admin-key utility // --------------------------------------------------------------------------- if (process.argv.includes('--gen-admin-key')) { const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const key = crypto.randomBytes(32).toString('hex'); const hash = bcrypt.hashSync(key, 12); console.log('\nGenerated admin key (store securely — shown only once):'); console.log(' Key: ' + key); console.log(' Hash: ' + hash); console.log('\nAdd to your .env file:'); console.log(' PATCHPROBE_ADMIN_KEY_HASH=' + hash + '\n'); process.exit(0); } // --------------------------------------------------------------------------- // Server startup // --------------------------------------------------------------------------- const fs = require('fs'); const path = require('path'); const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const cookieParser = require('cookie-parser'); const config = require('./src/config'); const logger = require('./src/logger'); const { initDb, closeDb } = require('./src/db'); const authRouter = require('./src/routes/auth'); const enrollmentRouter = require('./src/routes/enrollments'); const scansRouter = require('./src/routes/scans'); const adminRouter = require('./src/routes/admin'); initDb(); const app = express(); // --------------------------------------------------------------------------- // Security headers // Relax CSP slightly so the built React SPA can load scripts/styles from self // --------------------------------------------------------------------------- app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], // Vite injects a small inline style in dev imgSrc: ["'self'", 'data:'], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], frameSrc: ["'none'"], upgradeInsecureRequests: config.nodeEnv === 'production' ? [] : null, }, }, crossOriginEmbedderPolicy: false, })); // --------------------------------------------------------------------------- // Rate limiting // --------------------------------------------------------------------------- app.use('/api/enrollments', rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many enrollment requests — try again in a minute' }, })); app.use('/api/scans', rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many scan requests — try again in a minute' }, })); app.use('/api/auth', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many auth requests — try again in a minute' }, })); // --------------------------------------------------------------------------- // Body parsing + cookie parsing // raw body capture must come before cookie-parser to avoid interference // --------------------------------------------------------------------------- app.use(express.json({ limit: '50mb', verify: (req, _res, buf) => { req.rawBody = buf; }, })); app.use(cookieParser()); // --------------------------------------------------------------------------- // Static files (built SPA) // --------------------------------------------------------------------------- app.use(express.static(path.join(__dirname, 'public'))); // --------------------------------------------------------------------------- // API routes // --------------------------------------------------------------------------- app.use('/api/auth', authRouter); app.use('/api/enrollments', enrollmentRouter); app.use('/api/scans', scansRouter); app.use('/api/admin', adminRouter); // API 404 app.use('/api/', (req, res) => { res.status(404).json({ error: 'Not found' }); }); // --------------------------------------------------------------------------- // SPA catch-all — serve index.html for all non-API GET requests // React Router handles client-side routing // --------------------------------------------------------------------------- app.get('*', (req, res) => { const indexPath = path.join(__dirname, 'public', 'index.html'); if (fs.existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(503).send( 'Dashboard not built — run: cd PatchProbe.Dashboard && npm install && npm run build', ); } }); // --------------------------------------------------------------------------- // Error handler // --------------------------------------------------------------------------- // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed') { return res.status(400).json({ error: 'Invalid JSON in request body' }); } logger.error({ err, method: req.method, path: req.path }, 'Unhandled error'); res.status(err.status ?? 500).json({ error: err.message ?? 'Internal server error' }); }); // --------------------------------------------------------------------------- // Startup // --------------------------------------------------------------------------- const server = app.listen(config.port, '0.0.0.0', () => { logger.info({ port: config.port, auth: config.authEnabled, env: config.nodeEnv }, 'PatchProbe Server started'); if (!config.adminKeyHash) { logger.warn('PATCHPROBE_ADMIN_KEY_HASH not set — run: node server.js --gen-admin-key'); } if (!process.env.PATCHPROBE_JWT_SECRET) { logger.warn('PATCHPROBE_JWT_SECRET not set — sessions will not survive server restarts'); } }); // --------------------------------------------------------------------------- // Graceful shutdown // --------------------------------------------------------------------------- function shutdown(signal) { logger.info({ signal }, 'Shutdown signal received'); server.close(() => { closeDb(); logger.info('Server stopped cleanly'); process.exit(0); }); setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 10_000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));