Zwei Dinge, die Reichweite bringen sollen, hingen bisher daran, dass jemand vor seinem Rechner sitzt: der Discord-Rollen-Knopf und die Go-Live- Ankuendigung. Beide brauchen nur Internet. Der Schnitt verlaeuft entlang einer Frage: braucht die Aufgabe eine Datei oder ein Programm, das nur auf dem Stream-PC existiert? Hub (LXC) Ingest (Abdus PC) --------- ----------------- Chat, Zuschauerzahlen OBS-Wiedergabepuffer Discord: Knopf und Go-Live !clip, Rendern, Untertitel Overlay-Seiten fuer OBS Beide schreiben in ihre eigene Datenbank. Eine gemeinsame waere eine Netzwerkfreigabe fuer SQLite - der zuverlaessigste bekannte Weg zu einer beschaedigten Datei. Das kostet ADR-0005 seine Hauptbegruendung: "bindet nur auf 127.0.0.1" gilt fuer den Hub nicht mehr. An die Stelle treten ein Passwort mit signiertem Sitzungs-Cookie fuer alles, was ein Mensch bedient, und ein zufaelliger Schluessel in der Adresse fuer die Browser-Sources. Der Schluessel ist kein schoenes Verfahren - er steht in jedem OBS-Profil -, aber eine Browser-Source kann kein Formular ausfuellen und keinen Login durchhalten. `publicUrl` haengt der OAuth-Rueckruf daran. Twitch vergleicht die Redirect-URI zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext aus wie ein falsches Client-Secret. Die Routen der Einrichtungsseite sind aus ingest/server.ts herausgeloest, weil sie jetzt an zwei Servern haengen. Begruendung und Konsequenzen in ADR-0008. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
6.6 KiB
TypeScript
179 lines
6.6 KiB
TypeScript
// 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}`);
|
|
// 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<boolean> {
|
|
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;
|
|
}
|