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'"], scriptSrcAttr: ["'none'"], 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.use('/api/admin/learning', require('./src/routes/learningAI')); // User-level preference: save WebDAV learning path (auth only, not moderator-only) (function() { var { authMiddleware } = require('./src/middleware/auth'); var db = require('./src/db/database'); app.post('/api/user/webdav-path', authMiddleware, async function(req, res) { try { await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [req.body.path || null, req.user.id]); res.json({ success: true }); } catch(e) { res.status(500).json({ error: e.message }); } }); })(); 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')); }); // 404 handler — must be last app.use((req, res) => { res.status(404).sendFile(path.join(__dirname, 'public', '404.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('=========================================='); });