177 lines
6.2 KiB
JavaScript
177 lines
6.2 KiB
JavaScript
'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'));
|