const path = require('path'); const http = require('http'); const crypto = require('crypto'); const express = require('express'); const webpush = require('web-push'); const { config, getModel, getModels } = require('./lib/config'); const db = require('./lib/db'); const queue = require('./lib/queue'); const { runResearchJob } = require('./lib/research'); const { closeBrowser, deleteSessionFiles } = require('./lib/tools'); const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 1); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true })); const server = http.createServer(app); // --------------------------------------------------------------------------- // Auth (eine Anmeldung: Name + gemeinsames Passwort, signiertes Cookie) // --------------------------------------------------------------------------- function signToken(payload) { const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); const sig = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url'); return `${body}.${sig}`; } function verifyToken(token) { try { const [body, sig] = token.split('.'); const expected = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url'); if (sig !== expected) return null; const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); if (payload.exp < Date.now()) return null; return payload; } catch { return null; } } function cookieOptions(req) { return { httpOnly: true, sameSite: 'lax', secure: req.secure, maxAge: 30 * 24 * 3600 * 1000, path: '/', }; } function requireAuth(req, res, next) { const payload = verifyToken(req.cookies?.research_auth); if (!payload) return res.status(401).json({ error: 'Nicht angemeldet' }); req.user = payload; next(); } app.use((req, res, next) => { req.cookies = Object.fromEntries( (req.headers.cookie || '') .split(';') .map((c) => c.trim().split('=')) .filter(([k]) => k) .map(([k, ...v]) => [k, decodeURIComponent(v.join('='))]) ); next(); }); // --------------------------------------------------------------------------- // Login-Rate-Limit: X Fehlversuche pro IP → Y Minuten Sperre // --------------------------------------------------------------------------- const loginAttempts = new Map(); // ip -> { count, lockedUntil } function checkLoginLock(ip) { const entry = loginAttempts.get(ip); if (entry && entry.lockedUntil > Date.now()) { return Math.ceil((entry.lockedUntil - Date.now()) / 1000); } return 0; } function registerLoginFail(ip) { const entry = loginAttempts.get(ip) || { count: 0, lockedUntil: 0 }; entry.count += 1; if (entry.count >= config.loginMaxAttempts) { entry.lockedUntil = Date.now() + config.loginLockMinutes * 60 * 1000; entry.count = 0; } loginAttempts.set(ip, entry); } function registerLoginOk(ip) { loginAttempts.delete(ip); } // Alte Einträge aufräumen (1×/Stunde) setInterval(() => { const now = Date.now(); for (const [ip, entry] of loginAttempts) { if (entry.lockedUntil <= now && entry.count === 0) loginAttempts.delete(ip); } }, 60 * 60 * 1000).unref(); app.post('/api/login', (req, res) => { const { name, password } = req.body || {}; const ip = req.ip; const lockSeconds = checkLoginLock(ip); if (lockSeconds > 0) { return res .status(429) .json({ error: `Zu viele Fehlversuche – bitte in ${lockSeconds} Sekunden erneut versuchen.` }); } const pw = String(config.appPassword || ''); const valid = pw && password && password.length === pw.length && crypto.timingSafeEqual( crypto.createHash('sha256').update(String(password)).digest(), crypto.createHash('sha256').update(pw).digest() ); if (!valid) { registerLoginFail(ip); return res.status(401).json({ error: 'Falsches Passwort' }); } registerLoginOk(ip); const safeName = String(name || 'Nutzer').slice(0, 40); const token = signToken({ n: safeName, exp: Date.now() + 30 * 24 * 3600 * 1000 }); res.cookie('research_auth', token, cookieOptions(req)); res.json({ name: safeName }); }); app.post('/api/logout', (req, res) => { res.clearCookie('research_auth', cookieOptions(req)); res.json({ ok: true }); }); // --------------------------------------------------------------------------- // Sessions & Nachrichten // --------------------------------------------------------------------------- app.get('/api/me', requireAuth, (req, res) => res.json({ name: req.user.n })); app.get('/api/sessions', requireAuth, (req, res) => { const q = queue.state(); const sessions = db.listSessions().map((s) => ({ ...s, job: s.status === 'running' ? 'running' : q.queued.includes(s.id) ? 'queued' : 'idle', })); res.json(sessions); }); app.post('/api/sessions', requireAuth, (req, res) => { const session = db.createSession(req.user.n, 'Neue Recherche', getModel(config.model).id); res.json(session); }); app.patch('/api/sessions/:id', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } const modelId = String(req.body?.model || ''); if (!getModels().some((m) => m.id === modelId)) { return res.status(400).json({ error: 'Unbekanntes Modell' }); } db.setSessionModel(session.id, modelId); res.json(db.getSession(session.id)); }); app.get('/api/models', requireAuth, (req, res) => { res.json({ default: getModel(config.model).id, models: getModels().map((m) => ({ id: m.id, label: m.label, provider: m.provider, vision: m.vision, costNote: m.costNote || null, })), }); }); app.get('/api/sessions/:id', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session || (session.owner_name !== req.user.n && !session.share_token)) { return res.status(404).json({ error: 'Session nicht gefunden' }); } res.json({ session, messages: db.getSessionMessages(session.id) }); }); app.get('/api/sessions/:id/share', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session nicht gefunden' }); const url = `/share/${session.share_token}`; res.json({ url, fullUrl: `${req.protocol}://${req.get('host')}${url}` }); }); app.delete('/api/sessions/:id', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } queue.cancelQueued(session.id); queue.abortRunning(session.id); db.deleteSession(session.id); deleteSessionFiles(session.id); res.json({ ok: true }); }); app.post('/api/sessions/:id/abort', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session nicht gefunden' }); } if (queue.queuedBefore(session.id) >= 0) { // Job wartet noch in der Queue → direkt entfernen und aufräumen queue.cancelQueued(session.id); } else if (queue.hasController(session.id)) { // Laufender Job → Controller auslösen, der Job räumt selbst auf queue.abortRunning(session.id); return res.json({ ok: true }); } // Wartender Job oder veralteter Job (z.B. nach Server-Neustart, ohne // laufenden Prozess): Zustand und UI direkt zurücksetzen db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' }); db.setSessionStatus(session.id, 'done'); emitToSession(session.id, 'aborted', {}); emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' }); res.json({ ok: true }); }); app.get('/api/share/:token', (req, res) => { const session = db.getSessionByShare(req.params.token); if (!session) return res.status(404).json({ error: 'Link ungültig' }); res.json({ session, messages: db.getSessionMessages(session.id) }); }); // --------------------------------------------------------------------------- // Job starten + SSE-Streaming // --------------------------------------------------------------------------- function emitToSession(sessionId, type, data) { queue.events.emit('session-event', sessionId, type, data); } app.post('/api/sessions/:id/messages', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session) return res.status(404).json({ error: 'Session nicht gefunden' }); const text = String(req.body?.text || '').trim(); const image = typeof req.body?.image === 'string' && req.body.image ? req.body.image : null; if (!text && !image) return res.status(400).json({ error: 'Leere Nachricht' }); if (image) { if (!/^data:image\/(jpeg|png|webp);base64,/.test(image)) { return res.status(400).json({ error: 'Ungültiges Bild (JPEG/PNG/WebP als Data-URL erwartet)' }); } if (image.length > 4000000) { return res.status(400).json({ error: 'Bild zu groß (max. ~3 MB)' }); } const model = getModel(session.model); if (!model.vision) { return res.status(400).json({ error: `Das Modell "${model.label}" kann keine Bilder verarbeiten – bitte auf ein Modell mit Bilderkennung wechseln (z.B. GLM 5.3 Flash).`, }); } } if (session.status === 'running' || session.status === 'queued') { return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' }); } if (queue.state().running || queue.state().queued.length > 0) { return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' }); } db.createMessage(session.id, { role: 'user', content: text, image }); if (session.title === 'Neue Recherche') { db.setSessionTitle(session.id, text.slice(0, 70)); } db.setSessionStatus(session.id, 'queued'); const controller = queue.registerController(session.id); const job = { sessionId: session.id, async run() { try { db.setSessionStatus(session.id, 'running'); emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' }); const emit = { token: (t) => emitToSession(session.id, 'token', { text: t }), thinking: (t) => emitToSession(session.id, 'thinking', { text: t }), progress: (icon, text) => emitToSession(session.id, 'progress', { icon, text }), step: (text) => emitToSession(session.id, 'step', { text }), done: (result) => { emitToSession(session.id, 'done', result); emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' }); sendPush(session, result); }, aborted: () => { emitToSession(session.id, 'aborted', {}); emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' }); }, error: (msg) => emitToSession(session.id, 'error', { text: msg }), }; await runResearchJob( config, db.getSession(session.id), text, emit, db, controller.signal ); } finally { queue.clearController(session.id); } }, async onError(e) { console.error('Job-Fehler:', e); db.setSessionStatus(session.id, 'done'); emitToSession(session.id, 'error', { text: `Fehler: ${e.message}` }); emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'error' }); }, }; queue.enqueue(job); res.status(202).json({ ok: true, status: 'queued' }); }); app.get('/api/sessions/:id/stream', requireAuth, (req, res) => { const session = db.getSession(req.params.id); if (!session) return res.status(404).end(); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); const send = (type, data) => { if (res.writableEnded) return; res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); }; const onEvent = (sessionId, type, data) => { if (sessionId === session.id) send(type, data); }; queue.events.on('session-event', onEvent); const q = queue.state(); const jobStatus = session.status === 'running' ? 'running' : q.queued.includes(session.id) ? 'queued' : 'idle'; send('status', { sessionStatus: session.status, jobStatus }); const heartbeat = setInterval(() => send('ping', { t: Date.now() }), 25000); req.on('close', () => { clearInterval(heartbeat); queue.events.off('session-event', onEvent); }); }); // --------------------------------------------------------------------------- // Web Push // --------------------------------------------------------------------------- webpush.setVapidDetails( 'mailto:research@localhost', config.vapidPublicKey, config.vapidPrivateKey ); app.get('/api/push/vapid-key', (req, res) => res.json({ key: config.vapidPublicKey })); app.post('/api/push/subscribe', requireAuth, (req, res) => { const { endpoint, keys } = req.body || {}; if (!endpoint || !keys?.p256dh || !keys?.auth) { return res.status(400).json({ error: 'Ungültige Subscription' }); } db.upsertPushSub(req.user.n, endpoint, keys); res.json({ ok: true }); }); function sendPush(session, result) { const payload = JSON.stringify({ title: '✅ Recherche fertig', body: `"${session.title}" – Dashboard ist bereit`, url: `/s/${session.id}`, }); for (const sub of db.listPushSubs()) { webpush .sendNotification( { endpoint: sub.endpoint, keys: JSON.parse(sub.keys) }, payload, { TTL: 3600 } ) .then(() => console.log('Push gesendet an', sub.endpoint)) .catch((e) => { if (e.statusCode === 404 || e.statusCode === 410) { db.removePushSub(sub.endpoint); } else { console.error('Push-Fehler:', e.message); } }); } } // --------------------------------------------------------------------------- // Statische Dateien & Dashboard-Dateien // --------------------------------------------------------------------------- app.use( '/dashboards', express.static(path.join(__dirname, 'dashboards'), { // Kein Caching: Dashboards werden bei Folge-Nachrichten überschrieben, // der Browser soll immer die aktuelle Version sehen. maxAge: 0, setHeaders: (res, filePath) => { if (filePath.endsWith('.gpx')) res.setHeader('Content-Type', 'application/gpx+xml'); if (filePath.endsWith('.tour.json')) res.setHeader('Content-Type', 'application/json'); }, }) ); app.use('/manifest.json', express.static(path.join(__dirname, 'public', 'manifest.json'))); app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js'))); app.use('/icons', express.static(path.join(__dirname, 'public', 'icons'))); app.use(express.static(path.join(__dirname, 'public'))); app.get('/share/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'share.html')); }); app.get('/s/:id', requireAuth, (req, res) => { res.redirect(`/?session=${req.params.id}`); }); app.get('/', (req, res) => { const payload = verifyToken(req.cookies?.research_auth); if (!payload) return res.sendFile(path.join(__dirname, 'public', 'login.html')); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // --------------------------------------------------------------------------- // Nach einem Server-Neustart hängengebliebene Jobs (running/queued in der DB) // zurücksetzen – die Jobs existieren im Speicher nicht mehr. const staleCount = db.markStaleJobs(); if (staleCount > 0) { console.log(`INFO: ${staleCount} verwaiste Recherche(n) nach Neustart zurückgesetzt.`); } if (!config.apiKey) { console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – DeepSeek-Modelle werden nicht funktionieren.'); } if (!config.openrouterApiKey) { console.error('WARNUNG: OPENROUTER_API_KEY fehlt in .env – OpenRouter-Modelle (z.B. GLM 5.3 Flash) werden nicht funktionieren.'); } if (!config.appPassword) { console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.'); } server.listen(config.port, () => { console.log(`Research WebApp läuft auf http://localhost:${config.port}`); }); process.on('SIGINT', async () => { await closeBrowser(); process.exit(0); }); process.on('SIGTERM', async () => { await closeBrowser(); process.exit(0); });