// 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; }; function readBody(req: http.IncomingMessage): Promise { 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 { const url = new URL(req.url ?? '/', `http://localhost:${deps.port}`); // Auf dem Stream-PC ist die Einrichtung das Einzige, was dieser Server // kann - also faengt die Wurzel dorthin. Im Hub steht auf / die // Overlay-Uebersicht, deshalb liegt die Weiche hier und nicht in // `setupRoutes`. if (url.pathname === '/') { res.writeHead(303, { location: '/setup' }); res.end(); return; } if (await setupRoutes(req, res, deps, url)) return; res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Nicht gefunden'); } /** * Die Routen der Einrichtung, ohne 404 am Ende. * * Ausgelagert, weil sie seit dem zentralen Betrieb an zwei Servern haengen: * hier auf dem Stream-PC und im Overlay-Server des Hubs. Der Rueckgabewert * sagt, ob die Anfrage beantwortet wurde - wer sie einbindet, macht sonst * selbst weiter. */ export async function setupRoutes( req: http.IncomingMessage, res: http.ServerResponse, deps: ServerDeps, url: URL ): Promise { 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); return true; }; const back = (kind: 'ok' | 'err', text: string) => { res.writeHead(303, { location: `/setup?${kind}=${encodeURIComponent(text)}` }); res.end(); return true; }; // ---- Einrichtungsseite if (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 true; } 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 true; } return false; }