Backend (src/routes/learningAI.js): - POST /api/admin/learning/ai-generate * Accepts: topic text, uploaded file (PDF/TXT/MD/HTML), or Nextcloud WebDAV path * Extracts text from PDFs via pdf-parse; plain text read directly * Fetches WebDAV files using stored Nextcloud credentials * Prompts AI to return structured JSON: title, subject, body (HTML), questions[] * Strips code fences, falls back to regex JSON extraction if needed - POST /api/admin/learning/ai-refine — refine existing body with instructions - GET /api/admin/learning/webdav-browse — PROPFIND-based Nextcloud file browser - POST /api/admin/learning/webdav-path — save user's default browse path Frontend: - AI Generate button in CMS editor header (gradient purple→blue) - Panel with 3 source tabs: By Topic / Upload File / Nextcloud - Drag-and-drop file dropzone with DataTransfer API support - WebDAV file browser: navigate folders, select files, breadcrumb path - Options: question count, content type (article/quiz/pearl), model selector, instructions - Refine Current Body: prompt-driven AI rewrite of existing content - Generated content auto-fills title, subject, type, Tiptap body, quiz questions Settings: - Nextcloud section: "Learning Hub Default Browse Path" field (shown when connected) - Saves to webdav_learning_path column via /api/admin/learning/webdav-path DB: ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT
185 lines
7.4 KiB
JavaScript
185 lines
7.4 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.use('/api/admin/learning', require('./src/routes/learningAI'));
|
|
|
|
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('==========================================');
|
|
});
|