pediatric-ai-scribe-v3/server.js
Daniel Onyejesi 4064ccf516 Fix quiz SQL bug, security hardening, componentize HTML, speed improvements
- Fix: "missing FROM-clause entry for table q" in quiz submission query
- Security: replace broken sanitizeHtml with allowlist-based sanitizer (prevents stored XSS)
- Security: whitelist table names in uniqueSlug() to prevent SQL injection pattern
- Security: replace raw err.message responses with generic errors in learning routes
- Security: cap admin logs query LIMIT to 500
- Refactor: break 1685-line index.html into 12 lazy-loaded component files (280 lines shell)
- Speed: add preconnect hints for Google Fonts and CDN
- Speed: defer all script tags for faster initial paint
- Speed: lazy-load tab HTML on activation (only active tab's DOM is parsed)
- Update: replace HIPAA notice with institution-guidelines cautionary note
2026-03-23 20:01:54 -04:00

179 lines
7.2 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const path = require('path');
const http = require('http');
const loggingMiddleware = require('./src/middleware/logging');
const app = express();
const server = http.createServer(app);
// ============================================================
// SECURITY — Helmet with CSP
// ============================================================
app.use(helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"], // allow onclick/onchange attribute handlers
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'blob:'],
mediaSrc: ["'self'", 'blob:'],
connectSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://www.google.com', 'wss://www.google.com', 'https://clinicaltables.nlm.nih.gov'],
workerSrc: ["'self'"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
}
}
}));
// ============================================================
// CORS — restrict to APP_URL origin in production
// ============================================================
var allowedOrigin = process.env.APP_URL ? process.env.APP_URL.replace(/\/$/, '') : null;
app.use(cors({
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) return callback(null, true);
// In development (no APP_URL set) allow all
if (!allowedOrigin) return callback(null, true);
if (origin === allowedOrigin) return callback(null, true);
callback(new Error('CORS: origin not allowed'));
},
credentials: true
}));
app.use(cookieParser());
// ============================================================
// BODY LIMITS — 1mb for JSON, transcribe uses multipart (no limit here)
// ============================================================
app.use(express.json({ limit: '1mb' }));
// ============================================================
// RATE LIMITING
// ============================================================
// General API: 60 req/min
app.use('/api/', rateLimit({
windowMs: 60000, max: 60,
message: { error: 'Too many requests' },
standardHeaders: true, legacyHeaders: false
}));
// Auth routes: 10 attempts/15min per IP (brute-force protection)
app.use('/api/auth/login', rateLimit({
windowMs: 15 * 60 * 1000, max: 10,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true, legacyHeaders: false
}));
app.use('/api/auth/register', rateLimit({
windowMs: 60 * 60 * 1000, max: 5,
message: { error: 'Too many registration attempts.' },
standardHeaders: true, legacyHeaders: false
}));
app.use('/api/auth/forgot-password', rateLimit({
windowMs: 60 * 60 * 1000, max: 5,
message: { error: 'Too many password reset attempts.' },
standardHeaders: true, legacyHeaders: false
}));
app.use(loggingMiddleware);
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
// Never cache HTML — ensures new deployments are picked up immediately
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else if (filePath.match(/\.(js|css)$/)) {
// Short cache for JS/CSS — 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600');
} else if (filePath.indexOf('/components/') !== -1) {
// Component HTML — 1 hour cache
res.setHeader('Cache-Control', 'public, max-age=3600');
}
}
}));
// Routes
app.use('/api/auth', require('./src/routes/auth'));
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
// (moderators need access to /api/admin/learning but not other /api/admin routes)
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
app.use('/api/admin', require('./src/routes/admin'));
app.use('/api/admin', require('./src/routes/adminConfig'));
// Public endpoints — must come BEFORE any router that applies authMiddleware to /api/*
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
app.get('/api/models', async (req, res) => {
try {
var { getAvailableModelsWithOverrides } = require('./src/utils/models');
var db = require('./src/db/database');
var models = await getAvailableModelsWithOverrides(db);
res.json({ models: models, provider: modelsProvider });
} catch(e) {
res.json({ models: getAvailableModels(), provider: modelsProvider });
}
});
const { activeProvider } = require('./src/utils/ai');
app.get('/api/health', (req, res) => {
res.json({
status: 'running', version: '3.1.0', provider: activeProvider,
timestamp: new Date().toISOString(),
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured',
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing'
});
});
// Learning Hub routes (all authenticated users can read content & take quizzes)
app.use('/api/learning', require('./src/routes/learningHub'));
// Authenticated feature routes
app.use('/api', require('./src/routes/transcribe'));
app.use('/api', require('./src/routes/hpi'));
app.use('/api', require('./src/routes/hospitalCourse'));
app.use('/api', require('./src/routes/chartReview'));
app.use('/api', require('./src/routes/milestones'));
app.use('/api', require('./src/routes/soap'));
app.use('/api', require('./src/routes/tts'));
app.use('/api', require('./src/routes/nextcloud'));
app.use('/api', require('./src/routes/refine'));
app.use('/api', require('./src/routes/logs'));
app.use('/api', require('./src/routes/encounters'));
app.use('/api', require('./src/routes/memories'));
app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.get('*', (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Load prompt DB overrides after DB is ready (3s grace period)
const PROMPTS = require('./src/utils/prompts');
const db = require('./src/db/database');
setTimeout(() => { PROMPTS.loadFromDb(db); }, 3000);
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log('');
console.log('==========================================');
console.log('🏥 PEDIATRIC AI SCRIBE v3.1');
console.log('==========================================');
console.log('🌐 http://localhost:' + PORT);
console.log('🤖 Provider: ' + activeProvider);
console.log('==========================================');
});