code-kumpel/brain/server.js
jodaaisupport-commits ac509d54de
Some checks are pending
CI / test (push) Waiting to run
Brain-CORS: code-kumpel.jodabreit-ai.de erlauben
2026-10-31 23:44:56 +01:00

198 lines
10 KiB
JavaScript
Raw Permalink 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.

#!/usr/bin/env node
/**
* Minimaler Brain-Server (#88).
* POST /chat { message, buddy, memories } → { reply }
* Optional: OPENAI_API_KEY / OLLAMA_URL – sonst Stub-Antwort aus Memories.
*/
const http = require('http');
const PORT = Number(process.env.PORT || 8787);
const sync = require('./sync');
const kv = require('./db').createStore();
let features = null;
const auth = require('./auth').createAuth(kv, { onRegister: (u) => features && features.sendVerify(u) });
const mailer = require('./mailer').createMailer();
features = require('./features').createFeatures(kv, auth, mailer);
console.log('[code-kumpel-brain] mail:', mailer.kind || 'aus', '| oauth:', features.config().body.oauth.join(',') || 'aus');
console.log('[code-kumpel-brain] storage:', kv.kind);
const ALLOWED_ORIGINS = new Set(['https://code-kumpel.vercel.app', 'https://code-kumpel.onrender.com', 'https://code-kumpel.jodabreit-ai.de', 'https://localhost', 'http://localhost', 'capacitor://localhost', 'http://localhost:8080', 'http://127.0.0.1:8080', 'http://localhost:3000', 'http://localhost:5173']
.concat((process.env.CORS_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean)));
const RATE = new Map(); // ip -> timestamps
const MAX_PER_MIN = 30;
function rateOk(ip) {
const now = Date.now();
const arr = (RATE.get(ip) || []).filter((t) => now - t < 60_000);
if (arr.length >= MAX_PER_MIN) {
RATE.set(ip, arr);
return false;
}
arr.push(now);
RATE.set(ip, arr);
return true;
}
function cors(req, res) {
const o = req.headers.origin;
// /chat + /health bleiben offen (Extension/VS Code); Konto-/Sync-Endpunkte nur für bekannte Origins
const isAuthPath = /^\/(auth|sync|backups|leaderboard)(\/|\?|$)/.test(req.url || '');
if (!isAuthPath) res.setHeader('Access-Control-Allow-Origin', '*');
else if (o && (ALLOWED_ORIGINS.has(o) || /^chrome-extension:\/\//.test(o) || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o))) { res.setHeader('Access-Control-Allow-Origin', o); res.setHeader('Vary', 'Origin'); }
res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, DELETE, OPTIONS, GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Sync-Code, Authorization');
res.setHeader('Access-Control-Max-Age', '600');
}
const send = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
function clientIp(req) { const f = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); return f || req.socket.remoteAddress || 'local'; }
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
} catch (e) {
reject(e);
}
});
req.on('error', reject);
});
}
function stubReply(body) {
const name = (body.buddy && body.buddy.name) || 'Byte';
const mems = body.memories || [];
if (!mems.length) {
return `${name} (Brain-Stub): Dazu hab ich lokal nichts. Bring mir was bei oder setze OPENAI_API_KEY / OLLAMA_URL.`;
}
const lines = mems.slice(0, 3).map((m) => `• ${m.title}: ${(m.body || '').slice(0, 120)}`);
return `${name} erinnert sich:\n${lines.join('\n')}\n\n(Stub – kein LLM verbunden.)`;
}
async function llmReply(body) {
const ollama = process.env.OLLAMA_URL; // e.g. http://127.0.0.1:11434/api/generate
const key = process.env.OPENAI_API_KEY;
const sys = `Du bist ${body.buddy?.name || 'Byte'}, Level ${body.buddy?.level || 1}, Stufe ${body.buddy?.stage || ''}. Antworte kurz auf Deutsch. Nutze diese Erinnerungen:\n` +
(body.memories || []).map((m) => `- ${m.title}: ${m.body}`).join('\n');
if (ollama) {
const r = await fetch(ollama, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.OLLAMA_MODEL || 'llama3.2',
prompt: sys + '\n\nUser: ' + body.message + '\nAssistant:',
stream: false
})
});
const d = await r.json();
return d.response || d.text || stubReply(body);
}
if (key) {
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + key },
body: JSON.stringify({
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
messages: [
{ role: 'system', content: sys },
{ role: 'user', content: body.message }
]
})
});
const d = await r.json();
return (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || stubReply(body);
}
return stubReply(body);
}
const server = http.createServer(async (req, res) => {
cors(req, res);
if (req.method === 'OPTIONS') {
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && (req.url === '/' || req.url === '/health')) {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, service: 'code-kumpel-brain', port: PORT }));
}
if (req.method === 'POST' && (req.url === '/chat' || req.url.startsWith('/chat?'))) {
const ip = req.socket.remoteAddress || 'local';
if (!rateOk(ip)) {
res.writeHead(429, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'rate_limit' }));
}
try {
const body = await readBody(req);
const reply = await llmReply(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ reply }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: String(e.message || e) }));
}
}
const pathOnly = (req.url || '').split('?')[0];
if (pathOnly.startsWith('/auth/')) {
const ip = clientIp(req), h = req.headers.authorization;
let body = {};
try { if (req.method !== 'GET') body = await readBody(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
try {
let out;
if (pathOnly === '/auth/register' && req.method === 'POST') out = await auth.register(body, ip);
else if (pathOnly === '/auth/login' && req.method === 'POST') out = await auth.login(body, ip);
else if (pathOnly === '/auth/logout' && req.method === 'POST') out = await auth.logout(h);
else if (pathOnly === '/auth/me' && req.method === 'GET') out = await auth.me(h);
else if (pathOnly === '/auth/password' && (req.method === 'POST' || req.method === 'PUT')) out = await auth.changePassword(h, body, ip);
else if (pathOnly === '/auth/config' && req.method === 'GET') out = features.config();
else if (pathOnly === '/auth/forgot' && req.method === 'POST') out = await features.forgot(body, ip);
else if (pathOnly === '/auth/reset' && req.method === 'POST') out = await features.reset(body, ip);
else if (pathOnly === '/auth/verify' && req.method === 'POST') out = await features.verify(body);
else if (pathOnly === '/auth/resend-verify' && req.method === 'POST') out = await features.resendVerify(h, ip);
else if (pathOnly === '/auth/oauth/exchange' && req.method === 'POST') out = await features.oauthExchange(body);
else if (/^\/auth\/oauth\/(github|google)\/start$/.test(pathOnly) && req.method === 'GET') out = await features.oauthStart(pathOnly.split('/')[3], new URL(req.url, 'http://x').searchParams);
else if (/^\/auth\/oauth\/(github|google)\/callback$/.test(pathOnly) && req.method === 'GET') out = await features.oauthCallback(pathOnly.split('/')[3], new URL(req.url, 'http://x').searchParams);
else if ((pathOnly === '/auth/account' && req.method === 'DELETE') || (pathOnly === '/auth/delete' && req.method === 'POST')) { const a0 = await auth.authenticate(h); out = await auth.deleteAccount(h, body, ip); if (a0 && out.status === 200) await features.onDelete(a0.user.id); }
else out = { status: 404, body: { error: 'not_found' } };
if (out.location) { res.writeHead(302, { Location: out.location, 'Cache-Control': 'no-store' }); return res.end(); }
return send(res, out.status, out.body);
} catch (e) { console.error('[auth]', e && e.message); return send(res, 500, { error: 'server_error' }); }
}
if (pathOnly === '/sync') {
const ip = clientIp(req);
if (!rateOk(ip)) return send(res, 429, { error: 'rate_limit' });
let body = null;
try { if (req.method !== 'GET') body = await readBody(req); } catch (e) { body = null; }
try {
let id = null;
if (req.headers.authorization) { const a = await auth.authenticate(req.headers.authorization); if (!a) return send(res, 401, { error: 'unauthorized' }); id = 'user:' + a.user.id; }
else id = sync.idFor(req.headers['x-sync-code']);
const prev = id && id.startsWith('user:') && req.method !== 'GET' ? await kv.get('sync', id) : null;
const out = await sync.handleAsync(kv, req.method, id, body);
if (prev && out.status === 200) await features.snapshot(id.slice(5), prev);
return send(res, out.status, out.body);
} catch (e) { console.error('[sync]', e && e.message); return send(res, 500, { error: 'server_error' }); }
}
if (pathOnly === '/backups' || pathOnly === '/backups/restore' || pathOnly === '/leaderboard' || pathOnly === '/errors') {
const ip = clientIp(req), h = req.headers.authorization;
let body = {};
try { if (req.method !== 'GET') body = await readBody(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
try {
let out;
if (pathOnly === '/backups' && req.method === 'GET') out = await features.backupsList(h);
else if (pathOnly === '/backups/restore' && req.method === 'POST') out = await features.backupsRestore(h, body);
else if (pathOnly === '/leaderboard' && req.method === 'GET') out = await features.lbGet();
else if (pathOnly === '/leaderboard' && req.method === 'POST') out = await features.lbPost(h, body);
else if (pathOnly === '/errors' && req.method === 'POST') out = await features.reportError(body, ip);
else if (pathOnly === '/errors' && req.method === 'GET') out = await features.listErrors(h);
else out = { status: 405, body: { error: 'method' } };
return send(res, out.status, out.body);
} catch (e) { console.error('[api]', e && e.message); return send(res, 500, { error: 'server_error' }); }
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
});
server.listen(PORT, () => {
console.log(`[code-kumpel-brain] http://localhost:${PORT}/chat`);
});