- App-layer AES-256-GCM crypto helper (src/utils/crypto.js) - Nextcloud tokens encrypted at rest; transparent migration on next use - Audio backups encrypted at rest (version byte 0x01 envelope); legacy rows still decrypt as-is until overwritten - argon2id password hashing via src/utils/passwords.js with bcrypt fallback; bcrypt hashes rehashed to argon2id on next successful login. argon2 package is optional — server keeps running with bcrypt only until npm install adds the native dep - PHI redactor for audit log details (src/utils/redact.js) — strips SSN, phone, email, DoB, long IDs; caps at 500 chars; detects note bodies - DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in Learning Hub content rendering - SRI integrity hashes added for Font Awesome CSS and Chart.js - Magic-byte file-type verification on document uploads (src/utils/fileType.js) - Generic 500 error responses via src/utils/errors.js applied to nextcloud and audioBackups; full detail still logged server-side - DATA_ENCRYPTION_KEY env documented in .env.example Deploy: requires rebuild of the container image to pick up the new files and `npm install` (adds argon2). Existing users keep working because bcrypt stays available and crypto helpers pass through plaintext when the key is not yet set in dev.
261 lines
12 KiB
JavaScript
261 lines
12 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);
|
|
|
|
// 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'"],
|
|
}
|
|
}
|
|
}));
|
|
|
|
// ============================================================
|
|
// 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);
|
|
}
|
|
app.use(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'));
|
|
});
|
|
|
|
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: '6.0.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',
|
|
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/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 v6.0');
|
|
console.log('==========================================');
|
|
console.log('🌐 http://localhost:' + PORT);
|
|
console.log('🤖 Provider: ' + activeProvider);
|
|
console.log('==========================================');
|
|
});
|