405 lines
18 KiB
JavaScript
405 lines
18 KiB
JavaScript
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'"],
|
|
scriptSrc: ["'self'", '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',
|
|
// Cloudflare Turnstile
|
|
'https://challenges.cloudflare.com'],
|
|
workerSrc: ["'self'", 'blob:'],
|
|
childSrc: ["'self'", 'blob:'],
|
|
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 default; configurable so the e2e container
|
|
// can raise it without weakening production.
|
|
app.use('/api/', rateLimit({
|
|
windowMs: 60000,
|
|
max: parseInt(process.env.API_RATE_LIMIT_MAX || '200', 10),
|
|
message: { error: 'Too many requests' },
|
|
standardHeaders: true, legacyHeaders: false
|
|
}));
|
|
|
|
// Auth routes: 10 attempts/15min per IP (brute-force protection).
|
|
// LOGIN_RATE_LIMIT_MAX env var lets the e2e container raise it to a
|
|
// level that accommodates multi-worker Playwright runs without
|
|
// weakening production.
|
|
app.use('/api/auth/login', rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 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('/vendor/markdown-it', express.static(path.join(__dirname, 'node_modules', 'markdown-it', 'dist'), {
|
|
setHeaders: function(res) {
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
}
|
|
}));
|
|
app.use('/vendor/marked', express.static(path.join(__dirname, 'node_modules', 'marked', 'lib'), {
|
|
setHeaders: function(res) {
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
}
|
|
}));
|
|
app.use('/vendor/katex', express.static(path.join(__dirname, 'node_modules', 'katex', 'dist'), {
|
|
setHeaders: function(res) {
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
}
|
|
}));
|
|
app.use('/vendor/mathjax', express.static(path.join(__dirname, 'node_modules', 'mathjax-full', 'es5'), {
|
|
setHeaders: function(res) {
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
}
|
|
}));
|
|
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'));
|
|
app.use('/api/admin/docs', require('./src/routes/adminDocs'));
|
|
|
|
// 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');
|
|
if (defaultOverride && !models.find(function(m) { return m.id === defaultOverride; })) {
|
|
models.push({ id: defaultOverride, name: defaultOverride + ' (saved default)', tag: 'SAVED' });
|
|
}
|
|
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/notes'));
|
|
app.use('/api', require('./src/routes/diagrams'));
|
|
app.use('/api', require('./src/routes/clinicalAssistant'));
|
|
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', require('./src/routes/edEncounters'));
|
|
app.use('/api', require('./src/routes/dontMiss'));
|
|
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');
|
|
// Clear the hourly cleanup interval first — otherwise its handle
|
|
// keeps the event loop alive past process.exit(0) and Docker
|
|
// ends up SIGKILL'ing us mid-shutdown at the 10s hard limit.
|
|
if (dbMod && dbMod._cleanupInterval) clearInterval(dbMod._cleanupInterval);
|
|
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'); });
|