S1: Einrichtungs-Assistent, Twitch-Auth, Datenbank und Chat-Aufnahme
Zugangsdaten kommen jetzt aus der App statt aus einer .env: ein Einrichtungs-Assistent auf http://localhost:4792/setup nimmt Client-ID, Secret und OBS-Passwort entgegen, der OAuth-Rueckruf laeuft auf denselben Server. Gespeichert wird verschlueselt (AES-256-GCM) in einem Volume, bewusst nicht in der Datenbank - die wird auf die Windows-Seite gesichert. Siehe ADR-0005. Technikbasis vereinfacht: Node 24 bringt node:sqlite, WebSocket, fetch und die Ausfuehrung von TypeScript mit. Das Projekt hat damit null Laufzeitabhaengigkeiten und keinen Build-Schritt; pnpm-Workspaces und die Paketmanifeste sind entfallen, Importe laufen ueber die imports-Map der Wurzel-package.json. erasableSyntaxOnly verhindert Syntax, die das Type Stripping stillschweigend entfernen wuerde. Siehe Nachtrag 2 in ADR-0001. Enthalten: - packages/core: Config, Layout, Logger mit Secret-Maskierung, Retry, YAML - packages/db: Migrationen, Repositories, alle SQL-Anweisungen an einem Ort - packages/twitch: Secret-Speicher, OAuth, Helix mit Token-Bucket, IRC - apps/ingest: Daemon, Einrichtungsseite, Spike-Erkennung - 20 Tests (IRC-Parser, Spike-Detektor, Datenbankschicht) Der Chat verbindet sich anonym, solange nichts hinterlegt ist - damit laesst sich die Datenaufnahme pruefen, bevor die Twitch-Apps existieren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
4ceb61f095
commit
d07c18441b
82 files changed
+2775
-838
No files matched your search
@@ -0,0 +1,152 @@
|
||||
// Ein HTTP-Server fuer Einrichtung, OAuth-Rueckruf und Statusabfrage.
|
||||
// Bindet ausschliesslich auf 127.0.0.1 - siehe ADR-0005.
|
||||
// Ab S5 kommt hier die Sichtungsseite fuer Clip-Kandidaten dazu.
|
||||
|
||||
import http from 'node:http';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { authorizeUrl, handleCallback, redirectUri } from '#twitch/oauth.ts';
|
||||
import { secretStatus, updateSecrets, type Role } from '#twitch/secrets.ts';
|
||||
import { setupPage } from './setup-page.ts';
|
||||
|
||||
const log = logger('web');
|
||||
|
||||
export type ServerDeps = {
|
||||
channel: string;
|
||||
port: number;
|
||||
health: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > 64 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function isRole(v: string | null): v is Role {
|
||||
return v === 'broadcaster' || v === 'bot';
|
||||
}
|
||||
|
||||
export function startServer(deps: ServerDeps): http.Server {
|
||||
const server = http.createServer((req, res) => {
|
||||
void handle(req, res, deps).catch((err: Error) => {
|
||||
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Interner Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
// Vorgabe ist die Loopback-Adresse. Im Container muss das 0.0.0.0 sein -
|
||||
// dort ist 127.0.0.1 die Container-Loopback, die Dockers Portweiterleitung
|
||||
// nie erreicht; die Abschottung kommt dann daher, dass Compose den Port
|
||||
// ausschliesslich auf 127.0.0.1 des Hosts veroeffentlicht. Das Image setzt
|
||||
// BIND_HOST entsprechend. Siehe ADR-0005.
|
||||
const host = process.env.BIND_HOST || '127.0.0.1';
|
||||
server.listen(deps.port, host, () => {
|
||||
log.info(`Einrichtung und Status: http://localhost:${deps.port}/setup (gebunden an ${host})`);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`);
|
||||
const html = (body: string, code = 200) => {
|
||||
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
|
||||
res.end(body);
|
||||
};
|
||||
const back = (kind: 'ok' | 'err', text: string) => {
|
||||
res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` });
|
||||
res.end();
|
||||
};
|
||||
|
||||
// ---- Einrichtungsseite
|
||||
if (url.pathname === '/' || url.pathname === '/setup') {
|
||||
const ok = url.searchParams.get('ok');
|
||||
const err = url.searchParams.get('err');
|
||||
return html(setupPage(secretStatus(), {
|
||||
channel: deps.channel,
|
||||
redirectUri: redirectUri(deps.port),
|
||||
...(ok ? { notice: { kind: 'ok' as const, text: ok } }
|
||||
: err ? { notice: { kind: 'err' as const, text: err } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Client-ID und Secret speichern
|
||||
if (url.pathname === '/setup/app' && req.method === 'POST') {
|
||||
const form = await readBody(req);
|
||||
const role = form.get('role');
|
||||
const clientId = (form.get('clientId') ?? '').trim();
|
||||
const clientSecret = (form.get('clientSecret') ?? '').trim();
|
||||
if (!isRole(role)) return back('err', 'Unbekannte Rolle');
|
||||
if (!clientId) return back('err', 'Client-ID fehlt');
|
||||
|
||||
updateSecrets((s) => {
|
||||
const existing = s.apps[role];
|
||||
// Leeres Secret-Feld heisst "unveraendert lassen", nicht "loeschen"
|
||||
const secret = clientSecret || existing?.clientSecret;
|
||||
if (!secret) throw new Error('Client-Secret fehlt');
|
||||
s.apps[role] = { clientId, clientSecret: secret };
|
||||
// Bei gewechselter App sind die alten Tokens wertlos
|
||||
if (existing && existing.clientId !== clientId) delete s.tokens[role];
|
||||
});
|
||||
log.info(`Client-Daten fuer ${role} gespeichert`);
|
||||
return back('ok', 'Gespeichert.');
|
||||
}
|
||||
|
||||
// ---- OBS-Passwort
|
||||
if (url.pathname === '/setup/obs' && req.method === 'POST') {
|
||||
const password = ((await readBody(req)).get('password') ?? '').trim();
|
||||
if (!password) return back('err', 'Kein Passwort eingegeben - unveraendert gelassen.');
|
||||
updateSecrets((s) => { s.obs = { password }; });
|
||||
log.info('OBS-Passwort gespeichert');
|
||||
return back('ok', 'OBS-Passwort gespeichert.');
|
||||
}
|
||||
|
||||
// ---- OAuth starten
|
||||
if (url.pathname === '/setup/connect') {
|
||||
const role = url.searchParams.get('role');
|
||||
if (!isRole(role)) return back('err', 'Unbekannte Rolle');
|
||||
try {
|
||||
res.writeHead(303, { location: authorizeUrl(role, deps.port) });
|
||||
res.end();
|
||||
return;
|
||||
} catch (err) {
|
||||
return back('err', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OAuth-Rueckruf
|
||||
if (url.pathname === '/callback') {
|
||||
const error = url.searchParams.get('error_description') ?? url.searchParams.get('error');
|
||||
if (error) return back('err', `Twitch hat abgelehnt: ${error}`);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
if (!code || !state) return back('err', 'Antwort von Twitch war unvollstaendig');
|
||||
try {
|
||||
const role = await handleCallback(code, state, deps.port);
|
||||
return back('ok', `${role === 'bot' ? 'Bot' : 'Broadcaster'}-Konto verbunden.`);
|
||||
} catch (err) {
|
||||
return back('err', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status fuer Skripte und Dashboard
|
||||
if (url.pathname === '/health') {
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(deps.health(), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Nicht gefunden');
|
||||
}
|
||||
Reference in new issue
Block a user