- deepseek-flash als einziges Modell mit Vision, Preise aktualisiert - OpenRouter/GLM samt Code, Config und Env-Variablen entfernt - Legacy-Modell-IDs in Sessions werden automatisch migriert - browse lädt Produktbilder lokal nach dashboards/ (Dedupe + Cleanup) - Vision-Prompt: Workflow für visuelle Produktsuche, max. ~5 Screenshots
548 lines
18 KiB
JavaScript
548 lines
18 KiB
JavaScript
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.`,
|
||
});
|
||
}
|
||
}
|
||
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.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);
|
||
});
|