require('dotenv').config(); const { version: APP_VERSION } = require('./package.json'); 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); // Trust first proxy (Nginx/Caddy) — required for correct client IP in rate limiting and audit logs app.set('trust proxy', 1); // ============================================================ // SECURITY — Helmet with CSP // ============================================================ app.use(helmet({ crossOriginEmbedderPolicy: false, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], // 'wasm-unsafe-eval' required for WebAssembly (Whisper in-browser transcription) // cdn.jsdelivr.net required for @xenova/transformers worker script // 'unsafe-eval' needed for transformers.js dynamic imports in worker scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", 'https://cdn.jsdelivr.net', 'https://cdnjs.cloudflare.com', 'https://challenges.cloudflare.com'], 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', // HuggingFace CDN for Whisper model downloads 'https://huggingface.co', 'https://cdn-lfs.huggingface.co', 'https://cdn-lfs-us-1.huggingface.co', 'https://cdn-lfs-us-2.huggingface.co', // jsdelivr for worker importScripts 'https://cdn.jsdelivr.net', // Cloudflare Turnstile 'https://challenges.cloudflare.com'], workerSrc: ["'self'", 'blob:'], // Allow workers to load scripts from jsdelivr childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'], frameSrc: ["'self'", 'https://challenges.cloudflare.com'], objectSrc: ["'none'"], // Disable helmet's default upgrade-insecure-requests so tests can hit // the container over plain HTTP. In production, Caddy terminates TLS // and serves HTTPS — mixed-content is a non-issue there. upgradeInsecureRequests: null, } } })); // ============================================================ // CORS — restrict to APP_URL origin in production // ============================================================ var allowedOrigins = []; if (process.env.APP_URL) allowedOrigins.push(process.env.APP_URL.replace(/\/$/, '')); if (process.env.CORS_ORIGINS) process.env.CORS_ORIGINS.split(',').forEach(function(o) { allowedOrigins.push(o.trim().replace(/\/$/, '')); }); var IS_PROD = process.env.NODE_ENV === 'production' || !!process.env.APP_URL; if (IS_PROD && allowedOrigins.length === 0) { console.error('[FATAL] Production mode but no APP_URL or CORS_ORIGINS set. Refusing to run with open CORS.'); process.exit(1); } // Scoped to /api only — static assets (including type="module" scripts which // always send an Origin header) must not be subject to CORS checks. app.use('/api', cors({ origin: function(origin, callback) { // Allow requests with no origin (mobile apps, curl, server-to-server) if (!origin) return callback(null, true); // Development only: if no origins configured, allow all if (!IS_PROD && allowedOrigins.length === 0) return callback(null, true); if (allowedOrigins.indexOf(origin) !== -1) return callback(null, true); callback(new Error('CORS: origin not allowed')); }, credentials: true, exposedHeaders: ['X-TTS-Provider'] })); app.use(cookieParser()); // ============================================================ // BODY LIMITS — 10mb for JSON (chart review with many notes can be large), // transcribe uses multipart (25mb limit set in multer) // ============================================================ app.use(express.json({ limit: '10mb' })); // ============================================================ // RATE LIMITING // ============================================================ // General API: 200 req/min (increased — app makes many calls on login) app.use('/api/', rateLimit({ windowMs: 60000, max: 200, 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('/api/auth/resend-verification', rateLimit({ windowMs: 15 * 60 * 1000, max: 3, message: { error: 'Too many verification requests. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false })); // Post-login sensitive endpoints — protect against brute force with a stolen cookie. // Keyed per-IP; 20 attempts / 15 min covers legitimate use, blocks TOTP brute force (10^6 space). var sensitiveAuthLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, message: { error: 'Too many attempts. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false }); app.use('/api/auth/change-password', sensitiveAuthLimiter); app.use('/api/auth/setup-2fa', sensitiveAuthLimiter); app.use('/api/auth/verify-2fa', sensitiveAuthLimiter); app.use('/api/auth/disable-2fa', sensitiveAuthLimiter); // Serve .well-known/assetlinks.json for TWA verification (must be before static) app.get('/.well-known/assetlinks.json', (req, res) => { res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.sendFile(path.join(__dirname, 'public', '.well-known', 'assetlinks.json')); }); // ============================================================ // CACHE-BUSTING VERSION STAMP // ============================================================ // Compute a per-boot BUILD_ID (short hex). Inject it as ?v=BUILD_ID // on every local /js/*.js and /css/*.css reference in index.html so // browsers always fetch fresh JS/CSS after a deploy instead of // serving from the 1-hour cache. var fs = require('fs'); var crypto = require('crypto'); var BUILD_ID = crypto.randomBytes(4).toString('hex'); try { var gitHead = fs.readFileSync(path.join(__dirname, '.git/HEAD'), 'utf8').trim(); if (gitHead.indexOf('ref:') === 0) { var refPath = gitHead.split(' ')[1]; BUILD_ID = fs.readFileSync(path.join(__dirname, '.git', refPath), 'utf8').trim().slice(0, 7); } else { BUILD_ID = gitHead.slice(0, 7); } } catch (e) { // Non-git environment (Docker image) — use /app/BUILD_ID file if present, // otherwise stick with the random-on-boot value generated above. try { BUILD_ID = fs.readFileSync(path.join(__dirname, 'BUILD_ID'), 'utf8').trim() || BUILD_ID; } catch (_) {} } console.log('🔖 Build ID:', BUILD_ID); // Template index.html on each request if the file changed (mtime-based). // Avoids a stale in-memory copy after static edits / script additions while // still being essentially free — just an fs.stat per request, zero template // rebuild when mtime hasn't changed. var INDEX_PATH = path.join(__dirname, 'public', 'index.html'); var _indexCached = { mtime: 0, html: null }; function getTemplatedIndex() { try { var st = fs.statSync(INDEX_PATH); if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html; var raw = fs.readFileSync(INDEX_PATH, 'utf8'); _indexCached.html = raw.replace( /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g, '$1$2?v=' + BUILD_ID + '$3' ); _indexCached.mtime = st.mtimeMs; return _indexCached.html; } catch (e) { console.warn('[build-id] index.html read failed:', e.message); return null; } } // Prime once at boot so first request is fast getTemplatedIndex(); app.get(['/', '/index.html'], function(req, res) { var html = getTemplatedIndex(); if (!html) return res.sendFile(INDEX_PATH); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('X-Build-Id', BUILD_ID); res.send(html); }); // Public endpoint for cache-bust debugging + build-info app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); }); 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')); app.use('/api/auth', require('./src/routes/oidc')); // 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')); app.use('/api/admin', require('./src/routes/adminMilestones')); // 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, DEFAULT_MODEL } = require('./src/utils/models'); var db = require('./src/db/database'); var models = await getAvailableModelsWithOverrides(db); var defaultOverride = await db.getSetting('models.default'); res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL }); } catch(e) { res.json({ models: getAvailableModels(), provider: modelsProvider }); } }); const { activeProvider } = require('./src/utils/ai'); // Public health: minimal — just confirm the server is up. app.get('/api/health', (req, res) => { res.json({ ok: true }); }); // Detailed health: admin-only — exposes which providers are configured. const { authMiddleware: _hcAuth, adminMiddleware: _hcAdmin } = require('./src/middleware/auth'); app.get('/api/health/detailed', _hcAuth, _hcAdmin, (req, res) => { res.json({ status: 'running', version: APP_VERSION, 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', vertex: process.env.GOOGLE_VERTEX_PROJECT ? 'configured' : 'not configured', litellm: process.env.LITELLM_API_BASE ? 'configured' : 'not configured', whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing', tts: process.env.LITELLM_API_BASE ? 'litellm' : (process.env.ELEVENLABS_API_KEY ? 'elevenlabs' : 'none') }); }); // 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/peGuide')); app.use('/api', require('./src/routes/extensions')); 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/documents')); app.use('/api', require('./src/routes/audioBackups')); app.use('/api', require('./src/routes/billing')); app.use('/api/sessions', require('./src/routes/sessions')); app.use('/api', require('./src/routes/wellVisit')); app.use('/api', require('./src/routes/sickVisit')); app.use('/api/user', require('./src/routes/userPreferences')); 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 v' + APP_VERSION); console.log('=========================================='); console.log('🌐 http://localhost:' + PORT); console.log('🤖 Provider: ' + activeProvider); console.log('=========================================='); }); // Graceful shutdown — drain in-flight requests (including note writes) // before Docker kills us. Without this, a `docker restart` mid-save // truncates the write. Express accepts SIGTERM immediately by default. var shuttingDown = false; function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; console.log('[' + signal + '] starting graceful shutdown…'); // Stop accepting new connections; finish the ones already in-flight. server.close(async function(err) { if (err) console.error('[shutdown] server.close error:', err.message); console.log('[shutdown] HTTP server closed.'); // Drain batched audit/api/access log queues before the DB pool ends. try { var queues = require('./src/utils/auditQueue'); if (queues && typeof queues.drainAll === 'function') { await queues.drainAll(); console.log('[shutdown] Audit queues flushed.'); } } catch (e) { console.error('[shutdown] audit drain:', e.message); } // Close the Postgres pool so pending queries finish/reject cleanly. try { var dbMod = require('./src/db/database'); if (dbMod && dbMod.pool && typeof dbMod.pool.end === 'function') { await dbMod.pool.end(); console.log('[shutdown] DB pool drained.'); } } catch (e) {} process.exit(0); }); // Hard deadline — Docker sends SIGKILL after 10s by default, so beat it // to the punch with a clean exit if we're still hanging. setTimeout(function() { console.error('[shutdown] forcing exit after 9s'); process.exit(1); }, 9000).unref(); } process.on('SIGTERM', function() { shutdown('SIGTERM'); }); process.on('SIGINT', function() { shutdown('SIGINT'); });