Files
researchChat/server.js
T
nico 12f01b86d6 Checklisten-Häkchen in Dashboards speichern (localStorage)
Beim Ausliefern von /dashboards/*.html wird ein Skript injiziert, das den
Zustand der Checklisten-Checkboxen im localStorage speichert (Key =
Label-Text, pro Dashboard-URL) und beim Laden wiederherstellt, inkl.
update()-Aufruf für Zähler/Progress-Bar und 'Liste zurücksetzen'-Button.
Funktioniert in allen bestehenden und künftigen Dashboards; Marker
verhindert Doppel-Injektion, path.basename verhindert Traversal.
2026-08-28 12:28:58 +02:00

551 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
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
// ---------------------------------------------------------------------------
// Wird in jedes ausgelieferte Dashboard-HTML injiziert: speichert den Zustand
// der Checklisten-Häkchen im localStorage und stellt ihn beim Laden wieder her
// (inkl. „Liste zurücksetzen"-Button). Key = Label-Text der Checkbox, damit
// der Stand auch bei Layout-Änderungen stabil bleibt.
const CHECKLIST_SCRIPT = `<script>
(function () {
var KEY = 'rc-checks:' + location.pathname;
var boxes = document.querySelectorAll('input[type=checkbox]');
if (!boxes.length) return;
var saved = {};
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) {}
function labelFor(box, i) {
var li = box.closest('li');
var t = li ? li.querySelector('.check-text') : null;
var text = (t || li || {}).textContent || '';
return text.trim() || 'item-' + i;
}
boxes.forEach(function (box, i) {
var k = labelFor(box, i);
if (saved[k] !== undefined) box.checked = !!saved[k];
box.addEventListener('change', function () {
saved[k] = box.checked;
try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (e) {}
});
});
function refresh() {
if (typeof update === 'function') update();
}
// „Liste zurücksetzen"-Button am Anfang der ersten Checklisten-Section
var firstBox = boxes[0];
var section = firstBox.closest('section') || firstBox.closest('div') || document.body;
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '↺ Liste zurücksetzen';
btn.style.cssText =
'margin:0 0 12px;padding:6px 14px;font-size:12px;font-weight:600;border-radius:8px;' +
'border:1px solid rgba(100,116,139,.4);background:transparent;color:inherit;cursor:pointer;';
btn.addEventListener('click', function () {
if (!confirm('Alle Häkchen dieser Liste zurücksetzen?')) return;
try { localStorage.removeItem(KEY); } catch (e) {}
saved = {};
boxes.forEach(function (b) { b.checked = false; });
refresh();
});
section.insertBefore(btn, section.firstChild);
refresh();
})();
</script>`;
const CHECKLIST_MARKER = 'rc-checks';
app.get('/dashboards/:name.html', (req, res, next) => {
const name = path.basename(req.params.name);
const file = path.join(__dirname, 'dashboards', `${name}.html`);
fs.readFile(file, 'utf8', (err, html) => {
if (err) return next();
if (!html.includes(CHECKLIST_MARKER)) {
html = html.replace(/<\/body>/i, `${CHECKLIST_SCRIPT}\n</body>`);
}
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(html);
});
});
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);
});