Der billigste Hebel im Repo, und bis jetzt nur Vorarbeit: die Webhooks lagen seit der Discord-Einrichtung bereit, gepostet hat nie jemand. Ablauf: neuer Stream erkannt, eine Minute warten (Twitch setzt Titel und Kategorie erst kurz nach dem Start, wer sofort postet kuendigt den Titel des letzten Streams an), Daten frisch holen, Webhook posten, Rolle Live-Ping anpingen. Der Ping funktioniert nur mit `allowed_mentions` im Nachrichtenkoerper. `<@&ID>` allein faerbt die Erwaehnung lila ein und benachrichtigt niemanden - der Fehler, den man erst nach drei leeren Streams bemerkt. Dagegen steht ein eigener Test. Gegen den doppelten Post stehen drei Dinge, denn wer zweimal in zehn Minuten angepingt wird, nimmt sich die Rolle wieder ab und ist damit fuer immer stumm: - Tabelle `posts` mit eindeutigem Index je Stream und Plattform. Nur *gesendete* Posts sperren; ein fehlgeschlagener Versuch darf wiederholt werden, sonst kostet ein Netzwerkaussetzer die ganze Ankuendigung. - Abklingzeit von sechs Stunden, gegen die neue Stream-ID nach einem Verbindungsabbruch. - Ausgeloest wird nur beim erkannten Neustart, nie im Fortsetzungszweig. `stream_id` ist bewusst kein Fremdschluessel: ein Post ist die Quittung fuer etwas, das den Rechner verlassen hat, und muss auch dann schreibbar sein, wenn es die Sitzung nicht gibt - beim Probelauf etwa. Dazu ein vierter Webhook in #technik-log. Der Entwurfsmodus postet dieselbe Nachricht dorthin, ohne Ping und nur fuer Mods sichtbar. So sieht man das Ergebnis, bevor es der Server sieht. Die Rollenaufloesung aus selfroles.ts liegt jetzt in roles.ts, weil der Ping sie auch braucht: ein Webhook kann eine Rolle nicht ueber ihren Namen anpingen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
// Verschluesselter Speicher fuer Zugangsdaten. Siehe ADR-0005.
|
|
//
|
|
// Bewusst NICHT in der SQLite-Datenbank: die wird per VACUUM INTO auf die
|
|
// Windows-Seite gesichert, wo sie in einer Cloud-Synchronisation landen kann.
|
|
//
|
|
// Der Schluessel liegt im selben Volume wie die Daten. Das schuetzt nicht
|
|
// gegen jemanden mit Zugriff auf das Volume, und tut auch nicht so. Es
|
|
// schuetzt gegen versehentliche Anzeige im Stream, gegen verirrte Kopien und
|
|
// gegen Grep-Unfaelle - und genau darum geht es hier.
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { paths } from '#core/config.ts';
|
|
import { logger } from '#core/logger.ts';
|
|
|
|
const log = logger('secrets');
|
|
const FILE = path.join(paths.db, 'secrets.json');
|
|
const KEYFILE = path.join(paths.db, '.secret-key');
|
|
|
|
export type Role = 'broadcaster' | 'bot';
|
|
|
|
export type TokenSet = {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresAt: string; // ISO
|
|
scopes: string[];
|
|
userId: string;
|
|
login: string;
|
|
};
|
|
|
|
export type SecretStore = {
|
|
apps: Partial<Record<Role, { clientId: string; clientSecret: string }>>;
|
|
tokens: Partial<Record<Role, TokenSet>>;
|
|
obs?: { password: string };
|
|
discord?: {
|
|
/** Bot-Token der Discord-App. Nur fuers Einrichten und spaeter fuer den Bot. */
|
|
botToken?: string;
|
|
/** Server-ID ("Guild"), damit das Einrichten kein Argument braucht. */
|
|
guildId?: string;
|
|
goLiveWebhook?: string;
|
|
recapWebhook?: string;
|
|
clipsWebhook?: string;
|
|
/** Interner Kanal: Entwuerfe und Fehlermeldungen, nur fuer Mods sichtbar. */
|
|
technikWebhook?: string;
|
|
};
|
|
/**
|
|
* Zugang zur Weboberflaeche. Erst noetig, seit der Dienst nicht mehr auf
|
|
* 127.0.0.1 haengt - vorher war "wer am Rechner sitzt, darf" die Regel.
|
|
*/
|
|
web?: {
|
|
passwordHash?: string;
|
|
passwordSalt?: string;
|
|
/** Steht in der Adresse jeder Browser-Source. Darf nur lesen. */
|
|
overlayKey?: string;
|
|
/**
|
|
* Signiert die Sitzungs-Cookies. Liegt hier und nicht im Arbeitsspeicher,
|
|
* damit ein Neustart niemanden abmeldet - der Dienst startet bei jedem
|
|
* Ausrollen neu.
|
|
*/
|
|
sessionKey?: string;
|
|
};
|
|
bluesky?: { handle: string; appPassword: string };
|
|
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
|
update?: { token: string };
|
|
};
|
|
|
|
const EMPTY: SecretStore = { apps: {}, tokens: {} };
|
|
|
|
function key(): Buffer {
|
|
fs.mkdirSync(paths.db, { recursive: true });
|
|
if (fs.existsSync(KEYFILE)) return Buffer.from(fs.readFileSync(KEYFILE, 'utf8').trim(), 'base64');
|
|
const k = crypto.randomBytes(32);
|
|
fs.writeFileSync(KEYFILE, k.toString('base64'), { mode: 0o600 });
|
|
log.info('Neuer Verschluesselungsschluessel erzeugt');
|
|
return k;
|
|
}
|
|
|
|
function encrypt(plain: string): string {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
|
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), body.toString('base64')]
|
|
.join('.');
|
|
}
|
|
|
|
function decrypt(blob: string): string {
|
|
const [ivB64, tagB64, bodyB64] = blob.split('.');
|
|
if (!ivB64 || !tagB64 || !bodyB64) throw new Error('secrets.json ist beschaedigt');
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB64, 'base64'));
|
|
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
|
return Buffer.concat([decipher.update(Buffer.from(bodyB64, 'base64')), decipher.final()])
|
|
.toString('utf8');
|
|
}
|
|
|
|
let cache: SecretStore | null = null;
|
|
|
|
export function readSecrets(): SecretStore {
|
|
if (cache) return cache;
|
|
if (!fs.existsSync(FILE)) { cache = structuredClone(EMPTY); return cache; }
|
|
try {
|
|
cache = JSON.parse(decrypt(fs.readFileSync(FILE, 'utf8'))) as SecretStore;
|
|
} catch (err) {
|
|
log.error(`secrets.json nicht lesbar (${(err as Error).message}) - starte mit leerem Speicher`);
|
|
cache = structuredClone(EMPTY);
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
export function writeSecrets(next: SecretStore): void {
|
|
fs.mkdirSync(paths.db, { recursive: true });
|
|
fs.writeFileSync(FILE, encrypt(JSON.stringify(next)), { mode: 0o600 });
|
|
cache = next;
|
|
}
|
|
|
|
export function updateSecrets(fn: (s: SecretStore) => void): SecretStore {
|
|
const s = structuredClone(readSecrets());
|
|
fn(s);
|
|
writeSecrets(s);
|
|
return s;
|
|
}
|
|
|
|
/** Zustand fuer die Einrichtungsseite - enthaelt nie einen Geheimwert. */
|
|
export type SecretStatus = {
|
|
apps: Record<Role, { clientId: string; hasSecret: boolean }>;
|
|
tokens: Record<Role, { connected: boolean; login?: string; expiresAt?: string; scopes?: number }>;
|
|
obs: boolean;
|
|
ready: boolean;
|
|
};
|
|
|
|
export function secretStatus(): SecretStatus {
|
|
const s = readSecrets();
|
|
const roles: Role[] = ['broadcaster', 'bot'];
|
|
const apps = {} as SecretStatus['apps'];
|
|
const tokens = {} as SecretStatus['tokens'];
|
|
for (const r of roles) {
|
|
// Die Client-ID ist kein Geheimnis und wird angezeigt, damit man sieht,
|
|
// welche App hinterlegt ist. Das Secret nie.
|
|
apps[r] = { clientId: s.apps[r]?.clientId ?? '', hasSecret: Boolean(s.apps[r]?.clientSecret) };
|
|
const t = s.tokens[r];
|
|
tokens[r] = t
|
|
? { connected: true, login: t.login, expiresAt: t.expiresAt, scopes: t.scopes.length }
|
|
: { connected: false };
|
|
}
|
|
return {
|
|
apps, tokens,
|
|
obs: Boolean(s.obs?.password),
|
|
ready: Boolean(s.apps.broadcaster?.clientId && s.tokens.broadcaster),
|
|
};
|
|
}
|