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`. --> - - + + - + + + +
+ + + diff --git a/workspace/calls.html b/workspace/calls.html index 32a5e019..9ac7d966 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,15 +89,15 @@ - - + + - - - - - - - + + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 9a3df4dc..6050c927 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -739,24 +739,24 @@ - + - - - - - - - + + + + + + + - + - - - + + + diff --git a/workspace/content.html b/workspace/content.html index 9f1b888f..c1f6c65b 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,15 +179,15 @@ - - + + - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 3ab5cb68..9e25c7cc 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -459,7 +459,7 @@ - - + + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index cce89a8b..5668c943 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -23,25 +23,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609280834", + "src": "/assets/img/app-symbole/crew-192.png?v=202609280908", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609280834", + "src": "/assets/img/app-symbole/crew-512.png?v=202609280908", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609280834", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609280908", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609280834", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609280908", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 4591194f..270a9ef2 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,15 +108,15 @@ - - - + + + - - - - - - + + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 58d50dc1..52c3c08d 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -297,20 +297,20 @@ - - + + - - - - - - + + + + + + - - + + diff --git a/workspace/hilfe.html b/workspace/hilfe.html index de2c5a06..c86bc9c9 100644 --- a/workspace/hilfe.html +++ b/workspace/hilfe.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -146,14 +146,14 @@ - - + + - - - - - - + + + + + + diff --git a/workspace/index.html b/workspace/index.html index da3a2fbb..1d75cdea 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -427,7 +427,7 @@ - - + + diff --git a/workspace/kalender.html b/workspace/kalender.html index d79d61cd..9144e1b6 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -383,15 +383,15 @@ - - + + - - - - - - - + + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 502780ac..44ab7daa 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,13 +444,13 @@ - - - - - - - - + + + + + + + + diff --git a/workspace/material.html b/workspace/material.html index bca74620..985109c3 100644 --- a/workspace/material.html +++ b/workspace/material.html @@ -12,13 +12,13 @@ vergessen wird. --> - - - - - - - + + + + + + + @@ -138,14 +138,14 @@

Einen Moment 


- - - - - - - - - + + + + + + + + + diff --git a/workspace/notizen.html b/workspace/notizen.html index 5f838ed9..3b7b98d6 100644 --- a/workspace/notizen.html +++ b/workspace/notizen.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -181,12 +181,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 7c09c17b..ce149a74 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -453,14 +453,14 @@ - - + + - - - - - - + + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index cba09148..13dd314e 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,16 +214,16 @@ - - - + + + - - - - - - - + + + + + + + diff --git a/workspace/reaktion.html b/workspace/reaktion.html index dc0cd593..8642d526 100644 --- a/workspace/reaktion.html +++ b/workspace/reaktion.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -425,6 +425,21 @@ Die Anordnung gilt fĂŒr alle — sie ist Teil der Sendung, nicht deine Ansicht.

+ + +

FĂŒr OBS & TikTok Studio + –

+
+

+ In OBS: Quelle → Browser, Adresse einfĂŒgen, + 1920 × 1080. Deine eigene Kamera nimmst du in OBS direkt als + GerĂ€t — das ist schĂ€rfer und du kannst sie frei schieben und + grĂ¶ĂŸer ziehen. +

+ @@ -471,14 +486,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 9b80e844..3088b338 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,14 +134,14 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - - - - - + + + + + + + + + diff --git a/workspace/report.html b/workspace/report.html index eeb26066..9d36a5a6 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,17 +106,17 @@ - - + + - - - - - - - - + + + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 320e342f..957794f8 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,14 +173,14 @@ - - + + - - - - - - + + + + + + diff --git a/workspace/start.html b/workspace/start.html index 4d26dd36..796e3a44 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -732,16 +732,16 @@

- - + + - - - - - - - + + + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index e490604b..34032d65 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,16 +85,16 @@ - - + + - - - - - - - + + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 712fe3cb..e4d0492f 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -150,16 +150,16 @@ - - - + + + - - - - - - - + + + + + + + diff --git a/workspace/support.html b/workspace/support.html index f9e499fe..f0000fa7 100644 --- a/workspace/support.html +++ b/workspace/support.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -128,16 +128,16 @@ (reading 'LEITUNG')" ab -- gemessen am 24.09.2026, als diese Zeile beim Anlegen der Seite fehlte. Die Kopfleiste kam trotzdem, deshalb sah man es nur in der Browserkonsole. --> - + - - - - - - + + + + + + diff --git a/workspace/tafel.html b/workspace/tafel.html new file mode 100644 index 00000000..ee7a9b72 --- /dev/null +++ b/workspace/tafel.html @@ -0,0 +1,86 @@ + + + + + +Spendentafel + + + + + +
+ + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 333a1f01..80681dc0 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,18 +7,18 @@ - - - - - - + + + + + + - - - + + + @@ -111,15 +111,15 @@ - - + + - - - - - - - + + + + + + + diff --git a/workspace/team.html b/workspace/team.html index 4342581d..75f0d9ac 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,15 +110,15 @@ - + - - - - - - - + + + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index c92c1a5d..9fef6be2 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - + + - - - - - - - + + + + + + + diff --git a/workspace/teilen.html b/workspace/teilen.html index 82ec74b5..54d3349e 100644 --- a/workspace/teilen.html +++ b/workspace/teilen.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -112,15 +112,15 @@ - - + + - - - - - - + + + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index ab7bd5b9..71a9b4a0 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,14 +126,14 @@ - - + + - - - - - - + + + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index b774dd49..f0cb069a 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -248,14 +248,14 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - + - - - - - - - + + + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 276946c6..0834c740 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,16 +149,16 @@ - - + + - + - - - - - + + + + + diff --git a/workspace/unsere-seiten.html b/workspace/unsere-seiten.html index 43bca695..92e5eeaf 100644 --- a/workspace/unsere-seiten.html +++ b/workspace/unsere-seiten.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -335,14 +335,14 @@ - + - - - - - - - + + + + + + + diff --git a/workspace/unterstuetzen.html b/workspace/unterstuetzen.html index b3566c39..20e9900a 100644 --- a/workspace/unterstuetzen.html +++ b/workspace/unterstuetzen.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -106,17 +106,17 @@ - - - - - - - + + + + + + + - - + + diff --git a/workspace/werdegang.html b/workspace/werdegang.html index 37b4f9d4..a8d1ca86 100644 --- a/workspace/werdegang.html +++ b/workspace/werdegang.html @@ -7,15 +7,15 @@ - - - - - - - - - + + + + + + + + + @@ -144,14 +144,14 @@ - + - - - - - - + + + + + + diff --git a/workspace/willkommen.html b/workspace/willkommen.html index 5e6328cc..2f89e4c9 100644 --- a/workspace/willkommen.html +++ b/workspace/willkommen.html @@ -12,13 +12,13 @@ vergessen wird. --> - - - - - - - + + + + + + + - - - - - - - + + + + + + + - + @@ -190,15 +190,15 @@ - - - + + + - - - - - - + + + + + +