/* ===================================================================== DIE BUEHNENQUELLEN (28.09.2026) Zwei Seiten fuer OBS und TikTok Studio, die OHNE ANMELDUNG arbeiten -- geprueft wird ein Schluessel in der Adresse. Genau das ist die heikle Stelle: Alles, was hier zu weit aufgemacht ist, ist ohne Zugang erreichbar. Deshalb wird nicht nur geprueft, WER hineinkommt, sondern auch WAS herauskommt. Aufrufen mit: node server/pruef-buehne.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { request } from "node:http"; const HIER = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "ws-buehne-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Buehnen-Pruefung"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); notbremse(200_000, "pruef-buehne"); await new Promise((r) => setTimeout(r, 900)); let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const melde = (t) => console.log(t); process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); const CREW = "crew.dogfather-universe.com"; const AGENTUR = "workspace.dogfather-universe.com"; /* ---------- Zugaenge anlegen -------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const nun = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, nun); /* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger. Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von heute waere ein Sonderfall, den niemand im Blick hat. */ const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], ["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]]; const CODES = {}; const IDS = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`; IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]); } d.close(); /* ---------- Sprechen mit Hausadresse ------------------------------------ */ function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { return new Promise((fertig, schief) => { const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method, headers: { Host: host, Origin: origin === undefined ? `https://${host}` : origin, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), }, }, (antwort) => { const teile = []; antwort.on("data", (s) => teile.push(s)); antwort.on("end", () => { const text = Buffer.concat(teile).toString("utf8"); let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(rolle, host) { const a = await roh(host, "/workspace/api/anmelden", { method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true }, }); const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); return a.code === 200 && keks ? keks : null; } const k = {}; for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW); /* ======================================================================= 1. NUR DIE LEITUNG BEKOMMT DIE ADRESSEN ======================================================================= */ melde("=== 1. Wer die Adressen bekommt ==="); let schluessel = ""; { for (const rolle of ["linke", "modi", "gast"]) { const a = await roh(CREW, "/workspace/api/buehne/adressen", { keks: k[rolle] }); ok(a.code === 403, `${rolle}: bekommt die Adressen nicht (${a.code})`); } const ohne = await roh(CREW, "/workspace/api/buehne/adressen", {}); ok(ohne.code === 401, `ohne Anmeldung erst recht nicht (${ohne.code})`); const a = await roh(CREW, "/workspace/api/buehne/adressen", { keks: k.admin }); ok(a.code === 200 && a.j?.video && a.j?.tafel, `DogFather schon (${a.code})`); const b = await roh(CREW, "/workspace/api/buehne/adressen", { keks: k.hand }); ok(b.code === 200, `die rechte Hand auch -- sie fuehrt mit (${b.code})`); schluessel = new URL(a.j.video).searchParams.get("s"); ok(/^[0-9a-f]{64}$/.test(schluessel), `der Schluessel ist 32 Byte aus dem Zufall (${schluessel.length} Zeichen)`); ok(new URL(b.j.video).searchParams.get("s") === schluessel, "und beide bekommen denselben -- es gibt genau einen"); } /* ======================================================================= 2. OHNE SCHLUESSEL GIBT ES NICHTS ======================================================================= */ melde(""); melde("=== 2. Der Schluessel ==="); { const ohne = await roh(CREW, "/workspace/api/buehne/lage", {}); ok(ohne.code === 404, `ohne Schluessel: 404 (${ohne.code})`); const falsch = await roh(CREW, "/workspace/api/buehne/lage?s=falsch", {}); ok(falsch.code === 404, `mit falschem: 404 (${falsch.code})`); /* EIN SCHLUESSEL MIT RICHTIGER LAENGE, ABER FALSCHEM INHALT. Ein Vergleich, der nur die Laenge prueft, waere sonst gruen -- und das ist keine theoretische Sorge, sondern der haeufigste Fehler bei genau dieser Art Pruefung. */ const gleichLang = "0".repeat(64); const b = await roh(CREW, `/workspace/api/buehne/lage?s=${gleichLang}`, {}); ok(b.code === 404, `gleich lang, aber falsch: 404 (${b.code})`); const gut = await roh(CREW, `/workspace/api/buehne/lage?s=${schluessel}`, {}); ok(gut.code === 200, `mit dem richtigen: 200 (${gut.code})`); /* WAS HERAUSKOMMT, IST DIE EIGENTLICHE FRAGE. Wer den Schluessel hat, soll genau das sehen, was ohnehin im Stream steht -- und nichts ueber das Haus. */ const felder = Object.keys(gut.j || {}).sort(); /* `tempo` kam am 28.09. dazu -- die Quelle muss die Geschwindigkeit mitmachen, sonst liefe der Stream anders als der Saal. Die Liste wird von Hand gepflegt UND ist absichtlich abschliessend: Sie ist die einzige Stelle, an der auffaellt, wenn jemand dem Stand ein Feld hinzufuegt, das ohne Anmeldung abrufbar waere. */ /* `layout` kam am 28.09. dazu: Bei „nur Kamera" muss die Quelle das Bild abschalten, sonst liegt im Stream ein stehengebliebenes YouTube-Bild unter den Kameras. */ const erlaubt = ["gesendet", "laeuft", "layout", "sekunde", "stand", "tempo", "titel", "video", "video_titel"]; ok(JSON.stringify(felder) === JSON.stringify(erlaubt), `die Auskunft hat genau die erlaubten Felder (${felder.join(", ")})`); for (const verboten of ["zuschauer", "dabei", "gaeste", "chat", "host", "ich", "liste"]) { ok(!(verboten in (gut.j || {})), `kein „${verboten}" darin`); } } /* ======================================================================= 3. DIE ZWEI SEITEN KOMMEN OHNE ANMELDUNG ======================================================================= */ melde(""); melde("=== 3. Die Seiten ==="); { for (const seite of ["buehne.html", "tafel.html"]) { const a = await roh(CREW, `/workspace/${seite}`, {}); ok(a.code === 200, `${seite}: ohne Anmeldung erreichbar (${a.code})`); /* SIE DUERFEN KEINEN SCHLUESSEL ENTHALTEN. Er steht in der Adresse, nicht in der Seite -- sonst koennte ihn jeder abrufen, der die Seite kennt, und das ist der Sinn der Sache genau andersherum. */ ok(!a.text.includes(schluessel), `${seite}: enthaelt selbst keinen Schluessel`); } /* IM ANDEREN HAUS GIBT ES SIE NICHT. */ const AGENTUR2 = "workspace.dogfather-universe.com"; for (const seite of ["buehne.html", "tafel.html", "reaktion.html"]) { const a = await roh(AGENTUR2, `/workspace/${seite}`, {}); ok(a.code === 404, `${seite}: im Agenturhaus 404 (${a.code})`); } } /* ======================================================================= 4. EIN NEUER SCHLUESSEL MACHT DEN ALTEN TOT ======================================================================= */ melde(""); melde("=== 4. Neu erzeugen ==="); { for (const rolle of ["linke", "modi", "gast"]) { const a = await roh(CREW, "/workspace/api/buehne/schluessel", { method: "POST", keks: k[rolle], }); ok(a.code === 403, `${rolle}: erzeugt keinen neuen (${a.code})`); } const neu = await roh(CREW, "/workspace/api/buehne/schluessel", { method: "POST", keks: k.admin, }); ok(neu.code === 200, `DogFather schon (${neu.code})`); const neuerS = new URL(neu.j.video).searchParams.get("s"); ok(neuerS !== schluessel, "und er ist ein anderer"); const alt = await roh(CREW, `/workspace/api/buehne/lage?s=${schluessel}`, {}); ok(alt.code === 404, `der alte wirkt nicht mehr (${alt.code})`); const jetzt2 = await roh(CREW, `/workspace/api/buehne/lage?s=${neuerS}`, {}); ok(jetzt2.code === 200, `der neue schon (${jetzt2.code})`); } /* ======================================================================= 5. DER SCHLUESSEL STEHT NICHT IM PROTOKOLL ======================================================================= */ melde(""); melde("=== 5. Kein Geheimnis im Protokoll ==="); { const { DatabaseSync: DB3 } = await import("node:sqlite"); const d3 = new DB3(process.env.WORKSPACE_DB); const jetzige = d3.prepare("SELECT wert FROM einstellungen WHERE schluessel = ?") .get("buehne_schluessel")?.wert || ""; ok(jetzige.length === 64, `der Schluessel liegt in den Einstellungen (${jetzige.length})`); /* DIE VORHANDENE REGEL SOLL GREIFEN. Sie erkennt Einstellungen mit „schluessel" im Namen und schreibt nur, DASS sich etwas geaendert hat -- nicht, worauf. Ein Protokolleintrag mit dem Schluessel darin waere ein Geheimnis, das in einer Liste steht, die viele lesen duerfen. */ const spuren = d3.prepare( "SELECT COUNT(*) AS n FROM protokoll WHERE detail LIKE ?").get(`%${jetzige}%`); ok((spuren?.n || 0) === 0, "und er steht nirgends im Protokoll"); d3.close(); } melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); process.exit(fehler === 0 ? 0 : 1);