diff --git a/server/crew-adresse.js b/server/crew-adresse.js
index 2f18360b..54dfa8e5 100644
--- a/server/crew-adresse.js
+++ b/server/crew-adresse.js
@@ -387,6 +387,11 @@ export const GEHOERT_ZU_ADRESSE = [
die Daten sind die Sache. */
{ datei: "/workspace/notizen.html", zuhause: istCrewAdresse },
{ datei: "/workspace/reaktion.html", zuhause: istCrewAdresse },
+ /* DIE ZWEI BUEHNENQUELLEN (28.09.2026). Sie kommen ohne
+ Anmeldung aus -- geprueft wird ein Schluessel in der Adresse
+ -- und gehoeren trotzdem in genau ein Haus. */
+ { datei: "/workspace/buehne.html", zuhause: istCrewAdresse },
+ { datei: "/workspace/tafel.html", zuhause: istCrewAdresse },
];
/** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */
diff --git a/server/index.js b/server/index.js
index 08741aca..99060e90 100644
--- a/server/index.js
+++ b/server/index.js
@@ -41,6 +41,7 @@ import { supportRouter } from "./workspace-support.js";
import { notizenRouter } from "./workspace-notizen.js";
import { reaktionRouter } from "./workspace-reaktion.js";
import { spendenRouter } from "./workspace-spenden.js";
+import { buehneRouter } from "./workspace-buehne.js";
import { unterstuetzungRouter } from "./workspace-unterstuetzung.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
@@ -234,6 +235,12 @@ app.use("/workspace/api", sichtSetzen);
Pfade unter /workspace/api. Die Rueckmeldung der Sicherungskopie kommt
aber von einem Skript ohne Sitzung und wuerde dort abgewiesen, bevor
ihr Schluessel ueberhaupt geprueft wird. */
+/* EBENFALLS VOR aufgabenRouter, aus demselben Grund: Die zwei
+ Buehnenquellen fuer OBS kommen OHNE Sitzung -- geprueft wird ein
+ Schluessel in der Adresse. Weiter unten eingehaengt wuerden sie mit
+ 401 abgewiesen, bevor der Schluessel ueberhaupt angesehen wird.
+ Gemessen: genau das ist beim ersten Versuch passiert. */
+app.use(buehneRouter);
app.use(sicherungRouter);
/* Ebenfalls VOR aufgabenRouter: Das Profilbild wird von
geholt. Der Browser schickt dabei den Sitzungs-Cookie zwar mit, aber
diff --git a/server/mess-buehne.mjs b/server/mess-buehne.mjs
new file mode 100644
index 00000000..f7d3aea2
--- /dev/null
+++ b/server/mess-buehne.mjs
@@ -0,0 +1,427 @@
+/* =====================================================================
+ DIE ZWEI QUELLEN FUER OBS (28.09.2026)
+
+ Gemessen wird die Behauptung, auf der alles steht: Beide Seiten
+ arbeiten OHNE ANMELDUNG, nur mit einem Schluessel in der Adresse.
+ Dafuer wird ein Browserfenster ohne jeden Keks geoeffnet.
+
+ Dazu: Der falsche Schluessel fuehrt zu nichts, der Grund dafuer
+ steht im Bild, und eine bestaetigte Spende laeuft als Karte auf
+ der durchsichtigen Tafel.
+
+ Aufrufen mit: node server/mess-buehne.mjs
+ ===================================================================== */
+/* =====================================================================
+ WIE DIE REACTION AUSSIEHT (28.09.2026)
+
+ Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
+ sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
+ moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
+ schaltet sie durch alle drei Zustaende und legt Bilder ab.
+
+ Gemessen wird dabei auch, was man sonst uebersieht:
+ - laeuft etwas in die Konsole?
+ - ragt etwas seitlich hinaus?
+ - ist der Saal so hoch wie das Fenster, oder muss man scrollen?
+
+ NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
+ Zertifikat -- und am Ende ist alles weg.
+
+ Aufrufen mit: node server/mess-reaktion.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request } from "node:http";
+import { execFileSync } from "node:child_process";
+import { createServer as httpsServer } from "node:https";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "mess-buehne-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const PORT = 5483;
+const HTTPS_PORT = PORT + 1;
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+for (const art of ["uncaughtException", "unhandledRejection"]) {
+ process.on(art, (f) => {
+ console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
+ process.exit(8);
+ });
+}
+
+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");
+await new Promise((r) => setTimeout(r, 1000));
+
+const CREW = "crew.dogfather-universe.com";
+
+/* ---------- Leute ------------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const kenn = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kenn, new Date().toISOString());
+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", kenn).update(code).digest("hex"), ALT);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
+const IDS = {
+ admin: anlegen("DogFather", "admin", CODES.admin),
+ gast: anlegen("Nachtfalter", "gast", CODES.gast),
+ gast2: anlegen("Lenny", "gast", CODES.gast2),
+};
+d.close();
+
+function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
+ 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: CREW, Origin: `https://${CREW}`,
+ ...(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) {
+ const a = await roh("/workspace/api/anmelden", {
+ method: "POST",
+ rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
+ });
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const k = {
+ admin: await anmelden("admin"),
+ gast: await anmelden("gast"),
+ gast2: await anmelden("gast2"),
+};
+
+/* ---------- Die Buehne fuellen ------------------------------------------ */
+await roh("/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: {
+ titel: "Wir schauen den neuen Trailer",
+ video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
+ },
+});
+
+/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
+ einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
+ ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
+ Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
+for (const adresse of ["https://youtu.be/9bZkp7q19f0",
+ "https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin, rumpf: { video: adresse },
+ });
+}
+
+const REDEN = [
+ ["gast", "Bin schon daaa đș"],
+ ["gast2", "Ich auch! Hab extra Popcorn geholt"],
+ ["gast", "Wie lange noch?"],
+ ["admin", "Zwei Minuten, ich richte noch die Kamera"],
+ ["gast2", "Kein Stress, wir sitzen bequem"],
+ ["gast", "Letztes Mal war so gut, ich hab geheult"],
+];
+
+/* ---------- Der Vorbau -------------------------------------------------- */
+const schluesselDatei = join(ordner, "p.key");
+const zertDatei = join(ordner, "p.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
+
+const vorbau = httpsServer({
+ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
+}, (von, nach) => {
+ const weiter = request({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+});
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
+ /* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
+ Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
+ Aufnahme aus, als waere die Kamera kaputt. */
+ "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
+});
+
+/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
+ Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
+ laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
+ schliesst, nimmt den spaeteren den Platz weg.
+
+ GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
+ offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
+ Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
+ ein Messfehler, der wie ein Programmfehler aussah. */
+async function alsWer(rolle, breite, hoehe) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ ignoreHTTPSErrors: true,
+ permissions: ["camera", "microphone"],
+ });
+ await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
+ const [n, ...r] = z.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ const anfragen = [];
+ seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
+ seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+ seite.on("response", (a) => {
+ if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
+ });
+ return { kontext, seite, konsole, anfragen };
+}
+
+async function bild(seite, name) {
+ await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
+ console.log(` Bild: server/mess-reaktion-${name}.png`);
+}
+
+async function vermessen(seite, wo) {
+ const m = await seite.evaluate(() => ({
+ ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
+ hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
+ stand: document.getElementById("saal")?.dataset.stand || "?",
+ schild: document.getElementById("schild-wort")?.textContent || "",
+ }));
+ console.log(` ${wo}: Stand â${m.stand}", Schild â${m.schild}", `
+ + `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
+ return m;
+}
+
+
+/* ---------- Die Buehne vorbereiten -------------------------------------- */
+await roh("/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: { titel: "Probe fuer OBS", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
+});
+await roh("/workspace/api/reaktion/stand", {
+ method: "POST", keks: k.admin, rumpf: { stand: "live" },
+});
+await roh("/workspace/api/reaktion/dabei", { method: "POST", keks: k.admin });
+
+const adr = await roh("/workspace/api/buehne/adressen", { keks: k.admin });
+console.log(` Adressen: ${adr.code}`);
+let ausgang = 0;
+if (adr.code !== 200) {
+ console.log(" ACHTUNG: Die Adressen kommen nicht.");
+ process.exit(1);
+}
+const schluessel = new URL(adr.j.video).searchParams.get("s");
+console.log(` Schluessel: ${schluessel.length} Zeichen`);
+if (schluessel.length !== 64) {
+ console.log(" ACHTUNG: Der Schluessel ist zu kurz.");
+ ausgang = 1;
+}
+
+/* NUR DIE LEITUNG SIEHT DIE ADRESSEN. Wer sie hat, kann mitsehen --
+ das ist vertretbar fuer den, der sendet, und nicht fuer jeden im
+ Haus. Diese Messung legt nur DogFather und zwei Zuschauer an; die
+ uebrigen Rollen prueft `pruef-buehne`. */
+const alsGast = await roh("/workspace/api/buehne/adressen", { keks: k.gast });
+console.log(` Ein Zuschauer bekommt die Adressen: ${alsGast.code}`);
+if (alsGast.code !== 403) {
+ console.log(" ACHTUNG: Ein Zuschauer darf die Adressen sehen.");
+ ausgang = 1;
+}
+
+/* ---------- Ein Fenster OHNE Anmeldung ---------------------------------- */
+const fremd = await browser.newContext({
+ viewport: { width: 1280, height: 720 },
+ ignoreHTTPSErrors: true,
+});
+const konsole = [];
+const tafelSeite = await fremd.newPage();
+tafelSeite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+tafelSeite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+
+/* Erst der FALSCHE Schluessel. Eine Pruefung, die nur den richtigen
+ kennt, beweist nicht, dass der falsche abgewiesen wird. */
+await tafelSeite.goto(`${BASIS}/workspace/tafel.html?s=falschfalschfalsch`,
+ { waitUntil: "domcontentloaded" });
+await tafelSeite.waitForTimeout(1200);
+const beiFalsch = await tafelSeite.evaluate(() => ({
+ fehler: document.querySelector(".obs-fehler")?.textContent?.trim() || "",
+ karten: document.querySelectorAll(".spendenkarte").length,
+}));
+console.log(` Falscher Schluessel: â${beiFalsch.fehler.slice(0, 48)}âŠ", ${beiFalsch.karten} Karten`);
+if (!beiFalsch.fehler.includes("stimmt nicht") || beiFalsch.karten !== 0) {
+ console.log(" ACHTUNG: Ein falscher Schluessel wird nicht sauber abgewiesen.");
+ ausgang = 1;
+}
+
+/* Jetzt der richtige. */
+await tafelSeite.goto(`${BASIS}/workspace/tafel.html?s=${schluessel}`,
+ { waitUntil: "domcontentloaded" });
+await tafelSeite.waitForTimeout(1500);
+const kekse = await fremd.cookies();
+const grund = await tafelSeite.evaluate(() => ({
+ hintergrund: getComputedStyle(document.body).backgroundColor,
+ fehler: document.querySelector(".obs-fehler")?.textContent || "",
+}));
+console.log(` Kekse im Fenster: ${kekse.length} Hintergrund: ${grund.hintergrund}`);
+if (kekse.length !== 0) {
+ console.log(" ACHTUNG: Die Quelle hat sich angemeldet -- das soll sie nicht muessen.");
+ ausgang = 1;
+}
+if (!/rgba\(0, 0, 0, 0\)|transparent/.test(grund.hintergrund)) {
+ console.log(` ACHTUNG: Der Grund ist nicht durchsichtig (${grund.hintergrund}).`);
+ ausgang = 1;
+}
+if (grund.fehler) {
+ console.log(` ACHTUNG: Die Tafel meldet einen Fehler: ${grund.fehler}`);
+ ausgang = 1;
+}
+
+/* ---------- Laeuft eine Karte an? --------------------------------------- */
+await roh("/workspace/api/spenden", {
+ method: "POST", keks: k.admin,
+ rumpf: { betrag_cent: 2500, name: "Nachtfalter", gruss: "Gruss an die Buehne" },
+});
+await tafelSeite.waitForFunction(
+ () => document.querySelector('.spendenkarte[data-da="ja"]'), null, { timeout: 12000 },
+).catch(() => {});
+await tafelSeite.waitForTimeout(600);
+const karte = await tafelSeite.evaluate(() => {
+ const k2 = document.querySelector(".spendenkarte");
+ if (!k2) return null;
+ const r = k2.getBoundingClientRect();
+ return {
+ betrag: k2.querySelector(".spendenkarte__betrag")?.textContent?.trim(),
+ wer: k2.querySelector(".spendenkarte__wer")?.textContent?.trim(),
+ stufe: k2.querySelector(".spendenkarte__stufe")?.textContent?.trim(),
+ breite: Math.round(r.width), hoehe: Math.round(r.height),
+ };
+});
+console.log(` Karte auf der Tafel: ${JSON.stringify(karte)}`);
+if (!karte || !/25/.test(karte.betrag || "")) {
+ console.log(" ACHTUNG: Die Karte laeuft auf der Tafel nicht an.");
+ ausgang = 1;
+}
+await tafelSeite.screenshot({ path: join(HIER, "mess-buehne-1-tafel.png") });
+console.log(" Bild: server/mess-buehne-1-tafel.png");
+
+/* ---------- Das Video als Quelle ---------------------------------------- */
+const videoSeite = await fremd.newPage();
+videoSeite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+await videoSeite.goto(`${BASIS}/workspace/buehne.html?s=${schluessel}`,
+ { waitUntil: "domcontentloaded" });
+await videoSeite.waitForTimeout(6000);
+const video = await videoSeite.evaluate(() => ({
+ rahmen: !!document.querySelector("#platz iframe"),
+ fehler: document.querySelector(".obs-fehler")?.textContent || "",
+ bedienung: document.body.innerHTML.includes("controls=1"),
+}));
+console.log(` Video-Quelle: Rahmen=${video.rahmen} Fehler=â${video.fehler}"`);
+if (!video.rahmen || video.fehler) {
+ console.log(" ACHTUNG: Die Video-Quelle zeigt kein Video.");
+ ausgang = 1;
+}
+await videoSeite.screenshot({ path: join(HIER, "mess-buehne-2-video.png") });
+console.log(" Bild: server/mess-buehne-2-video.png");
+
+/* ---------- Ein neuer Schluessel macht die alten Adressen tot ----------- */
+const neu = await roh("/workspace/api/buehne/schluessel", { method: "POST", keks: k.admin });
+const neuerSchluessel = new URL(neu.j.video).searchParams.get("s");
+console.log(` Neuer Schluessel: ${neuerSchluessel !== schluessel ? "anders" : "GLEICH"}`);
+const altNochGut = await roh(`/workspace/api/buehne/lage?s=${schluessel}`, {});
+console.log(` Alte Adresse antwortet: ${altNochGut.code}`);
+if (neuerSchluessel === schluessel || altNochGut.code !== 404) {
+ console.log(" ACHTUNG: Der alte Schluessel wirkt noch.");
+ ausgang = 1;
+}
+const neuGut = await roh(`/workspace/api/buehne/lage?s=${neuerSchluessel}`, {});
+console.log(` Neue Adresse antwortet: ${neuGut.code}`);
+if (neuGut.code !== 200) {
+ console.log(" ACHTUNG: Der neue Schluessel wirkt nicht.");
+ ausgang = 1;
+}
+
+/* ---------- Der Buehnenmodus der Reaction-Seite -------------------------- */
+const hostSeite = await alsWer("admin", 1280, 720);
+await hostSeite.seite.goto(`${BASIS}/workspace/reaktion.html?nur=buehne`,
+ { waitUntil: "domcontentloaded" });
+await hostSeite.seite.waitForTimeout(3000);
+const nurBild = await hostSeite.seite.evaluate(() => {
+ const sicht = (w) => {
+ const e = document.querySelector(w);
+ if (!e) return false;
+ const r = e.getBoundingClientRect();
+ return r.width > 0 && r.height > 0 && getComputedStyle(e).display !== "none";
+ };
+ return {
+ modus: document.body.dataset.nur,
+ kopf: sicht(".kopfleiste"), chat: sicht(".schiene"), pult: sicht(".pult"),
+ schild: sicht(".buehne-schild"), leinwand: sicht(".leinwand"),
+ saal: Math.round(document.getElementById("saal").getBoundingClientRect().height),
+ fenster: window.innerHeight,
+ };
+});
+console.log(` Buehnenmodus: Kopf=${nurBild.kopf} Chat=${nurBild.chat} `
+ + `Pult=${nurBild.pult} Schild=${nurBild.schild} | Leinwand=${nurBild.leinwand} `
+ + `Saal ${nurBild.saal} von ${nurBild.fenster}`);
+if (nurBild.kopf || nurBild.chat || nurBild.pult || nurBild.schild || !nurBild.leinwand
+ || nurBild.saal < nurBild.fenster - 4) {
+ console.log(" ACHTUNG: Der Buehnenmodus raeumt nicht sauber auf.");
+ ausgang = 1;
+}
+await hostSeite.seite.screenshot({ path: join(HIER, "mess-buehne-3-modus.png") });
+console.log(" Bild: server/mess-buehne-3-modus.png");
+
+if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 4).join(" | "));
+
+await fremd.close();
+await hostSeite.kontext.close();
+await browser.close();
+await new Promise((r) => vorbau.close(r));
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log(`\nFertig. Rueckgabe ${ausgang}.`);
+process.exit(ausgang);
diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs
index 5d3b3240..2e8d1971 100644
--- a/server/pruef-buehne.mjs
+++ b/server/pruef-buehne.mjs
@@ -1,585 +1,254 @@
-/* ===================================================================
- Pruefung der Buehne (Casper und HasiDog im Hintergrund).
+/* =====================================================================
+ DIE BUEHNENQUELLEN (28.09.2026)
- Ein Bild hinter einem Arbeitswerkzeug ist der schnellste Weg zu einer
- Oberflaeche, die gut aussieht und unbenutzbar ist. Deshalb wird hier
- NICHT geprueft, ob es huebsch ist, sondern ob der Text noch lesbar
- ist -- und zwar an echten BILDPUNKTEN aus dem fertigen Bild, nicht an
- der Farbangabe im Stil. Die sagt naemlich nichts darueber, was
- dahinterliegt.
+ Zwei Seiten fuer OBS und TikTok Studio, die OHNE ANMELDUNG
+ arbeiten -- geprueft wird ein Schluessel in der Adresse.
- Massstab WCAG: 4,5:1 fuer normalen Text, 3:1 fuer grossen.
- =================================================================== */
+ 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 } from "node:path";
-
-
-/* =====================================================================
- DER DUNKELSTE FARBSTOPP DER ERSTEN HINTERGRUNDEBENE
-
- Gebraucht an ZWEI Stellen (Messung und Gegenprobe), deshalb steht er
- hier einmal und wird als Quelltext in die Seite gereicht. Zwei
- Kopien waeren zwei Gelegenheiten auseinanderzulaufen -- und genau
- das ist der Fehler, den diese Datei sonst sucht.
-
- DAS ENDE DER EBENE WIRD UEBER KLAMMERN GEZAEHLT, nicht ueber `"),"`.
- Mein erster Anlauf schnitt bei der ersten Zeichenfolge `),` ab --
- die steht aber schon am Ende des ERSTEN `rgb(...)`. Er las damit
- immer nur den ersten Stopp und haette einen dunklen Verlauf fuer
- hell gehalten. Die Gegenprobe unten hat es in der ersten Minute
- gemeldet. */
-function dunkelsterStopp(backgroundImage) {
- if (!backgroundImage || backgroundImage === "none") return null;
- const start = backgroundImage.indexOf("(");
- if (start < 0) return null;
- let tiefe = 0, ende = -1;
- for (let i = start; i < backgroundImage.length; i++) {
- if (backgroundImage[i] === "(") tiefe++;
- else if (backgroundImage[i] === ")") { tiefe--; if (tiefe === 0) { ende = i + 1; break; } }
- }
- const erste = ende > 0 ? backgroundImage.slice(0, ende) : backgroundImage;
- const stopps = [...erste.matchAll(/rgba?\(([^)]+)\)/g)]
- .map((m) => m[1].split(",").slice(0, 3).map((x) => Number(x.trim())))
- .filter((f) => f.length === 3 && f.every((x) => Number.isFinite(x)));
- if (!stopps.length) return null;
- const hell = (f) => 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
- return stopps.reduce((a, b) => (hell(b) < hell(a) ? b : a));
-}
-const DUNKELSTER_QUELLE = dunkelsterStopp.toString();
-
-/* ZWOELF SEITEN KAMEN AM 19.09.2026 DAZU -- und zwar nicht, weil sie neu
- sind, sondern weil sie bis dahin GAR KEINE Buehne hatten.
-
- Der Umbau vom 19.09. frueh heisst "Jede Seite traegt die Farbe ihrer
- Kachel". Nachgemessen traf das auf 18 von 34 Seiten zu: `zuSeite()`
- sucht nur in `Bereiche.GRUPPEN`, und dort stehen ausschliesslich die
- Kacheln der AGENTUR. Die Kacheln von Team Dogi und vom Treff liefert
- der Server -- die kannte die Stelle nicht. Seit der Reparatur in
- kopf.js bekommen auch sie Ton UND Buehne, und damit steht auf ihnen
- zum ersten Mal Text ueber einem Foto. Genau das misst diese Datei.
-
- NUR EINE TEILMENGE LAUFEN LASSEN: `BUEHNE_SEITEN=werdegang.html`.
- Ohne die Angabe laeuft die ganze Liste -- das dauert spuerbar und
- gehoert deshalb in einen abgesprochenen Lauf, nicht in jede
- Zwischenpruefung. */
-const ALLE_SEITEN = [
- "start.html", "uebersicht.html", "personen.html", "content.html",
- "aufgaben.html", "kalender.html",
- /* Die zwoelf ohne Buehne, bis zum 19.09.2026: */
- "werdegang.html", "entwicklung.html", "talente.html", "befinden.html",
- "hilfe.html", "rechte.html", "teamlage.html", "teilen.html",
- "bewerbungen.html", "treff-moderation.html", "treff-regeln.html",
- /* Neu am 21.09.2026 -- sie traegt eine Buehne und viel Text. */
- "willkommen.html",
- "unsere-seiten.html",
- /* Neu am 26.09.2026: der Notizblock. Er traegt eine Buehne, und
- auf ihm steht die leiseste Schrift des Hauses direkt ueber
- einem Foto -- genau der Fall, fuer den diese Datei gebaut ist. */
- "notizen.html",
-];
-const SEITEN = process.env.BUEHNE_SEITEN
- ? process.env.BUEHNE_SEITEN.split(",").map((s) => s.trim()).filter(Boolean)
- : ALLE_SEITEN;
-/* DRITTER AUSGANG: Wer sich vertippt, soll es merken, statt eine
- PrĂŒfung ĂŒber null Seiten fĂŒr bestanden zu halten. */
-{
- const unbekannt = SEITEN.filter((s) => !ALLE_SEITEN.includes(s));
- if (!SEITEN.length || unbekannt.length) {
- console.log("ABBRUCH: unbekannte Seite(n) in BUEHNE_SEITEN: " + unbekannt.join(", "));
- process.exit(2);
- }
-}
-console.log(`Gemessen werden ${SEITEN.length} von ${ALLE_SEITEN.length} Seiten.`);
-
-/* WIE LANGE DAS DAUERN DARF -- aus der Listenlaenge, nicht aus dem Kopf.
-
- Sechs Seiten auf zwei Bildschirmgroessen brauchten bisher knapp
- 220 Sekunden, also rund 18 s je Seite und Groesse. Aufgerundet auf 22,
- damit ein langsamer Lauf nicht faelschlich als Fehlschlag endet.
-
- Eine feste Zahl waere hier eine Zeitbombe: Mit den zwoelf neuen
- Seiten waere die alte Frist gerissen, und der Abbruch saehe aus wie
- ein Befund. */
-const SEITEN_ZEIT = SEITEN.length * 2 * 22_000;
-console.log(`Zeitrahmen: ${Math.round((100_000 + SEITEN_ZEIT) / 1000)} s.`);
+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");
-/* DER PORT MUSS MEINER SEIN (nachgeruestet 19.09.2026).
- Ohne diese Zeile startet der eigene Server bei belegtem Port
- STILL nicht -- und alles Folgende misst gegen einen fremden
- Stand. Das ist der dritte Ausgang: belegt ist etwas anderes
- als kaputt. */
-const PORT = await eigenerPort(import.meta, "pruef-buehne");
+
+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_ACCESS_CODE_DOGI = "DOGI-TEST-1";
-process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
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 }); };
-import { notbremse } from "./helfer-notbremse.mjs";
-import { eigenerPort } from "./helfer-port.mjs";
await import("./index.js");
-/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
- zwei Auffangnetze ein, die einen Absturz nur protokollieren --
- richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
- Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
- Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
-notbremse(120_000 + SEITEN_ZEIT, "pruef-buehne");
-await new Promise((r) => setTimeout(r, 700));
-const BASIS = `http://127.0.0.1:${PORT}`;
-/* Die Frist waechst mit der Liste: 220 s reichten fuer sechs Seiten.
- Eine feste Zahl waere hier eine Zeitbombe -- die zwoelf neuen
- haetten sie gerissen, und der Abbruch saehe aus wie ein Befund. */
-setTimeout(() => { console.log("ABBRUCH: Zeit ueberschritten"); process.exit(1); },
- 100_000 + SEITEN_ZEIT).unref?.();
+notbremse(200_000, "pruef-buehne");
+await new Promise((r) => setTimeout(r, 900));
-let fehler = 0;
-const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+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 */ } });
-await fetch(BASIS + "/workspace/api/anmelden", {
- method: "POST", headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+const CREW = "crew.dogfather-universe.com";
+const AGENTUR = "workspace.dogfather-universe.com";
+/* ---------- Zugaenge anlegen -------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
-const { scryptSync, randomBytes } = await import("node:crypto");
-
-/* Warten, bis der Server die Datenbank wirklich angelegt hat. Der
- Anmeldeversuch oben STOESST das nur an -- Schema und Umstellungen
- laufen danach. Wer sofort weiterschreibt, trifft auf "no such table". */
-for (let i = 0; i < 60; i++) {
- try {
- const p = new DatabaseSync(process.env.WORKSPACE_DB);
- try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
- } catch (f) {
- if (i === 0 || i === 5) console.log(" warte auf die Datenbank:", f.message);
- await new Promise((r) => setTimeout(r, 250));
- }
-}
-
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
-const jetzt = new Date().toISOString();
-const salt = randomBytes(16).toString("hex");
-const hash = scryptSync("CODE-DOGI-0001", 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
- .run("Filipe", "admin", hash, salt, 32768, jetzt);
+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();
-const { chromium } = await import(
- "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
-const browser = await chromium.launch();
-
-/* Der Kalender kam am 01.09.2026 dazu -- er bringt die meisten neuen
- FarbflÀchen mit (vier Arten, FeiertagsfÀrbung, Anlassmarken), und
- genau dort entscheidet sich, ob der hellere Hintergrund noch trÀgt. */
-
-
-for (const [breite, hoehe, wie] of [[1440, 950, "Computer"], [390, 780, "Handy"]]) {
- console.log(`\n=== ${wie} (${breite}x${hoehe})`);
- const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
- const seite = await kontext.newPage();
- await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
- await seite.click('.rolle[data-rolle="admin"]');
- await seite.fill("#code", "CODE-DOGI-0001");
- await seite.click("button[type=submit]");
- await seite.waitForURL("**/start.html", { timeout: 15000 });
-
- for (const s of SEITEN) {
- await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" });
- await seite.waitForTimeout(800);
-
- const da = await seite.evaluate(() => ({
- /* Seit dem 01.09.2026 gibt es neun BĂŒhnen statt einer, benannt
- nach ihrer Szene (buehne-garage-breit.webp). Der alte Ausdruck
- suchte nach "buehne-breit" und fand nichts mehr -- gemeldet als
- "die BĂŒhne liegt im Hintergrund: FEHL", obwohl sie da war. */
- buehne: /buehne-[a-z]+-(breit|schmal)\.webp/
- .test(getComputedStyle(document.body, "::before").backgroundImage),
- schleier: !!document.querySelector(".schleier"),
- durchlaessig: getComputedStyle(document.querySelector(".schleier") || document.body).pointerEvents,
- }));
- if (s === "start.html") {
- ok(da.buehne, "die BĂŒhne liegt im Hintergrund");
- }
- ok(da.schleier && da.durchlaessig === "none",
- `${s}: Schleier da und durchlĂ€ssig fĂŒr Klicks (${da.durchlaessig})`);
-
- /* Der eigentliche Punkt: Kontrast an ECHTEN Bildpunkten aus dem
- fertigen Bild. Gemessen wird der Untergrund direkt NEBEN jedem
- sichtbaren Text -- nicht die Farbangabe im Stil, die weiĂ nichts
- davon, was dahinterliegt. */
- const bild = await seite.screenshot({ type: "png" });
- const schlimmster = await seite.evaluate(async ([b64, quelle]) => {
- /* Derselbe Helfer wie in der Gegenprobe -- als Quelltext
- hereingereicht, damit es ihn nur EINMAL gibt. */
- const dunkelster = new Function("return " + quelle)();
- const kanal = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
- const hell = (c) => 0.2126 * kanal(c[0]) + 0.7152 * kanal(c[1]) + 0.0722 * kanal(c[2]);
- const kontrast = (a, b) => {
- const w = [hell(a), hell(b)].sort((x, y) => y - x);
- return (w[0] + 0.05) / (w[1] + 0.05);
- };
-
- const bild = new Image();
- bild.src = "data:image/png;base64," + b64;
- await bild.decode();
- const c = document.createElement("canvas");
- c.width = bild.width; c.height = bild.height;
- const g = c.getContext("2d");
- g.drawImage(bild, 0, 0);
- const skala = bild.width / window.innerWidth;
-
- /* Wo darf man den Untergrund abtasten?
-
- Vorher wurde stur 4 px rechts neben dem Text gemessen. Das ging
- gut, solange dort nichts stand -- sobald aber zwei TextstĂŒcke
- nebeneinanderliegen (GruĂwort und Name, Marke und Ortsangabe),
- traf der Punkt DEN NACHBARTEXT und die PrĂŒfung verglich Schrift
- mit Schrift. Ergebnis: 1,1:1 und ein Alarm, der nichts mit der
- Lesbarkeit zu tun hatte.
-
- Jetzt werden mehrere Stellen rings um den Text angeboten und
- jede zuerst gefragt: Liegt dort wirklich Untergrund? Ein Punkt
- gilt nur, wenn an seiner Stelle KEIN fremdes Textelement liegt.
- Das macht die PrĂŒfung strenger, nicht lockerer -- sie misst
- jetzt tatsÀchlich das, was sie behauptet zu messen. */
- const istFremderText = (el2, e) =>
- el2 && el2 !== e && !el2.contains(e) && !e.contains(el2)
- && el2.children.length === 0 && el2.textContent.trim().length > 0;
-
- let schlecht = null, anzahl = 0, ohneStelle = 0, uebersprungen = 0;
- /* WELCHE Texte ungemessen blieben -- nicht nur wie viele.
- â3 von 40" sagt, dass etwas ist, und nicht was; man muss die
- Seite danach von Hand durchgehen. Mit den Anfaengen der drei
- Texte sieht man in einer Sekunde, ob es dicht gesetzte Zeilen
- sind (in Ordnung) oder etwas, das auf der Buehne liegt. */
- const ohneListe = [];
- const alle = [...document.querySelectorAll("p, h1, h2, h3, span, a, li")]
- .filter((e) => e.children.length === 0 && e.textContent.trim().length > 2);
- for (const e of alle) {
- const r = e.getBoundingClientRect();
- if (r.width < 8 || r.height < 8) { uebersprungen++; continue; }
- if (r.top < 0 || r.bottom > window.innerHeight) { uebersprungen++; continue; }
-
- const mitte = r.top + r.height / 2;
- const st0 = getComputedStyle(e);
- const kandidaten = [];
- let gemessenBei = null, gemessenAuf = null;
-
- /* ZUERST IN DER EIGENEN POLSTERUNG.
-
- Zweiter Fund an derselben Pruefung: Sie tastete ausschliesslich
- NEBEN dem Element ab. Traegt der Text seine Flaeche aber selbst
- (eine Beschriftung mit eigenem Hintergrund und Polsterung), dann
- liegt jeder Punkt daneben schon auf dem Hintergrundbild -- und
- gemeldet wurde 1,89:1, obwohl der Text auf einer deckenden
- Flaeche steht und tadellos lesbar ist.
-
- Der Untergrund eines Textes ist das, worauf er WIRKLICH liegt.
- Hat das Element genug Polsterung, wird dort gemessen: innen,
- aber sicher neben jedem Buchstaben. */
- const pl = parseFloat(st0.paddingLeft) || 0;
- const pt = parseFloat(st0.paddingTop) || 0;
- if (pt >= 5) kandidaten.push([r.left + r.width / 2, r.top + pt / 2]);
- if (pl >= 7) kandidaten.push([r.left + pl / 2, mitte]);
-
- /* Danach wie bisher rings herum. */
- kandidaten.push(
- [r.right + 4, mitte], [r.right + 14, mitte],
- [r.left - 4, mitte], [r.left - 14, mitte],
- [r.left + r.width / 2, r.top - 3], [r.left + r.width / 2, r.bottom + 3],
- );
- let p = null;
- for (const [kx, ky] of kandidaten) {
- if (kx < 0 || ky < 0 || kx >= window.innerWidth || ky >= window.innerHeight) continue;
- if (istFremderText(document.elementFromPoint(kx, ky), e)) continue;
- /* UND KEIN FREMDER UNTERGRUND (22.09.2026).
- ============================================================
- Diese Pruefung meldete tagelang 3,28:1 fuer den Markentext
- in der Kopfleiste. Drei Vermutungen nacheinander gemessen
- und alle drei widerlegt: nicht die Glut der Leiste, nicht
- der Schein hinter der Schrift, nicht die Schriftfarbe.
-
- Die Antwort kam erst, als die Pruefung sagen konnte, WO
- sie misst: bei 125,34 -- auf `button.wahl2__knopf`. Bei
- 390 px liegt der Sicht-Umschalter direkt neben dem
- Schriftzug, und der Abtastpunkt 4 px rechts daneben trifft
- SEINEN Hintergrund. Der Text liegt dort nicht drauf.
-
- Dieselbe Lehre wie schon einmal oben, nur andersherum:
- Damals mass sie daneben statt innen, hier misst sie
- daneben und trifft einen Nachbarn. Ein Element mit
- eigenem, deckendem Hintergrund, das KEIN Vorfahre des
- Textes ist, taugt nicht als sein Untergrund.
-
- Die Regel ist schmal: Nur deckende Hintergruende zaehlen
- als fremd. Ein durchsichtiger Nachbar laesst denselben
- Grund durch wie der Text selbst -- dort darf weiter
- gemessen werden. */
- const dort = document.elementFromPoint(kx, ky);
- if (dort && dort !== e && !e.contains(dort) && !dort.contains(e)) {
- /* WESSEN FLAECHE IST DAS? Der Knopf selbst ist oft
- durchsichtig -- seine Flaeche traegt der Behaelter
- darueber. Deshalb die Elternkette hoch bis zum ersten
- deckenden Hintergrund und fragen, ob DER auch ueber dem
- Text liegt. Tut er das nicht, ist es eine fremde
- Flaeche, und der Text liegt dort nicht drauf. */
- const flaecheVon = (n) => {
- for (let q = n; q && q !== document.documentElement; q = q.parentElement) {
- const st2 = getComputedStyle(q);
- const g2 = st2.backgroundColor || "";
- const teile = g2.match(/[\d.]+/g);
- const deckend = teile && (teile.length < 4 || Number(teile[3]) >= 0.5)
- && !/rgba\(0, 0, 0, 0\)/.test(g2);
- const bild = st2.backgroundImage && st2.backgroundImage !== "none"
- && (st2.webkitBackgroundClip || st2.backgroundClip || "")
- .split(",")[0].trim() !== "text";
- if (deckend || bild) return q;
- }
- return null;
- };
- const meine = flaecheVon(e);
- const fremde = flaecheVon(dort);
- if (fremde && fremde !== meine) continue;
- /* AUCH EINE HALBDURCHSICHTIGE EIGENE FLAECHE ZAEHLT.
- Der Knopf, der den Fehlalarm ausloeste, liegt in
- DERSELBEN Kopfleiste wie der Text -- die deckende
- Flaeche ist also dieselbe. Er traegt aber darueber noch
- eine eigene, halbdurchsichtige, und genau die hebt den
- gemessenen Punkt an. Ein Text liegt darauf nicht drauf,
- er liegt DANEBEN. */
- {
- let fremdeFlaeche = false;
- for (let q = dort; q && q !== meine && !q.contains(e); q = q.parentElement) {
- const st3 = getComputedStyle(q);
- const t3 = (st3.backgroundColor || "").match(/[\d.]+/g);
- const hatFarbe = t3 && (t3.length < 4 || Number(t3[3]) > 0.02);
- const hatBild = st3.backgroundImage && st3.backgroundImage !== "none";
- if (hatFarbe || hatBild) { fremdeFlaeche = true; break; }
- }
- if (fremdeFlaeche) continue;
- }
- }
- const bx = Math.round(kx * skala), by = Math.round(ky * skala);
- if (bx < 0 || bx >= c.width || by < 0 || by >= c.height) continue;
- p = g.getImageData(bx, by, 1, 1).data;
- gemessenBei = Math.round(kx) + "," + Math.round(ky);
- const hier = document.elementFromPoint(kx, ky);
- gemessenAuf = hier ? hier.tagName.toLowerCase()
- + (typeof hier.className === "string" && hier.className
- ? "." + hier.className.trim().split(/\s+/)[0] : "") : "nichts";
- break;
- }
- /* Kein freier Punkt gefunden -- das wird GEZĂHLT und ausgegeben.
- Ein stillschweigend ĂŒbersprungener Text wĂ€re eine PrĂŒfung, die
- behauptet, etwas geprĂŒft zu haben. */
- if (!p) {
- ohneStelle++;
- if (ohneListe.length < 6) ohneListe.push(e.textContent.trim().slice(0, 28));
- continue;
- }
-
- const st = st0;
-
- /* WENN DIE SCHRIFT IHRE FARBE AUS EINEM VERLAUF BEKOMMT
- (09.09.2026).
-
- Seit die Abschnittsschilder aus gebuerstetem Metall bestehen
- (`background-clip: text`), ist ihr `color` notwendigerweise
- `transparent` -- anders geht die Technik nicht. Diese
- Pruefung las genau dieses `color`, machte daraus [0,0,0] und
- meldete 1,07:1 fuer Text, der in Wahrheit hell und gut
- lesbar ist. Fuenf Fehlalarme auf drei Seiten.
-
- Eine Warnung, die bei richtiger Arbeit anschlaegt, wird
- abgeschaltet -- deshalb wird sie hier nicht weichgemacht,
- sondern GENAUER: Bei Verlaufsschrift zaehlt der DUNKELSTE
- Farbstopp des Verlaufs. Das ist der schlechteste Punkt, den
- es auf dieser Schrift wirklich gibt, und damit strenger als
- ein Mittelwert und ehrlicher als das durchsichtige `color`.
-
- Nur die ERSTE Ebene wird gelesen: `background-clip: text,
- border-box, border-box` schneidet Ebene 1 auf die Schrift zu,
- die uebrigen sind die Flaeche darunter. */
- const clipText = (st.webkitBackgroundClip || st.backgroundClip || "")
- .split(",")[0].trim() === "text";
- let farbe = clipText ? dunkelster(st.backgroundImage) : null;
- if (!farbe) farbe = st.color.match(/\d+/g).slice(0, 3).map(Number);
- const gr = parseFloat(st.fontSize);
- const fett = Number(st.fontWeight) >= 700;
- const grenze = (gr >= 24 || (gr >= 18.66 && fett)) ? 3 : 4.5;
- const v = kontrast(farbe, [p[0], p[1], p[2]]);
- anzahl++;
- if (!schlecht || v / grenze < schlecht.v / schlecht.grenze) {
- /* WO ES STEHT, NICHT NUR WAS DASTEHT (22.09.2026).
- Diese Pruefung meldete tagelang 3,28:1 bei "Spicy & Dogi"
- -- und die Suche danach kostete eine halbe Stunde, weil
- derselbe Text an mehreren Stellen vorkommt. Eine Pruefung,
- die einen Fehler findet, ihn aber nicht auffindbar macht,
- kostet mehr Zeit als sie spart. */
- const wo = e.tagName.toLowerCase()
- + (typeof e.className === "string" && e.className
- ? "." + e.className.trim().split(/\s+/).join(".") : "");
- schlecht = { v, grenze, text: e.textContent.trim().slice(0, 26),
- grund: [p[0], p[1], p[2]], wo,
- eltern: (() => { const k=[]; for (let q=e.parentElement; q && k.length<4; q=q.parentElement)
- k.push(q.tagName.toLowerCase()+(typeof q.className==="string"&&q.className?"."+q.className.trim().split(/\s+/)[0]:"")); return k.join(" < "); })(),
- lage: Math.round(r.left)+","+Math.round(r.top)+" "+Math.round(r.width)+"x"+Math.round(r.height),
- gemessenBei, gemessenAuf,
- groesse: Math.round(gr * 10) / 10, fett: st.fontWeight,
- verlauf: clipText };
- }
- }
- return { ...schlecht, anzahl, ohneStelle, uebersprungen,
- ohneListe, gesamt: alle.length };
- }, [bild.toString("base64"), DUNKELSTER_QUELLE]);
-
- /* ---- GEGENPROBE ZUR VERLAUFSSCHRIFT ----------------------------
-
- Seit die Messung bei `background-clip: text` den dunkelsten
- Farbstopp nimmt statt des durchsichtigen `color`, muss bewiesen
- werden, dass sie damit noch NEIN sagen kann. Sonst haette ich
- eine Warnung entschaerft, statt sie genauer zu machen.
-
- Zwei erfundene Elemente auf derselben Seite: eines mit einem
- hellen Verlauf (muss durchgehen), eines mit einem dunklen (muss
- durchfallen). Beide werden sofort wieder entfernt. */
- const gegen = await seite.evaluate((quelle) => {
- const dunkelster = new Function("return " + quelle)();
- const bauen = (verlauf) => {
- const d = document.createElement("span");
- d.textContent = "Probe";
- d.style.cssText = "position:fixed;left:-9999px;font-size:14px;"
- + `background:${verlauf} text;-webkit-background-clip:text;`
- + "background-clip:text;color:transparent;";
- document.body.append(d);
- const st = getComputedStyle(d);
- const clip = (st.webkitBackgroundClip || st.backgroundClip || "").split(",")[0].trim();
- const gefunden = dunkelster(st.backgroundImage);
- d.remove();
- return { clip, dunkelster: gefunden };
- };
- return {
- hell: bauen("linear-gradient(180deg, #ffffff, #cfd9e6)"),
- dunkel: bauen("linear-gradient(180deg, #ffffff, #101418)"),
- };
- }, DUNKELSTER_QUELLE);
- ok(gegen.hell.clip === "text" && gegen.hell.dunkelster
- && gegen.hell.dunkelster.join(",") === "207,217,230",
- `${s}: Gegenprobe -- heller Verlauf, dunkelster Stopp erkannt `
- + `(${gegen.hell.dunkelster?.join(",")})`);
- ok(gegen.dunkel.dunkelster && gegen.dunkel.dunkelster.join(",") === "16,20,24",
- `${s}: Gegenprobe -- dunkler Verlauf wird als dunkel erkannt `
- + `(${gegen.dunkel.dunkelster?.join(",")}), also faellt so etwas auf`);
-
- /* DIE ZAHL GEHOERT IN DIE BEDINGUNG (22.09.2026).
- Sie stand bisher nur im Meldetext. Am 22.09. wurde die Abtastung
- nachsichtiger gemacht (fremde Flaechen zaehlen nicht mehr als
- Untergrund) -- richtig, aber eine Lockerung kann zu weit gehen.
- Wuerde sie irgendwann JEDEN Punkt verwerfen, waere diese Zeile
- fuer immer gruen, ohne je wieder etwas anzusehen.
- ERSTER ANLAUF WAR `anzahl >= 5` -- und die Pruefung wurde auf
- uebersicht.html sofort rot, weil die Seite schlicht nur vier
- Texte hat. Eine feste Schwelle ist eine Rechnung von gestern;
- sie war hier keine fuenf Minuten alt.
-
- Richtig ist die ABLEITUNG: Jeder gefundene Text muss in genau
- einem der drei Toepfe landen -- gemessen, ohne freien Untergrund,
- oder vorher aussortiert (zu klein / ausserhalb des Bildes). Geht
- die Rechnung nicht auf, ist unterwegs etwas verschwunden, und
- zwar still. Diese Bedingung braucht keine Zahl und veraltet nicht.
- Wie VIELE davon gemessen werden duerfen, deckelt die Zeile
- darunter ("hoechstens zwei Texte ohne freien Untergrund"). */
- ok(schlimmster.anzahl + schlimmster.ohneStelle + schlimmster.uebersprungen
- === schlimmster.gesamt
- && schlimmster.v >= schlimmster.grenze,
- `${s}: schlechtester Kontrast ${schlimmster.v.toFixed(2)}:1 (nötig ${schlimmster.grenze}:1) `
- + `bei "${schlimmster.text}" auf rgb(${schlimmster.grund.join(",")}) · `
- + `${schlimmster.anzahl} gemessen + ${schlimmster.ohneStelle} ohne Grund `
- + `+ ${schlimmster.uebersprungen} aussortiert = ${schlimmster.gesamt}`
- + (schlimmster.wo ? ` -- ${schlimmster.wo}, ${schlimmster.groesse}px/${schlimmster.fett}`
- + (schlimmster.verlauf ? ", Verlauf im Text" : "")
- + ` [${schlimmster.lage}] in ${schlimmster.eltern}`
- + ` -- gemessen bei ${schlimmster.gemessenBei} auf ${schlimmster.gemessenAuf}` : ""));
-
- /* ==== UND WIE VIEL LUFT IST NOCH DA? (25.09.2026) ============
-
- 4,5:1 ist die Grenze, an der Text aufhoert, lesbar zu sein.
- Eine Seite, die GENAU dort steht, ist nicht in Ordnung --
- sie ist einen Handgriff davon entfernt, es nicht mehr zu sein.
-
- Dass das kein Gedankenspiel ist, steht in gate.css: Derselbe
- leiseste Ton musste dreimal in vier Wochen angehoben werden,
- immer aus demselben Grund -- der Hintergrund ist ein Bild, es
- wurde heller, und die Schrift war auf zwei Kommastellen genau
- auf den alten Stand eingestellt.
-
- 01.09. 4,43 -> 4,95
- 07.09. 4,50 -> rund 4,9
- 25.09. 4,42 -> 5,75
-
- Beide Rutscher waren rund elf Prozent gross. Deshalb steht
- hier 1,11 und keine gewaehlte Zahl: Eine Seite besteht diese
- Zeile genau dann, wenn sie noch EINEN Rutscher dieser Groesse
- vertraegt, ohne unlesbar zu werden. Bei 4,5 sind das 5,0.
-
- Diese Zeile ist die zweite Meinung zur Zeile darueber und
- ersetzt sie nicht: Die obere sagt, ob die Seite in Ordnung
- ist, diese, ob sie es morgen noch sein wird.
-
- Sie haengt bewusst an `schlimmster.grenze` und nicht an einer
- eigenen 5,0 -- bei grossem Text ist die Grenze 3:1, und
- dieselbe Luft sind dort 3,33. Wer die Zahl fest hineinschriebe,
- wuerde grossen Text unnoetig anmahnen. */
- const LUFT = 1.11;
- ok(schlimmster.v >= schlimmster.grenze * LUFT,
- `${s}: und noch Luft bis zur Grenze `
- + `(${schlimmster.v.toFixed(2)}:1, noetig waeren `
- + `${(schlimmster.grenze * LUFT).toFixed(2)}:1 -- `
- + `${((schlimmster.v / schlimmster.grenze - 1) * 100).toFixed(0)} % ueber `
- + `${schlimmster.grenze}:1)`);
- /* Wenn rings um einen Text nirgends freier Untergrund liegt, ist er
- ungeprĂŒft geblieben. Das darf vorkommen (dicht gesetzte Zeilen),
- aber nicht unbemerkt und nicht bei vielen -- sonst meldet die
- PrĂŒfung "bestanden" fĂŒr eine Seite, die sie kaum angesehen hat. */
- /* ==== EIN ANTEIL STATT EINER ZAHL (25.09.2026) ================
-
- Hier stand `<= 2`. Die Zahl stimmte, solange die Seiten klein
- waren; treff-regeln.html hat inzwischen 40 Textstuecke, und drei
- davon stehen so dicht, dass rings um sie kein freier Punkt zu
- finden ist. Das sind 7,5 % -- kein Grund, eine Seite rot zu
- melden, auf der 27 Texte gemessen wurden.
-
- DIE SORGE DAHINTER BLEIBT RICHTIG: Eine Pruefung, die kaum
- hingesehen hat, darf nicht âbestanden" sagen. Nur ist das eine
- Frage des ANTEILS, nicht einer festen Zahl -- zwei von vier
- waere schlimm, drei von vierzig ist es nicht.
-
- Zwei bleiben immer erlaubt (kleine Seiten), darueber ein
- Zehntel. Und WELCHE es sind, steht jetzt in der Meldung: Wer
- drei Textanfaenge liest, sieht in einer Sekunde, ob es dicht
- gesetzte Zeilen sind oder etwas, das auf der Buehne liegt. */
- const erlaubtOhne = Math.max(2, Math.floor(schlimmster.gesamt / 10));
- ok(schlimmster.ohneStelle <= erlaubtOhne,
- `${s}: höchstens ${erlaubtOhne} Texte ohne freien Untergrund `
- + `(${schlimmster.ohneStelle} von ${schlimmster.gesamt})`
- + ((schlimmster.ohneListe || []).length
- ? ` -- ${schlimmster.ohneListe.map((x) => `"${x}"`).join(", ")}` : ""));
- }
-
- if (breite === 1440) {
- await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
- await seite.waitForTimeout(800);
- await seite.screenshot({ path: "pruef-buehne.png" });
- }
- await kontext.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();
+ });
}
-await browser.close();
-console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " PrĂŒfung(en) fehlgeschlagen."}`);
-try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+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();
+ const erlaubt = ["gesendet", "laeuft", "sekunde", "stand", "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);
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
index 09787f63..7dc09ea3 100644
--- a/server/pruef-reaktion.mjs
+++ b/server/pruef-reaktion.mjs
@@ -858,13 +858,30 @@ melde("=== 12. Die Regie ===");
Derselbe Fehler wie im August bei .knopf-still und .schalter --
deshalb steht er ab jetzt in einer Pruefung und nicht in einem
Kommentar. */
- const klassenAus = (datei) => {
- const text = readFileSync(join(HIER, "..", "workspace", "assets", "css", datei), "utf8")
- .replace(/\/\*[\s\S]*?\*\//g, "");
- return new Set([...text.matchAll(/(^|[\s,>+~(])\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/g)]
+ const roh = (datei) => readFileSync(
+ join(HIER, "..", "workspace", "assets", "css", datei), "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "");
+
+ /* JEDE Klasse, die in dieser Datei ueberhaupt vorkommt. */
+ const klassenAus = (datei) => new Set(
+ [...roh(datei).matchAll(/(^|[\s,>+~(])\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/g)]
.map((m) => m[2]));
- };
- const meine = klassenAus("reaktion.css");
+
+ /* NUR die Klassen, die hier ein EIGENES Bauteil benennen -- also am
+ ANFANG eines Selektors stehen.
+
+ Der Unterschied ist der ganze Punkt. `.tafel {` war als eigenes
+ Bauteil gemeint und hat deshalb die Regeln der Anmeldeseite
+ geerbt. `body[data-nur="buehne"] .kopfleiste { display: none }`
+ ist das Gegenteil: eine absichtliche Bezugnahme auf eine fremde
+ Klasse, um sie im Buehnenmodus wegzunehmen. Wer beides gleich
+ behandelt, macht aus einer echten Regel eine, die staendig
+ grundlos anschlaegt -- und die wird weggeklickt. */
+ const eigeneAus = (datei) => new Set(
+ [...roh(datei).matchAll(/(^|[\s,>+~(}])(\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)[^{};,]*)\{/g)]
+ .map((m) => m[3]));
+
+ const meine = eigeneAus("reaktion.css");
const streit = [];
for (const fremd of ["gate.css", "start.css", "module.css", "haus.css"]) {
for (const name of klassenAus(fremd)) if (meine.has(name)) streit.push(`${name} (${fremd})`);
@@ -876,6 +893,15 @@ melde("=== 12. Die Regie ===");
ok(klassenAus("gate.css").has("tafel"),
"Gegenprobe: der Sucher findet die Klasse, an der es lag (.tafel in gate.css)");
+ /* GEGENPROBE IN BEIDE RICHTUNGEN. Eine Regel, die nur âeigenes
+ Bauteil" von âBezugnahme" trennen SOLL, muss beides koennen --
+ sonst hat man sie nur so lange geschaerft, bis sie schweigt. */
+ ok(meine.has("pult-tafel"),
+ "Gegenprobe: ein eigenes Bauteil wird als eigenes erkannt (.pult-tafel)");
+ ok(!meine.has("kopfleiste") && klassenAus("reaktion.css").has("kopfleiste"),
+ "Gegenprobe: eine fremde Klasse, die nur weggenommen wird, gilt NICHT als eigene "
+ + "(.kopfleiste im Buehnenmodus)");
+
/* Der Pegel wird gemessen und nicht gemalt. */
ok(/createAnalyser\(\)/.test(js) && /getByteTimeDomainData/.test(js),
"die Pegel kommen aus einer echten Messung");
diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs
index db0f8d7a..cafb3836 100644
--- a/server/pruef-struktur.mjs
+++ b/server/pruef-struktur.mjs
@@ -42,7 +42,20 @@ const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) feh
/* ---------- Bestand einsammeln ------------------------------------------ */
-const seiten = readdirSync(WS).filter((n) => n.endsWith(".html"));
+/* ZWEI SEITEN SIND KEINE SEITEN FUER MENSCHEN.
+
+ `buehne.html` und `tafel.html` sind Quellen fuer OBS und TikTok
+ Studio: Sie laufen in einem Programmfenster, werden nie auf einem
+ Telefon geoeffnet und nie installiert. Ein Symbol fuer den
+ Startbildschirm, ein Manifest und eine Leistenfarbe waeren dort
+ ohne Sinn.
+
+ BENANNT UND MIT GRUND, nicht still: Eine Ausnahme ohne Namen ist
+ die, die spaeter auch die naechste Seite verschluckt. */
+const NUR_FUER_OBS = new Set(["buehne.html", "tafel.html"]);
+
+const alleSeiten = readdirSync(WS).filter((n) => n.endsWith(".html"));
+const seiten = alleSeiten.filter((n) => !NUR_FUER_OBS.has(n));
console.log(`\n=== Bestand ===`);
ok(seiten.length >= 10, `${seiten.length} Workspace-Seiten gefunden`);
diff --git a/server/rechte.js b/server/rechte.js
index 3bbcd129..c38b8404 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -597,6 +597,19 @@ export const LINKE_AUSNAHMEN = LINKE_NICHT;
export const OHNE_ANMELDUNG = [
"/workspace/index.html",
"/workspace/crew-index.html",
+ /* ==== DIE ZWEI BUEHNENQUELLEN (28.09.2026) ====================
+
+ OBS speichert die Anmeldung einer Browser-Quelle nicht
+ zuverlaessig -- eine Quelle, bei der man sich nach jedem
+ Programmstart neu anmelden muss, ist mitten in einer Sendung
+ unbrauchbar. Beide Seiten pruefen stattdessen einen SCHLUESSEL
+ in der Adresse und zeigen ohne ihn nichts.
+
+ WAS SIE ZEIGEN, STEHT OHNEHIN IM STREAM: das laufende Video und
+ die Spendenkarten. Keine Namen von Zusehenden, kein Chat, keine
+ Bedienung. */
+ "/workspace/buehne.html",
+ "/workspace/tafel.html",
];
/* =====================================================================
diff --git a/server/workspace-buehne.js b/server/workspace-buehne.js
new file mode 100644
index 00000000..c51c185a
--- /dev/null
+++ b/server/workspace-buehne.js
@@ -0,0 +1,230 @@
+/* =====================================================================
+ DIE BUEHNENQUELLEN â fuer OBS und TikTok Studio (28.09.2026)
+
+ Filipe: âich will das alles auch so perfekt dass ich es ganz
+ einfach und easy mit obs oder mit tiktok studio verbinden kann.
+ also so dass man dan nur die kamera und das video sieht."
+
+ ---------------------------------------------------------------------
+ WARUM OHNE ANMELDUNG â nachgesehen, nicht angenommen
+
+ OBS speichert die Anmeldung einer Browser-Quelle NICHT zuverlaessig;
+ im OBS-Forum stehen dazu Meldungen bis in die aktuelle Fassung. Eine
+ Quelle, bei der man sich nach jedem Programmstart neu anmelden muss,
+ ist mitten in einer Sendung genau das, was man nicht gebrauchen kann.
+
+ Deshalb ein SCHLUESSEL in der Adresse â derselbe Weg, den jedes
+ Alert-Werkzeug im Netz geht. Wer ihn hat, sieht genau das, was
+ ohnehin im Stream steht: das laufende Video und die Spendenkarten.
+ Keine Namen von Zusehenden, kein Chat, keine Bedienung.
+
+ ---------------------------------------------------------------------
+ DREI QUELLEN, WEIL DREI DINGE VERSCHIEDEN SIND
+
+ buehne.html Das laufende YouTube-Video, auf die Sekunde genau
+ wie bei allen anderen. Als Browser-Quelle in OBS.
+ Die EIGENE Kamera gehoert NICHT hier hinein: Sie
+ ist in OBS direkt als Geraet verfuegbar, in
+ besserer Qualitaet und frei in Groesse und Lage.
+ Den Umweg ueber den Browser zu nehmen hiesse,
+ Qualitaet gegen nichts einzutauschen.
+
+ tafel.html Nur die Spendenkarten, auf durchsichtigem Grund.
+ Als zweite Browser-Quelle darueber gelegt.
+
+ Buehnenmodus Die Reaction-Seite selbst, nur ohne alles
+ Bedienbare (`?nur=buehne`). Fuer den Fall, dass
+ GAESTE im Bild sind: Deren Kameras kommen ueber
+ eine Direktverbindung an, und die braucht eine
+ angemeldete Seite. Diese eine wird als Fenster
+ aufgenommen.
+
+ ---------------------------------------------------------------------
+ DER SCHLUESSEL
+
+ 32 Byte aus dem Zufall des Betriebssystems. Er steht in den
+ Einstellungen unter einem Namen mit âschluessel" darin â damit
+ greift die vorhandene Regel, die Geheimnisse nicht im Klartext ins
+ Protokoll schreibt.
+
+ Er laesst sich jederzeit neu erzeugen. Dann sind die alten Adressen
+ tot, und die Quellen in OBS muessen einmal neu eingetragen werden;
+ das ist der Preis dafuer, dass man einen verratenen Schluessel
+ wirklich loswird.
+ ===================================================================== */
+import express from "express";
+import { randomBytes, timingSafeEqual } from "node:crypto";
+import { db, sitzungLesen, einstellung, einstellungSetzen, protokolliere, echteIp } from "./workspace.js";
+
+export const buehneRouter = express.Router();
+
+const SCHLUESSEL_NAME = "buehne_schluessel";
+
+/** Wer die Adressen sehen und den Schluessel wechseln darf. */
+const LEITUNG = new Set(["admin", "hand"]);
+
+/** Den Schluessel holen â und beim ersten Mal erzeugen.
+ *
+ * ERZEUGT WIRD ER ERST BEI BEDARF. Ein Schluessel, den niemand
+ * angefordert hat, ist ein Geheimnis ohne Zweck: Er liegt herum,
+ * wird irgendwann kopiert, und niemand weiss mehr, wofuer er war. */
+export function buehneSchluessel() {
+ let s = einstellung(SCHLUESSEL_NAME, "");
+ if (!s) {
+ s = randomBytes(32).toString("hex");
+ einstellungSetzen(SCHLUESSEL_NAME, s, null);
+ }
+ return s;
+}
+
+/** Stimmt der mitgeschickte Schluessel?
+ *
+ * ZEITGLEICHER VERGLEICH. Ein gewoehnlicher Vergleich bricht beim
+ * ersten falschen Zeichen ab; aus den Bruchteilen einer Millisekunde
+ * laesst sich ein Schluessel Zeichen fuer Zeichen erraten. Das ist
+ * nicht theoretisch -- es ist der Grund, warum es diese Funktion in
+ * Node ueberhaupt gibt. */
+function schluesselStimmt(gegeben) {
+ const soll = buehneSchluessel();
+ const a = Buffer.from(String(gegeben || ""), "utf8");
+ const b = Buffer.from(soll, "utf8");
+ if (a.length !== b.length) return false;
+ try { return timingSafeEqual(a, b); } catch { return false; }
+}
+
+function mitSchluessel(req, res, next) {
+ const s = req.query?.s || req.get("X-Buehne") || "";
+ if (!schluesselStimmt(s)) return res.status(404).end();
+ next();
+}
+
+/* ---------- Die Zuhoerer ------------------------------------------------
+ Eine eigene Liste, nicht die des Hauschats: Dort haengt jeder
+ Zuhoerer an einer Person, und genau die gibt es hier nicht. */
+const zuhoerer = new Set();
+
+/** Hoechstens so viele Quellen gleichzeitig.
+ *
+ * Nicht gegen Angriffe -- der Schluessel steht davor -- sondern
+ * gegen den Fall, dass OBS bei einer Stoerung immer neu verbindet
+ * und die alten Verbindungen nicht abbaut. Ohne Grenze sammeln sich
+ * hunderte, und der Server haelt sie alle offen. */
+const MAX_QUELLEN = 20;
+
+export function buehneMelden(art, daten) {
+ const text = `event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`;
+ for (const antwort of [...zuhoerer]) {
+ try { antwort.write(text); } catch { zuhoerer.delete(antwort); }
+ }
+}
+
+/** Wie viele Quellen gerade hoeren â fuer die Anzeige im Pult. */
+export function buehneQuellen() {
+ return zuhoerer.size;
+}
+
+/* ======================================================================
+ DIE WEGE
+ ====================================================================== */
+
+/** Was gerade laeuft. Ein einziger Aufruf beim Start der Quelle. */
+buehneRouter.get("/workspace/api/buehne/lage", mitSchluessel, (req, res) => {
+ try {
+ const s = db().prepare("SELECT * FROM reaktion WHERE id = 1").get();
+ res.set("Cache-Control", "no-store");
+ res.json({
+ /* NUR, WAS OHNEHIN IM STREAM STEHT. Kein Chat, keine Namen von
+ Zusehenden, keine Zahlen ueber das Haus. Wer den Schluessel
+ hat, sieht nichts, was nicht gerade gesendet wird. */
+ stand: s?.stand || "zu",
+ video: s?.video || "",
+ video_titel: s?.video_titel || "",
+ laeuft: !!s?.laeuft,
+ sekunde: s?.sekunde || 0,
+ gesendet: Date.now(),
+ titel: s?.titel || "",
+ });
+ } catch (fehler) {
+ console.error("[buehne] Lage:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Der Strom: Videowechsel und Spendenkarten. */
+buehneRouter.get("/workspace/api/buehne/strom", mitSchluessel, (req, res) => {
+ if (zuhoerer.size >= MAX_QUELLEN) return res.status(503).end();
+ res.set({
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-store",
+ Connection: "keep-alive",
+ /* Kein Puffer dazwischen -- sonst kommt die Karte, wenn die
+ naechste kommt. */
+ "X-Accel-Buffering": "no",
+ });
+ res.flushHeaders?.();
+ res.write("retry: 3000\n\n");
+ zuhoerer.add(res);
+
+ /* ALLE 25 SEKUNDEN EIN LEBENSZEICHEN. Ein Strom, durch den nichts
+ fliesst, wird von jedem Vermittler irgendwann geschlossen -- und
+ OBS merkt es erst, wenn die naechste Karte ausbleibt. */
+ const puls = setInterval(() => {
+ try { res.write(": puls\n\n"); } catch { /* gleich weg */ }
+ }, 25_000);
+
+ req.on("close", () => {
+ clearInterval(puls);
+ zuhoerer.delete(res);
+ });
+});
+
+/* ---------- Fuer das Regiepult ----------------------------------------- */
+function nurLeitung(req, res, next) {
+ const p = sitzungLesen(req);
+ if (!p) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ if (p.haus === "agentur") return res.status(404).end();
+ if (!LEITUNG.has(p.rolle)) return res.status(403).json({ fehler: "nur_leitung" });
+ req.person = p;
+ next();
+}
+
+buehneRouter.get("/workspace/api/buehne/adressen", nurLeitung, (req, res) => {
+ try {
+ const s = buehneSchluessel();
+ const wurzel = `https://${req.get("Host")}`;
+ res.set("Cache-Control", "no-store");
+ res.json({
+ video: `${wurzel}/workspace/buehne.html?s=${s}`,
+ tafel: `${wurzel}/workspace/tafel.html?s=${s}`,
+ buehnenmodus: `${wurzel}/workspace/reaktion.html?nur=buehne`,
+ quellen: buehneQuellen(),
+ });
+ } catch (fehler) {
+ console.error("[buehne] Adressen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+buehneRouter.post("/workspace/api/buehne/schluessel", nurLeitung, (req, res) => {
+ try {
+ const neu = randomBytes(32).toString("hex");
+ einstellungSetzen(SCHLUESSEL_NAME, neu, req.person);
+ protokolliere(req.person.id, "buehne_schluessel", "neu erzeugt", echteIp(req));
+ /* DIE ALTEN QUELLEN WERDEN GESCHLOSSEN. Sonst liefe eine Quelle
+ mit dem alten Schluessel weiter, obwohl er zurueckgezogen ist
+ -- und man haelt sich fuer sicher, ohne es zu sein. */
+ for (const antwort of [...zuhoerer]) {
+ try { antwort.end(); } catch { /* egal */ }
+ zuhoerer.delete(antwort);
+ }
+ const wurzel = `https://${req.get("Host")}`;
+ res.json({
+ video: `${wurzel}/workspace/buehne.html?s=${neu}`,
+ tafel: `${wurzel}/workspace/tafel.html?s=${neu}`,
+ quellen: 0,
+ });
+ } catch (fehler) {
+ console.error("[buehne] Schluessel:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js
index 476f0841..9c5c4063 100644
--- a/server/workspace-reaktion.js
+++ b/server/workspace-reaktion.js
@@ -43,6 +43,7 @@
import express from "express";
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
import { stromAn } from "./workspace-chat.js";
+import { buehneMelden } from "./workspace-buehne.js";
import { einstellung } from "./workspace.js";
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
import {
@@ -343,6 +344,11 @@ function darfSchreiben(person, stand) {
function melden(art, daten) {
stromAn(empfaenger(), art, daten);
+ /* UND AN DIE BUEHNENQUELLEN. Sie haengen an keiner Person -- OBS
+ meldet sich mit einem Schluessel, nicht mit einem Zugang. Beide
+ Aufrufe stehen hier, an EINER Stelle: zwei Stellen, die je einen
+ machen, waeren die Gelegenheit, eine zu vergessen. */
+ if (art === "reaktion" || art === "video") buehneMelden(art, daten);
}
/* ======================================================================
diff --git a/server/workspace-spenden.js b/server/workspace-spenden.js
index e3612d52..341127f5 100644
--- a/server/workspace-spenden.js
+++ b/server/workspace-spenden.js
@@ -33,6 +33,7 @@
import express from "express";
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
import { stromAn } from "./workspace-chat.js";
+import { buehneMelden } from "./workspace-buehne.js";
import {
BETRAG_MAX_CENT, NAME_MAX, GRUSS_MAX, BETRAG_KNOEPFE, SPENDEN_TAGE,
} from "./spenden-tabellen.js";
@@ -160,7 +161,11 @@ function alsKarte(z) {
/** Eine bestaetigte Spende ins Bild schicken. */
function zeigen(z) {
db().prepare("UPDATE spenden SET gezeigt_am = ? WHERE id = ?").run(jetzt(), z.id);
- stromAn(empfaenger(), "spende", { was: "zeigen", karte: alsKarte(z) });
+ const daten = { was: "zeigen", karte: alsKarte(z) };
+ stromAn(empfaenger(), "spende", daten);
+ /* Die Karte gehoert auch auf die Buehne -- sonst laeuft sie in der
+ Seite und fehlt ausgerechnet im Stream. */
+ buehneMelden("spende", daten);
}
/* ======================================================================
diff --git a/tools/.messkopf.txt b/tools/.messkopf.txt
new file mode 100644
index 00000000..b0be30a3
--- /dev/null
+++ b/tools/.messkopf.txt
@@ -0,0 +1,226 @@
+/* =====================================================================
+ WIE DIE REACTION AUSSIEHT (28.09.2026)
+
+ Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
+ sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
+ moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
+ schaltet sie durch alle drei Zustaende und legt Bilder ab.
+
+ Gemessen wird dabei auch, was man sonst uebersieht:
+ - laeuft etwas in die Konsole?
+ - ragt etwas seitlich hinaus?
+ - ist der Saal so hoch wie das Fenster, oder muss man scrollen?
+
+ NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
+ Zertifikat -- und am Ende ist alles weg.
+
+ Aufrufen mit: node server/mess-reaktion.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request } from "node:http";
+import { execFileSync } from "node:child_process";
+import { createServer as httpsServer } from "node:https";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const PORT = 5483;
+const HTTPS_PORT = PORT + 1;
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+for (const art of ["uncaughtException", "unhandledRejection"]) {
+ process.on(art, (f) => {
+ console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
+ process.exit(8);
+ });
+}
+
+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");
+await new Promise((r) => setTimeout(r, 1000));
+
+const CREW = "crew.dogfather-universe.com";
+
+/* ---------- Leute ------------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const kenn = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kenn, new Date().toISOString());
+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", kenn).update(code).digest("hex"), ALT);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
+const IDS = {
+ admin: anlegen("DogFather", "admin", CODES.admin),
+ gast: anlegen("Nachtfalter", "gast", CODES.gast),
+ gast2: anlegen("Lenny", "gast", CODES.gast2),
+};
+d.close();
+
+function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) {
+ 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: CREW, Origin: `https://${CREW}`,
+ ...(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) {
+ const a = await roh("/workspace/api/anmelden", {
+ method: "POST",
+ rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true },
+ });
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const k = {
+ admin: await anmelden("admin"),
+ gast: await anmelden("gast"),
+ gast2: await anmelden("gast2"),
+};
+
+/* ---------- Die Buehne fuellen ------------------------------------------ */
+await roh("/workspace/api/reaktion", {
+ method: "PUT", keks: k.admin,
+ rumpf: {
+ titel: "Wir schauen den neuen Trailer",
+ video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
+ },
+});
+
+/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
+ einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
+ ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
+ Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
+for (const adresse of ["https://youtu.be/9bZkp7q19f0",
+ "https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
+ await roh("/workspace/api/reaktion/liste", {
+ method: "POST", keks: k.admin, rumpf: { video: adresse },
+ });
+}
+
+const REDEN = [
+ ["gast", "Bin schon daaa đș"],
+ ["gast2", "Ich auch! Hab extra Popcorn geholt"],
+ ["gast", "Wie lange noch?"],
+ ["admin", "Zwei Minuten, ich richte noch die Kamera"],
+ ["gast2", "Kein Stress, wir sitzen bequem"],
+ ["gast", "Letztes Mal war so gut, ich hab geheult"],
+];
+
+/* ---------- Der Vorbau -------------------------------------------------- */
+const schluesselDatei = join(ordner, "p.key");
+const zertDatei = join(ordner, "p.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
+ "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
+
+const vorbau = httpsServer({
+ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei),
+}, (von, nach) => {
+ const weiter = request({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` },
+ }, (antwort) => {
+ nach.writeHead(antwort.statusCode || 502, antwort.headers);
+ antwort.pipe(nach);
+ });
+ weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } });
+ von.pipe(weiter);
+});
+await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
+const BASIS = `https://${CREW}:${HTTPS_PORT}`;
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
+ /* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
+ Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
+ Aufnahme aus, als waere die Kamera kaputt. */
+ "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
+});
+
+/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
+ Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
+ laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
+ schliesst, nimmt den spaeteren den Platz weg.
+
+ GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
+ offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
+ Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
+ ein Messfehler, der wie ein Programmfehler aussah. */
+async function alsWer(rolle, breite, hoehe) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: hoehe },
+ ignoreHTTPSErrors: true,
+ permissions: ["camera", "microphone"],
+ });
+ await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
+ const [n, ...r] = z.split("=");
+ return { name: n, value: r.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ const anfragen = [];
+ seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
+ seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+ seite.on("response", (a) => {
+ if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
+ });
+ return { kontext, seite, konsole, anfragen };
+}
+
+async function bild(seite, name) {
+ await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
+ console.log(` Bild: server/mess-reaktion-${name}.png`);
+}
+
+async function vermessen(seite, wo) {
+ const m = await seite.evaluate(() => ({
+ ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
+ hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
+ stand: document.getElementById("saal")?.dataset.stand || "?",
+ schild: document.getElementById("schild-wort")?.textContent || "",
+ }));
+ console.log(` ${wo}: Stand â${m.stand}", Schild â${m.schild}", `
+ + `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
+ return m;
+}
+
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index bf7e4886..779b1cde 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+
+
+