Hub: der Dienst laeuft zentral statt auf dem Stream-PC
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>
This commit is contained in:
1 parent
e18336c57f
commit
3b606e36fb
8 files changed
+985
-10
No files matched your search
@@ -1,7 +1,8 @@
|
||||
// OAuth-Autorisierungscode-Flow gegen Twitch. Rueckruf laeuft auf denselben
|
||||
// Server wie die Einrichtungsseite (Port 4792) - siehe ADR-0005.
|
||||
// Server wie die Einrichtungsseite - siehe ADR-0005.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { readSecrets, updateSecrets, type Role, type TokenSet } from './secrets.ts';
|
||||
|
||||
@@ -19,6 +20,10 @@ export const SCOPES: Record<Role, string[]> = {
|
||||
'moderator:read:chatters',
|
||||
'channel:manage:raids',
|
||||
'channel:read:redemptions',
|
||||
// Zum Zurueckgeben der Punkte, wenn eine Aktion scheitert oder abgelehnt
|
||||
// wird. Wer Punkte nimmt und nichts liefert, verliert schneller Vertrauen
|
||||
// als durch jeden Fehler im Bild.
|
||||
'channel:manage:redemptions',
|
||||
'bits:read',
|
||||
],
|
||||
bot: ['chat:read', 'chat:edit'],
|
||||
@@ -27,9 +32,19 @@ export const SCOPES: Record<Role, string[]> = {
|
||||
/** Kurzlebige, einmalig einloesbare `state`-Werte gegen CSRF. */
|
||||
const pending = new Map<string, { role: Role; created: number }>();
|
||||
|
||||
/**
|
||||
* Muss exakt so in der Twitch-App hinterlegt sein - Twitch vergleicht
|
||||
* zeichengenau, und ein fehlender Schraegstrich sieht im Fehlertext aus wie
|
||||
* ein falsches Client-Secret.
|
||||
*
|
||||
* Laeuft der Dienst zentral, steht in `publicUrl` die oeffentliche Adresse.
|
||||
* Dann ist der Rueckruf `https://.../callback`, und Twitch verlangt dort
|
||||
* ohnehin HTTPS: `http://localhost` ist die einzige Ausnahme, die Twitch
|
||||
* ohne Zertifikat akzeptiert.
|
||||
*/
|
||||
export function redirectUri(port: number): string {
|
||||
// Muss exakt so in der Twitch-App hinterlegt sein.
|
||||
return `http://localhost:${port}/callback`;
|
||||
const base = loadConfig().publicUrl;
|
||||
return base ? `${base}/callback` : `http://localhost:${port}/callback`;
|
||||
}
|
||||
|
||||
export function authorizeUrl(role: Role, port: number): string {
|
||||
@@ -86,6 +101,52 @@ export async function handleCallback(code: string, state: string, port: number):
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-Token (client_credentials). Gehoert keinem Nutzer und braucht keine
|
||||
* Freigabe - es beweist nur, dass die Anfrage von dieser App kommt.
|
||||
*
|
||||
* Damit laufen die oeffentlichen Endpunkte: `GET /users` und `GET /streams`.
|
||||
* Genau die beiden traegt die Stream-Erkennung, und deshalb muss sie nicht
|
||||
* darauf warten, dass der Broadcaster sein Konto verbindet. Alles, was ueber
|
||||
* einen Kanal etwas *tut* oder Privates liest - Clips anlegen, Chatter
|
||||
* auflisten, Abos sehen - verlangt weiterhin ein Nutzer-Token.
|
||||
*
|
||||
* Gemerkt wird nur im Arbeitsspeicher: das Token gilt rund zwei Monate, und
|
||||
* nach einem Neustart ist es eine einzige Anfrage.
|
||||
*/
|
||||
const appTokens = new Map<Role, { token: string; bis: number }>();
|
||||
|
||||
export async function appToken(role: Role): Promise<string> {
|
||||
const gemerkt = appTokens.get(role);
|
||||
// Fuenf Minuten Puffer, damit kein Aufruf mitten im Ablauf landet
|
||||
if (gemerkt && gemerkt.bis - Date.now() > 300_000) return gemerkt.token;
|
||||
|
||||
const app = readSecrets().apps[role];
|
||||
if (!app?.clientId || !app.clientSecret) {
|
||||
throw new Error(`Fuer "${role}" fehlen Client-ID und Secret - Einrichtung unter /setup.`);
|
||||
}
|
||||
const res = await fetch(TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: app.clientId,
|
||||
client_secret: app.clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`App-Token fuer "${role}" nicht erhalten (HTTP ${res.status}). ` +
|
||||
'Stimmen Client-ID und Secret?');
|
||||
}
|
||||
const body = await res.json() as { access_token: string; expires_in: number };
|
||||
appTokens.set(role, { token: body.access_token, bis: Date.now() + body.expires_in * 1000 });
|
||||
log.info(`App-Token fuer ${role} geholt (gilt ${Math.round(body.expires_in / 86400)} Tage)`);
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
/** Vergisst gemerkte App-Tokens. Fuer Tests und nach einem App-Wechsel. */
|
||||
export function forgetAppTokens(): void { appTokens.clear(); }
|
||||
|
||||
export async function validate(accessToken: string) {
|
||||
const res = await fetch(VALIDATE, { headers: { Authorization: `OAuth ${accessToken}` } });
|
||||
if (!res.ok) throw new Error(`Token ungueltig: HTTP ${res.status}`);
|
||||
|
||||
Reference in new issue
Block a user