329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
const path = require('path');
|
||
const http = require('http');
|
||
const crypto = require('crypto');
|
||
const express = require('express');
|
||
const webpush = require('web-push');
|
||
|
||
const { config } = require('./lib/config');
|
||
const db = require('./lib/db');
|
||
const queue = require('./lib/queue');
|
||
const { runResearchJob } = require('./lib/research');
|
||
const { closeBrowser } = require('./lib/tools');
|
||
|
||
const app = express();
|
||
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();
|
||
});
|
||
|
||
app.post('/api/login', (req, res) => {
|
||
const { name, password } = req.body || {};
|
||
const pw = String(config.appPassword || '');
|
||
if (!pw || !password || password.length !== pw.length) {
|
||
return res.status(401).json({ error: 'Falsches Passwort' });
|
||
}
|
||
const a = crypto.createHash('sha256').update(password).digest();
|
||
const b = crypto.createHash('sha256').update(pw).digest();
|
||
if (!crypto.timingSafeEqual(a, b)) {
|
||
return res.status(401).json({ error: 'Falsches Passwort' });
|
||
}
|
||
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);
|
||
res.json(session);
|
||
});
|
||
|
||
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);
|
||
db.deleteSession(session.id);
|
||
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();
|
||
if (!text) return res.status(400).json({ error: 'Leere Nachricht' });
|
||
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 });
|
||
if (session.title === 'Neue Recherche') {
|
||
db.setSessionTitle(session.id, text.slice(0, 70));
|
||
}
|
||
db.setSessionStatus(session.id, 'queued');
|
||
|
||
const job = {
|
||
sessionId: session.id,
|
||
async run() {
|
||
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);
|
||
},
|
||
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
|
||
};
|
||
|
||
await runResearchJob(
|
||
{ apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl },
|
||
db.getSession(session.id),
|
||
text,
|
||
emit,
|
||
db
|
||
);
|
||
},
|
||
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'), { maxAge: '1h' }));
|
||
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'));
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
if (!config.apiKey) {
|
||
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – Recherche wird 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);
|
||
});
|