From 5d89d108f91c455ff9adc607dc5b6d28bb18a9fe Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 28 Sep 2026 01:52:22 +0200 Subject: [PATCH] Die Reaction: zusammen schauen, live, mit Kamera und Chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filipes Kurznotiz vom 28.09.2026, von links nach rechts: Kachel sichtbar -> geschlossen -> Vorbereitung -> Wartebereich/ Chat -> Countdown -> LIVE -> Reaction + Gaeste + Chat + PayPal -> Ende DREI STAENDE, NICHT SIEBEN „Wartebereich" und „Countdown" sind keine eigenen Zustaende, sondern das, was „Vorbereitung" auf dem Bildschirm TUT. Drei Staende, die sich gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich vier ueberlappen, sind es nicht. DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER Naheliegend waere: Der Host spielt ab, alle sehen seinen Bildschirm. Das waere aus zwei Gruenden falsch. Rechtlich ist ein weitergesendetes YouTube-Video eine oeffentliche Wiedergabe -- genau die Sache, fuer die Kanaele gesperrt werden. Und technisch kostet es Bandbreite und Qualitaet. Jeder Zuschauer laedt das Video deshalb SELBST. Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert, Sekunde. Das sind ein paar Byte, jeder sieht es in voller Qualitaet, und alle sind auf derselben Sekunde. Nachgefuehrt wird erst ab anderthalb Sekunden Abweichung -- ein Player, dem man jede Sekunde eine neue Position gibt, ruckelt sichtbar. DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH Ueber denselben Weg wie die Anrufe im Haus (seit 18.09.), nur mit mehr Empfaengern. Das hat eine Grenze, und sie ist gerechnet, nicht geraten: Bei 360p und rund 350 kbit/s sind zwoelf Zuschauer etwa 4 Mbit/s Upload beim Host. Darueber schaltet die Sendung von selbst auf Ton um -- wer keine Kamera mehr bekommt, hoert alles, sieht das Video und kann schreiben. Ehrlicher als eine Verbindung, die stockt, und sichtbar im Regiepult. Heute sind es elf Menschen im ganzen Haus (gemessen: 1 admin, 1 hand, 1 linke, 4 modi, 4 gast). Die Grenze ist weit weg -- sie steht trotzdem drin, weil sie sonst erst auffaellt, wenn es zu spaet ist. DIE SEITE IST ANDERS GEBAUT ALS JEDE ANDERE IM HAUS Ueberall sonst: Kacheln, Karten, Listen -- man liest, entscheidet, geht wieder. Hier sitzt man. Eine Stunde, mit anderen, auf EINE Sache schauend. Deshalb kein Raster, sondern ein SAAL: grosse Flaeche fuer das Video, Kamerabilder als schwebende Fenster darueber, der Chat als Schiene daneben. Die Seite scrollt nicht -- ein Video, das beim Tippen im Chat nach oben rutscht, ist der schnellste Weg, dass jemand aufhoert zu schreiben. Fuer den Host ein REGIEPULT: vier senkrechte Regler nebeneinander wie an einem Mischpult, darueber die Sendung, daneben Gaeste und Anordnung, unten drei grosse Knoepfe. Es SCHIEBT den Saal, es deckt ihn nicht zu. Die Kachel traegt ihren Zustand als Farbe: grau geschlossen, bernstein in Vorbereitung, rot auf Sendung. Keine Ton-Nummer -- der Farbraum ist bei 46 voll, und sie braucht auch keine. PAYPAL: EINE QUELLE Der Knopf nimmt den Weg, der auf der Unterstuetzen-Seite hinterlegt ist -- derselbe Eintrag, dieselbe Pflege. Ist dort nichts eingetragen oder steht er auf unsichtbar, erscheint hier kein Knopf. Eine geratene Adresse ist an dieser Stelle die gefaehrlichste aller Abkuerzungen. ======================================================================= ACHT FEHLER, DIE OHNE MESSUNG LIVE GEGANGEN WAEREN ======================================================================= 1. `data-live` WAR SCHON VERGEBEN. Die Draussen-Kachel bekommt es, sobald Filipe auf Twitch sendet. Meine Regel haette ihr waehrend jedes Streams die Farbe genommen -- genau dann, wenn sie wichtig ist. Heisst jetzt `data-sendung`, und pruef-reaktion haelt beides auseinander. 2. DIE INHALTSRICHTLINIE HAETTE YOUTUBE LAUTLOS GESPERRT. Die Datei warnt an genau dieser Stelle selbst davor: Am 27.08.2026 hat `frame-src 'none'` den Musik-Knopf stillgelegt -- der Knopf reagierte, das Feld ging auf, und wo die Player sein sollten, blieb es leer. Hier waere das Ergebnis eine schwarze Leinwand vor Publikum gewesen. youtube-nocookie.com fuer den Rahmen (setzt keine Werbekennungen), www.youtube.com fuer die Einbett-API, i.ytimg.com fuer die Vorschaubilder. 3. KAMERA UND MIKROFON WAREN GESPERRT. Dieselbe Falle, vor der index.js selbst warnt -- und die am 18.09. schon einmal zugeschlagen hat. Die Ausnahme ist jetzt eine benannte MENGE statt eines zweiten Sonderfalls, und pruef-kamera-richtlinie.mjs haelt sie GEGEN DEN QUELLTEXT: Welche Seite laedt ein Skript, das getUserMedia aufruft? Genau die muss drinstehen -- und keine andere. Eine Liste, die abgeleitet wird, kann nicht veralten. 4. ZWEI ANRUFE AN DIESELBE PERSON. Zwischen `await kameraHolen()` und dem Anlegen der Verbindung laeuft alles andere weiter; jeder Takt sagte wieder „den kenne ich noch nicht". Der Empfaenger antwortete auf beide Angebote, und die zweite Antwort traf eine Verbindung, die laengst stand. 5. DAS ANGEBOT GING HINAUS, BEVOR DER EMPFAENGER ZUHOEREN KONNTE. Gemessen: [spur] an [3] reaktion_signal | offen: [2,1] ... [spur] Strom auf fuer 3 Lenny Die Anmeldung ist ein gewoehnlicher Abruf und sofort durch, der Ereignisstrom eine stehende Verbindung. Der Host erfaehrt vom Neuankoemmling also zuverlaessig, BEVOR der zuhoeren kann. Die Richtung ist jetzt umgedreht: Wer bereit ist, BITTET um den Anruf -- er ist der Einzige, der das sicher weiss. Dazu ein eigener, schneller Takt (2,5 s) und eine Ruecknahme, wenn ein Angebot bei niemandem ankommt. 6. EIN VIDEO MIT TON STARTET NICHT VON ALLEIN. `videoWidth` war 640, das Bild kam also an -- und das Fenster blieb schwarz. Kein Fehler, keine Meldung, es passiert einfach nichts. Die Kameras starten jetzt stumm (stumm darf losgehen), ein Knopf schaltet den Ton frei, und die erste Beruehrung der Seite tut es ohnehin. 7. DIE LADE AM HANDY GING NICHT AUF. Gemessen: ein 390x775 grosser Saal mit 219 px Video und 556 px Leere darunter. Statt den Knopf zu reparieren, ist die Lade weg -- unter Kopfleiste und Video bleiben auf einem Telefon rund 550 px, das ist mehr Chat, als eine Lade je zeigen wuerde. Ein Zustand weniger ist besser als ein Zustand, der funktioniert. 8. `sendBeacon` KANN NUR POST. Beim Schliessen des Fensters wird ein gewoehnlicher Abruf abgebrochen; mein DELETE waere nie angekommen, und jeder haette zwei Minuten lang als anwesend gegolten. Dazu drei Funde der Hauspruefungen, alle von mir verursacht: 17 Schriftgroessen unter der Lesbarkeitsgrenze von 11,5 px, elf Maschinenworte ohne deutschen Satz, und ein Aufbewahrungseintrag ohne Rechtsgrundlage. ======================================================================= GEMESSEN pruef-reaktion 74 Punkte, 0 Fehler (11 Abschnitte) pruef-kamera-richtlinie 10 Punkte, 0 Fehler (neu, abgeleitet) mess-reaktion beide Kameras kommen an, 640 px, laufen -- beim Zuschauer UND beim Host. Diese Messung hat einen Rueckgabewert: Alles andere kann gruen sein, und trotzdem sitzt jeder vor einem schwarzen Rechteck. pruef-handy 180 (vorher 177), pruef-notizen 79, pruef-aufbewahrung 45, pruef-meldungen 8, pruef-css-klassen 33, pruef-struktur 35, pruef-crew-adresse 161, pruef-haus-trennung 100, pruef-start-ansicht 160 -- alle 0 Fehler. --- server/crew-adresse.js | 1 + server/index.js | 21 +- server/inhaltsrichtlinie.js | 25 +- server/mess-reaktion.mjs | 377 ++++++++++ server/pruef-kamera-richtlinie.mjs | 138 ++++ server/pruef-reaktion.mjs | 578 ++++++++++++++ server/reaktion-tabellen.js | 169 +++++ server/rechte.js | 5 + server/workspace-aufbewahrung.js | 19 + server/workspace-chat.js | 35 + server/workspace-reaktion.js | 665 ++++++++++++++++ server/workspace.js | 21 + workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/reaktion.css | 795 ++++++++++++++++++++ workspace/assets/css/start.css | 78 ++ workspace/assets/js/bereiche.js | 17 + workspace/assets/js/meldung.js | 33 + workspace/assets/js/reaktion.js | 1123 ++++++++++++++++++++++++++++ workspace/assets/js/start.js | 30 + workspace/aufgaben.html | 38 +- workspace/automation.html | 32 +- workspace/befinden.html | 34 +- workspace/bereich.html | 44 +- workspace/bewerben.html | 38 +- workspace/bewerbungen.html | 36 +- workspace/calls.html | 34 +- workspace/chat.html | 40 +- workspace/content.html | 34 +- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +- workspace/entwicklung.html | 36 +- workspace/hilfe.html | 30 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +- workspace/leistung.html | 32 +- workspace/material.html | 32 +- workspace/notizen.html | 28 +- workspace/personen.html | 32 +- workspace/profil.html | 36 +- workspace/reaktion.html | 270 +++++++ workspace/rechte.html | 34 +- workspace/report.html | 36 +- workspace/scouting.html | 32 +- workspace/start.html | 32 +- workspace/startcheck.html | 34 +- workspace/steckbrief.html | 36 +- workspace/support.html | 28 +- workspace/talente.html | 36 +- workspace/team.html | 32 +- workspace/teamlage.html | 34 +- workspace/teilen.html | 30 +- workspace/treff-moderation.html | 32 +- workspace/treff-regeln.html | 32 +- workspace/uebersicht.html | 32 +- workspace/unsere-seiten.html | 30 +- workspace/unterstuetzen.html | 32 +- workspace/werdegang.html | 32 +- workspace/willkommen.html | 30 +- workspace/wissen.html | 34 +- 61 files changed, 5040 insertions(+), 650 deletions(-) create mode 100644 server/mess-reaktion.mjs create mode 100644 server/pruef-kamera-richtlinie.mjs create mode 100644 server/pruef-reaktion.mjs create mode 100644 server/reaktion-tabellen.js create mode 100644 server/workspace-reaktion.js create mode 100644 workspace/assets/css/reaktion.css create mode 100644 workspace/assets/js/reaktion.js create mode 100644 workspace/reaktion.html diff --git a/server/crew-adresse.js b/server/crew-adresse.js index 238c5015..2f18360b 100644 --- a/server/crew-adresse.js +++ b/server/crew-adresse.js @@ -386,6 +386,7 @@ export const GEHOERT_ZU_ADRESSE = [ (nurTeamDogi in workspace-notizen.js) -- die Seite ist nur HTML, die Daten sind die Sache. */ { datei: "/workspace/notizen.html", zuhause: istCrewAdresse }, + { datei: "/workspace/reaktion.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 74297970..91d24dee 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,7 @@ import { befindenRouter } from "./workspace-befinden.js"; import { hilfeRouter } from "./workspace-hilfe.js"; import { supportRouter } from "./workspace-support.js"; import { notizenRouter } from "./workspace-notizen.js"; +import { reaktionRouter } from "./workspace-reaktion.js"; import { unterstuetzungRouter } from "./workspace-unterstuetzung.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; @@ -103,9 +104,24 @@ app.use((req, res, next) => { `(self)` und nicht `*`: Nur diese Seite selbst, keine eingebetteten Fremdinhalte. */ - const chatSeite = /^\/workspace\/chat\.html/.test(req.path); + /* DIE ZWEITE STELLE KAM AM 28.09.2026 DAZU: die Reaction. Sie + zeigt die Kamera des Hosts und bis zu zwei Gaeste, ueber + denselben Weg wie der Anruf im Chat. + + Und sie kam als MENGE dazu, nicht als zweiter Sonderfall: Beim + Chat stand die Bedingung als Einzelfall im Code, und ein zweiter + Einzelfall daneben ist die Bauweise, bei der der dritte + vergessen wird. Vergessen heisst hier: Die Sendung startet, alle + sind da, und die Kamera geht nicht an -- mit einer Meldung, die + auf die falsche Faehrte fuehrt. + + pruef-kamera-richtlinie.mjs haelt die Menge gegen den Quelltext: + Jede Seite, deren Skripte `getUserMedia` aufrufen, muss hier + stehen -- und jede andere darf es nicht. */ + const MIT_KAMERA = [/^\/workspace\/chat\.html/, /^\/workspace\/reaktion\.html/]; + const darfKamera = MIT_KAMERA.some((m) => m.test(req.path)); res.setHeader("Permissions-Policy", - chatSeite + darfKamera ? "geolocation=(), microphone=(self), camera=(self), payment=()" : "geolocation=(), microphone=(), camera=(), payment=()"); @@ -317,6 +333,7 @@ app.use(supportRouter); sieht nur seinen eigenen -- auch DogFather. Begruendung im Kopf von notiz-tabellen.js. */ app.use(notizenRouter); +app.use(reaktionRouter); /* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas beitragen kann – lesen alle, ändern nur DogFather. */ app.use(unterstuetzungRouter); diff --git a/server/inhaltsrichtlinie.js b/server/inhaltsrichtlinie.js index 8089a022..6f00aec8 100644 --- a/server/inhaltsrichtlinie.js +++ b/server/inhaltsrichtlinie.js @@ -76,6 +76,25 @@ import { join, normalize } from "node:path"; /* Spotify: die zwei Player im Musik-Feld (main.js, jede Seite). */ const SPOTIFY = "https://open.spotify.com"; +/* ==== YOUTUBE (28.09.2026, fuer die Reaction) ====================== + + Die Reaction zeigt ein YouTube-Video, und jeder Zuschauer laedt es + SELBST -- weitergesendet wird nur der Spielstand (siehe + reaktion-tabellen.js). Dafuer braucht das Dokument drei Dinge: + + RAHMEN der eingebettete Player. `youtube-nocookie.com` setzt + keine Werbekennungen, solange niemand abspielt; das ist + der Grund, warum diese Adresse zuerst steht. + SKRIPT die Einbett-API (`/iframe_api`) liegt auf www.youtube.com + und laedt von dort ihren eigentlichen Teil nach. Ohne + sie liesse sich der Player nicht fernsteuern -- und genau + das ist der Kern: Alle sehen dieselbe Sekunde. + BILDER die Vorschaubilder (i.ytimg.com), die im Wartebereich + stehen, wenn der Host kein eigenes Bild hinterlegt hat. */ +const YOUTUBE_RAHMEN = "https://www.youtube-nocookie.com https://www.youtube.com"; +const YOUTUBE_SKRIPT = "https://www.youtube.com"; +const YOUTUBE_BILDER = "https://i.ytimg.com https://i9.ytimg.com"; + /* PayPal: das SDK selbst liegt auf www.paypal.com, Bilder/Schriften der Knöpfe auf www.paypalobjects.com. Der Bezahlvorgang läuft anschließend in einem Rahmen von www.paypal.com. @@ -241,7 +260,7 @@ export function inhaltsrichtlinie(wurzel) { wenn ohnehin etwas grundlegend nicht stimmt. */ if (!summen) return next(); - const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT].join(" "); + const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT, YOUTUBE_SKRIPT].join(" "); res.setHeader("Content-Security-Policy", [ "default-src 'self'", @@ -259,7 +278,7 @@ export function inhaltsrichtlinie(wurzel) { ihn: Der Deploy hätte Erfolg gemeldet, der Server wäre gestartet, und der Schaden wäre erst jemandem aufgefallen, der die Seite ansieht. */ - `img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER}`, + `img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER} ${YOUTUBE_BILDER}`, "font-src 'self'", /* Die Seite spricht mit dem eigenen Server und mit dem internen Bereich unter postfach. -- mehr nicht. Ein eingeschleustes @@ -302,7 +321,7 @@ export function inhaltsrichtlinie(wurzel) { pruef-musik.mjs und pruef-kasse.mjs prüfen das im echten Browser mit -- inklusive der Kasse, die vorher genau hier gescheitert wäre. */ - `frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`, + `frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN} ${YOUTUBE_RAHMEN}`, "worker-src 'self'", /* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs new file mode 100644 index 00000000..6ed5b052 --- /dev/null +++ b/server/mess-reaktion.mjs @@ -0,0 +1,377 @@ +/* ===================================================================== + 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(), + }, +}); + +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; +} + +console.log("\n=== ZU ==="); +{ + const { kontext, seite, konsole } = await alsWer("gast", 1440, 900); + await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + await vermessen(seite, "Rechner"); + await bild(seite, "1-zu-rechner"); + if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | ")); + await kontext.close(); +} + +console.log("\n=== VORBEREITUNG ==="); +await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } }); +for (const [wer, text] of REDEN) { + await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } }); +} +{ + const { kontext, seite, konsole } = await alsWer("gast", 1440, 900); + await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + await vermessen(seite, "Rechner"); + await bild(seite, "2-warte-rechner"); + if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | ")); + await kontext.close(); +} + +console.log("\n=== LIVE ==="); +await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); +await roh("/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 } }); + +/* ==== BEIDE SEITEN GLEICHZEITIG ==================================== + Der Host sendet, die Zuschauerin empfaengt -- das geht nur, wenn + beide zur selben Zeit offen sind. */ +const host = await alsWer("admin", 1440, 900); +await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); +await host.seite.waitForTimeout(2500); + +const gast2 = await alsWer("gast2", 1280, 800); +await gast2.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); +await gast2.seite.waitForTimeout(2500); + +const zuschauer = await alsWer("gast", 1440, 900); +await zuschauer.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); + +/* Verbindungen brauchen ein paar Sekunden: Adressen holen, Angebot, + Antwort, Kandidaten. Acht Sekunden sind grosszuegig -- es geht + darum, ob es UEBERHAUPT ankommt, nicht wie schnell. */ +await zuschauer.seite.waitForTimeout(8000); + +async function bildBreiten(seite) { + return seite.evaluate(() => { + const raus = {}; + for (const k2 of document.querySelectorAll("#kameras .kamera")) { + const v = k2.querySelector("video"); + raus[k2.querySelector(".kamera__name")?.textContent || k2.dataset.wer] = { + breite: v ? v.videoWidth : -1, + strom: !!(v && v.srcObject), + laeuft: !!(v && !v.paused), + stumm: !!(v && v.muted), + }; + } + return raus; + }); +} + +console.log("\n --- Fliesst ein Bild? ---"); +for (const [wer, w] of [["Host", host.seite], ["Gast", gast2.seite], ["Zuschauerin", zuschauer.seite]]) { + const z = await w.evaluate(() => ({ + url: location.pathname, + saal: !!document.getElementById("saal"), + stand: document.getElementById("saal")?.dataset.stand, + kameras: document.querySelectorAll("#kameras .kamera").length, + })).catch((f) => ({ url: "FEHLER " + f.message })); + console.log(` ${wer.padEnd(12)} ${z.url} saal=${z.saal} stand=${z.stand} kameras=${z.kameras}`); + const q = { Host: host, Gast: gast2, Zuschauerin: zuschauer }[wer]; + console.log(" Strom-Anfragen: " + + q.anfragen.filter((x) => x.includes("strom")).length + " | Signale: " + q.anfragen.filter((x) => x.includes("signal")).length); +} +const beiZuschauer = await bildBreiten(zuschauer.seite); +for (const [name, m] of Object.entries(beiZuschauer)) { + const gut = m.breite > 0; + console.log(` Zuschauerin sieht ${name.padEnd(12)} ` + + `${gut ? "BILD " + m.breite + " px" : (m.strom ? "Strom da, aber 0 px" : "NICHTS")}` + + ` ${m.laeuft ? "laeuft" : "STEHT"} ${m.stumm ? "stumm" : "mit Ton"}`); +} +/* AUCH DER HOST MUSS SEINEN GAST SEHEN. Wenn nur die Zuschauer + etwas sehen, redet der Host gegen eine schwarze Kachel -- und + merkt nicht, wenn beim Gast die Kamera aus ist. */ +const beimHost = await bildBreiten(host.seite); +for (const [name, m] of Object.entries(beimHost)) { + console.log(` Host sieht ${name.padEnd(12)} ` + + `${m.breite > 0 ? "BILD " + m.breite + " px" : "NICHTS"}` + + ` ${m.laeuft ? "laeuft" : "STEHT"}`); +} +const zaehlt = Object.values(beiZuschauer).filter((m) => m.breite > 0).length; +console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kommen an.`); +/* DIESE MESSUNG HAT EINEN RUECKGABEWERT -- und zwar wegen dieser + einen Zahl. Alles andere kann gruen sein: Die Schnittstelle + antwortet, die Seite sieht gut aus, die Verbindung steht. Wenn + hier 0 steht, sitzt trotzdem jeder vor einem schwarzen Rechteck. + + Gemessen wurde genau das zweimal: einmal, weil das Angebot + hinausging, bevor der Empfaenger zuhoeren konnte -- einmal, weil + ein Video mit Ton nicht von allein startet. Beide Male sahen die + Bildschirmfotos plausibel aus. */ +let ausgang = 0; +const beimHostAn = Object.values(beimHost).filter((m) => m.breite > 0).length; +if (beimHostAn < Object.keys(beimHost).length) { + console.log(" ACHTUNG: Der Host sieht nicht alle Kameras."); + ausgang = 1; +} +if (!zaehlt) { + console.log(" ACHTUNG: Es kommt kein einziges Bild an."); + ausgang = 1; +} +const stehen = [...Object.values(beiZuschauer), ...Object.values(beimHost)] + .filter((m) => m.breite > 0 && !m.laeuft).length; +if (stehen) { + console.log(` ACHTUNG: ${stehen} Bild(er) kommen an, stehen aber still ` + + "-- ein Video mit Ton startet nicht von allein."); + ausgang = 1; +} + +await host.seite.click("#pult-griff").catch(() => {}); +await host.seite.waitForTimeout(700); +await vermessen(host.seite, "Host am Rechner"); +await bild(host.seite, "3-live-host"); +if (host.konsole.length) console.log(" KONSOLE:", host.konsole.slice(0, 4).join(" | ")); + +await vermessen(zuschauer.seite, "Zuschauerin am Rechner"); +await bild(zuschauer.seite, "4-live-zuschauer"); +if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice(0, 4).join(" | ")); + +await host.kontext.close(); +await gast2.kontext.close(); +await zuschauer.kontext.close(); + +{ + const handy = await alsWer("gast2", 390, 844); + await handy.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); + await handy.seite.waitForTimeout(4000); + await vermessen(handy.seite, "Handy"); + await bild(handy.seite, "5-live-handy"); + + await handy.seite.waitForTimeout(500); + await bild(handy.seite, "6-live-handy-chat"); + const hm = await handy.seite.evaluate(() => { + const g = (w) => { const e = document.querySelector(w); if (!e) return "(fehlt)"; + const r = e.getBoundingClientRect(); const c = getComputedStyle(e); + return `${Math.round(r.width)}x${Math.round(r.height)} display=${c.display}`; }; + return { saal: g("#saal"), kino: g("#teil-live"), leinwand: g(".leinwand"), + schiene: g(".schiene"), schieneWert: document.getElementById("teil-live")?.dataset.schiene }; + }); + console.log(" Handy-Masse:", JSON.stringify(hm)); + if (handy.konsole.length) console.log(" KONSOLE:", handy.konsole.slice(0, 4).join(" | ")); +} + +await browser.close(); +await new Promise((r) => vorbau.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log("\nFertig."); +process.exit(ausgang); diff --git a/server/pruef-kamera-richtlinie.mjs b/server/pruef-kamera-richtlinie.mjs new file mode 100644 index 00000000..0eb2ea59 --- /dev/null +++ b/server/pruef-kamera-richtlinie.mjs @@ -0,0 +1,138 @@ +/* ===================================================================== + WER DIE KAMERA DARF — und wer sie NICHT darf (28.09.2026) + + In index.js steht eine Sperre: Mikrofon und Kamera sind auf JEDER + Seite verboten, ausser auf wenigen benannten. Das ist richtig und + soll so bleiben -- ohne sie koennte ein eingeschleustes Skript + ueberall das Mikrofon anschalten. + + Die Sperre hat aber eine unangenehme Eigenschaft: Wenn sie zu weit + geht, merkt es niemand beim Bauen. Die Seite laedt, die Knoepfe + sind da, und erst wenn jemand auf „Kamera an" drueckt, kommt eine + Meldung, die auf die falsche Faehrte fuehrt -- man sucht dann in + den Browsereinstellungen. Genau so ist es am 18.09.2026 beim + Anruf passiert und am 28.09.2026 bei der Reaction. + + --------------------------------------------------------------------- + DESHALB WIRD HIER NICHT EINE LISTE GEPRUEFT, SONDERN ABGELEITET + + Gesucht wird im Quelltext: Welche Seite laedt ein Skript, das + `getUserMedia` aufruft? Genau diese Seiten brauchen die Erlaubnis. + Eine Liste, die gegen den Quelltext gehalten wird, kann nicht + veralten -- wer morgen eine dritte Seite mit Kamera baut, bekommt + hier einen roten Punkt statt in drei Wochen einen Anruf. + + UND DIE GEGENRICHTUNG WIRD MITGEPRUEFT. Eine Seite, die die + Erlaubnis hat und sie nicht braucht, ist eine offene Tuer ohne + Grund. Auch das faellt hier auf. + + Aufrufen mit: node server/pruef-kamera-richtlinie.mjs + ===================================================================== */ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); +const SEITEN_ORDNER = join(WURZEL, "workspace"); +const JS_ORDNER = join(SEITEN_ORDNER, "assets", "js"); + +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); + +/* ---------- 1. Welche Skripte fassen die Kamera an? --------------------- */ +melde("=== 1. Welche Skripte die Kamera anfassen ==="); + +const jsDateien = readdirSync(JS_ORDNER).filter((n) => n.endsWith(".js")); +const mitKamera = new Set(); +for (const name of jsDateien) { + const text = readFileSync(join(JS_ORDNER, name), "utf8"); + /* Der Aufruf selbst, nicht das Wort in einem Kommentar: Es muss + eine Klammer folgen. Sonst zaehlte jede Erklaerung mit, in der + `getUserMedia` erwaehnt wird -- und davon gibt es hier viele. */ + if (/getUserMedia\s*\(/.test(text.replace(/\/\*[\s\S]*?\*\//g, ""))) { + mitKamera.add(name); + } +} +ok(mitKamera.size > 0, + `${mitKamera.size} Skript(e) rufen getUserMedia auf: ${[...mitKamera].join(", ")}`); + +/* ---------- 2. Welche Seiten laden diese Skripte? ----------------------- */ +melde(""); +melde("=== 2. Welche Seiten sie laden ==="); + +const seiten = readdirSync(SEITEN_ORDNER).filter((n) => n.endsWith(".html")); +const brauchen = new Set(); +for (const seite of seiten) { + const html = readFileSync(join(SEITEN_ORDNER, seite), "utf8"); + for (const js of mitKamera) { + if (html.includes(`assets/js/${js}`)) { brauchen.add(seite); break; } + } +} +ok(brauchen.size > 0, + `${brauchen.size} Seite(n) brauchen die Erlaubnis: ${[...brauchen].sort().join(", ")}`); + +/* ---------- 3. Und was steht in index.js? ------------------------------- */ +melde(""); +melde("=== 3. Der Abgleich ==="); + +const indexText = readFileSync(join(HIER, "index.js"), "utf8"); +const zeile = indexText.match(/const MIT_KAMERA = \[([^\]]*)\]/); +ok(!!zeile, "die Menge MIT_KAMERA steht in index.js"); + +const erlaubt = new Set(); +if (zeile) { + for (const m of zeile[1].matchAll(/workspace\\\/([a-z0-9-]+)\\\.html/g)) { + erlaubt.add(m[1] + ".html"); + } +} +ok(erlaubt.size > 0, `sie nennt ${erlaubt.size} Seite(n): ${[...erlaubt].sort().join(", ")}`); + +const fehlend = [...brauchen].filter((s) => !erlaubt.has(s)); +ok(fehlend.length === 0, + fehlend.length + ? `FEHLT: ${fehlend.join(", ")} laedt ein Kamera-Skript, steht aber nicht in MIT_KAMERA ` + + "-- dort geht die Kamera nicht an, ohne dass jemand einen Fehler sieht" + : "jede Seite, die die Kamera braucht, steht drin"); + +const zuviel = [...erlaubt].filter((s) => !brauchen.has(s)); +ok(zuviel.length === 0, + zuviel.length + ? `ZU VIEL: ${zuviel.join(", ")} hat die Erlaubnis, braucht sie aber nicht ` + + "-- eine offene Tuer ohne Grund" + : "und keine Seite hat sie, ohne sie zu brauchen"); + +/* ---------- 4. Die Gegenprobe ------------------------------------------ + Eine Pruefung, die nur bestaetigen kann, bestaetigt nichts. Hier + wird erfunden, was passiert waere, haette jemand die Reaction + vergessen. */ +melde(""); +melde("=== 4. Gegenprobe ==="); +{ + const erfunden = new Set([...erlaubt].filter((s) => s !== "reaktion.html")); + const haetteGefehlt = [...brauchen].filter((s) => !erfunden.has(s)); + ok(haetteGefehlt.includes("reaktion.html"), + "ohne reaktion.html in der Menge wuerde die Pruefung rot " + + `(${haetteGefehlt.join(", ") || "sie wuerde NICHT anschlagen"})`); +} + +/* ---------- 5. Und die Sperre steht ueberhaupt --------------------------- + Wenn jemand die Zeile ganz entfernt, waeren Kamera und Mikrofon + ueberall offen -- und keine der Pruefungen oben wuerde das sehen, + denn beide Mengen waeren dann leer und stimmten wunderbar ueberein. */ +melde(""); +melde("=== 5. Die Sperre selbst ==="); +{ + ok(/microphone=\(\), camera=\(\)/.test(indexText), + "die Vorgabe verbietet Mikrofon und Kamera"); + ok(/microphone=\(self\), camera=\(self\)/.test(indexText), + "und oeffnet sie nur fuer die eigene Seite, nicht fuer fremde Rahmen"); + ok(/Permissions-Policy/.test(indexText), + "der Kopf heisst Permissions-Policy"); +} + +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 new file mode 100644 index 00000000..f04d8ddd --- /dev/null +++ b/server/pruef-reaktion.mjs @@ -0,0 +1,578 @@ +/* ===================================================================== + DIE REACTION (28.09.2026) + + Geprueft wird der ganze Weg aus Filipes Kurznotiz: + + Kachel sichtbar -> geschlossen -> Vorbereitung -> + Wartebereich/Chat -> Countdown -> LIVE -> + Reaction + Gaeste + Chat + PayPal -> Ende + + --------------------------------------------------------------------- + WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT + + Die Community meldet sich ueber den STILLEN ZUGANG an -- ein Modi + oder ein Zuschauer tippt auf irgendeine Kachel und gibt seinen Code + ein, der Code allein entscheidet. Dieser Weg ist auf einer + Pruefadresse (127.0.0.1) absichtlich abgeschaltet: Er wuerde dort + eine messbare Zeitspur hinterlassen, und genau die soll er nicht. + + Folge: Ohne Host-Kopfzeile koennte sich hier kein einziger + Zuschauer anmelden -- und eine Pruefung, die ihren Hauptdarsteller + nicht hereinbekommt, prueft nichts. `fetch` kann die Kopfzeile + nicht setzen, deshalb node:http. + + Aufrufen mit: node server/pruef-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"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const ordner = mkdtempSync(join(tmpdir(), "ws-reakt-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Reaction-Pruefung"); + +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(260_000, "pruef-reaktion"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const melde = (t) => console.log(t); +process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); + +const CREW = "crew.dogfather-universe.com"; +const AGENTUR = "workspace.dogfather-universe.com"; + +/* ---------- Zugaenge anlegen -------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const nun = new Date().toISOString(); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, nun); + +/* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger. + Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von + heute waere ein Sonderfall, den niemand im Blick hat. */ +const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], + ["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]]; +const CODES = {}; const IDS = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`; + IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]); +} +d.close(); + +/* ---------- Sprechen mit Hausadresse ------------------------------------ */ +function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: host, + Origin: origin === undefined ? `https://${host}` : origin, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function anmelden(rolle, host) { + const a = await roh(host, "/workspace/api/anmelden", { + method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, + code: CODES[rolle], alter_ok: true }, + }); + const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); + return a.code === 200 && keks ? keks : null; +} + +const k = {}; +for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW); + +/* ======================================================================= + 1. ALLE KOMMEN HEREIN — auch die Community + ======================================================================= */ +melde("=== 1. Wer die Reaction sieht ==="); +{ + const fehlend = ROLLEN.map(([r]) => r).filter((r) => r !== "creator" && !k[r]); + ok(fehlend.length === 0, + `alle Rollen des Hauses sind angemeldet (${fehlend.length ? "fehlt: " + fehlend.join(", ") : "5 von 5"})`); + + for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) { + const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] }); + ok(a.code === 200 && a.j?.stand === "zu", + `${rolle}: sieht die Lage und sie ist „zu" (${a.code}/${a.j?.stand})`); + } + const s = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast }); + ok(s.code === 200, `die Seite steht der Community offen (${s.code})`); +} + +/* ======================================================================= + 2. DAS ANDERE HAUS SIEHT NICHTS + ======================================================================= */ +melde(""); +melde("=== 2. Die Agentur kennt sie nicht ==="); +{ + const kA = await anmelden("admin", AGENTUR); + ok(!!kA, "DogFather meldet sich auch auf der Agenturadresse an"); + const a = await roh(AGENTUR, "/workspace/api/reaktion", { keks: kA }); + ok(a.code === 404, `und bekommt dort 404 (${a.code})`); + const s = await roh(AGENTUR, "/workspace/reaktion.html", { keks: kA }); + ok(s.code === 404, `auch die Seite gibt es dort nicht (${s.code})`); + + /* Das andere Ende derselben Regel: Auf einer Pruefadresse hat + niemand ein Haus, und die Schranke darf das nicht mit dem + fremden Haus verwechseln. Genau dieser Fehler ist am 27.09. + beim Notizblock passiert. */ + const kP = await anmelden("admin", "127.0.0.1"); + const p = await roh("127.0.0.1", "/workspace/api/reaktion", { keks: kP }); + ok(p.code === 200, `auf der Pruefadresse ist sie offen (${p.code}) -- ohne Haus wird nicht gesperrt`); +} + +/* ======================================================================= + 3. NUR DER HOST STELLT EIN + ======================================================================= */ +melde(""); +melde("=== 3. Wer die Sendung fuehrt ==="); +{ + for (const rolle of ["hand", "linke", "modi", "gast"]) { + const a = await roh(CREW, "/workspace/api/reaktion", { + method: "PUT", keks: k[rolle], rumpf: { titel: "Nicht du" }, + }); + ok(a.code === 403, `${rolle}: darf nicht einstellen (${a.code})`); + } + const a = await roh(CREW, "/workspace/api/reaktion", { + method: "PUT", keks: k.admin, + rumpf: { titel: "Wir schauen den Trailer", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }, + }); + ok(a.code === 200 && a.j?.video === "dQw4w9WgXcQ", + `DogFather schon -- und aus der Adresse wird die Kennung (${a.j?.video})`); +} + +/* ---- Die Kennung kommt aus jeder Form, die man wirklich einfuegt ---- */ +{ + const { videoKennung } = await import("./workspace-reaktion.js"); + const faelle = [ + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"], + ["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ["dQw4w9WgXcQ", "dQw4w9WgXcQ"], + ]; + const sitzt = faelle.filter(([roh2, soll]) => videoKennung(roh2) === soll).length; + ok(sitzt === faelle.length, `alle ${faelle.length} Formen der Adresse ergeben die Kennung`); + /* Gegenprobe: Was keine Kennung ist, darf auch keine werden -- sonst + stuende vor Publikum ein schwarzes Rechteck. */ + const mist = ["", "https://vimeo.com/12345", "kein-video", "https://www.youtube.com/watch?v=zu_kurz"]; + ok(mist.every((m) => videoKennung(m) === ""), + "und was keine ist, wird auch keine (Gegenprobe)"); + const b = await roh(CREW, "/workspace/api/reaktion", { + method: "PUT", keks: k.admin, rumpf: { video: "https://vimeo.com/12345" }, + }); + ok(b.code === 400 && b.j?.fehler === "video_unklar", + `eine fremde Adresse wird abgelehnt (${b.code}/${b.j?.fehler})`); +} + +/* ======================================================================= + 4. DER ABLAUF + ======================================================================= */ +melde(""); +melde("=== 4. zu -> vorbereitung -> live -> zu ==="); +{ + const a = await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" }, + }); + ok(a.code === 200 && a.j?.stand === "vorbereitung", `Vorbereitung geht (${a.j?.stand})`); + + const b = await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "erfunden" }, + }); + ok(b.code === 400, `ein erfundener Stand wird abgelehnt (${b.code})`); + + /* Im Wartebereich darf schon geschrieben werden -- das ist der + halbe Sinn der Vorbereitung. */ + const c = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "Bin schon da!" }, + }); + ok(c.code === 201, `im Wartebereich ist der Chat offen (${c.code})`); + + const e = await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "live" }, + }); + ok(e.code === 200 && e.j?.stand === "live" && e.j?.gestartet_am, + `auf Sendung -- mit Startzeit (${e.j?.stand})`); +} + +/* ---- Ohne Video kein Live ---- */ +{ + await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" } }); + await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Ohne", video: "" } }); + const a = await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "live" }, + }); + ok(a.code === 400 && a.j?.fehler === "kein_video", + `ohne Video laesst sich nicht senden (${a.code}/${a.j?.fehler})`); + /* und danach wieder aufbauen */ + await roh(CREW, "/workspace/api/reaktion", { + method: "PUT", keks: k.admin, + rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" }, + }); + await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); +} + +/* ======================================================================= + 5. WER DABEI IST + ======================================================================= */ +melde(""); +melde("=== 5. Dabei sein ==="); +{ + for (const rolle of ["gast", "gast2", "modi"]) { + await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] }); + } + const a = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }); + ok(a.j?.dabei === 3, `drei sind dabei (${a.j?.dabei})`); + + ok(Array.isArray(a.j?.zuschauer) && a.j.zuschauer.length === 3, + "DogFather sieht, WER dabei ist"); + + const z = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast }); + ok(z.j?.zuschauer === null, + "ein Zuschauer bekommt die Namensliste NICHT"); + ok(z.j?.dabei === 3, `die Zahl sieht er aber (${z.j?.dabei})`); + + /* Jeder Dabeisitzende hat einen Kameraplatz bekommen -- es sind + erst drei, und zwoelf waeren erlaubt. */ + ok(a.j.zuschauer.every((x) => x.sicht === true), + "alle drei haben einen Kameraplatz (bei 12 Plaetzen)"); + + /* Abmelden: der Weg, den `sendBeacon` beim Schliessen benutzt -- + POST, nicht DELETE. Beides muss gehen. */ + const w1 = await roh(CREW, "/workspace/api/reaktion/dabei-weg", { method: "POST", keks: k.modi }); + ok(w1.code === 200, `abmelden per POST geht (fuer sendBeacon) (${w1.code})`); + const n1 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }); + ok(n1.j?.dabei === 2, `danach sind es zwei (${n1.j?.dabei})`); + await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k.modi }); +} + +/* ======================================================================= + 6. GAESTE + ======================================================================= */ +melde(""); +melde("=== 6. Ein bis zwei Gaeste ==="); +{ + const { GAESTE_MAX } = await import("./reaktion-tabellen.js"); + ok(GAESTE_MAX === 2, `zwei Plaetze, wie in der Notiz (${GAESTE_MAX})`); + + const a = await roh(CREW, "/workspace/api/reaktion/gast", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast }, + }); + ok(a.code === 201 && a.j?.gaeste?.length === 1, `Gast 1 dazu (${a.code})`); + ok(a.j.gaeste[0].platz === 1, `er bekommt Platz 1 (${a.j?.gaeste?.[0]?.platz})`); + + const b = await roh(CREW, "/workspace/api/reaktion/gast", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 }, + }); + ok(b.code === 201 && b.j?.gaeste?.length === 2, `Gast 2 dazu (${b.code})`); + + const c = await roh(CREW, "/workspace/api/reaktion/gast", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi }, + }); + ok(c.code === 409 && c.j?.fehler === "gaeste_voll", + `ein dritter passt nicht (${c.code}/${c.j?.fehler})`); + + /* EIN GAST BEKOMMT DIE NAMENSLISTE -- und das ist kein Zufall: + Ohne sie wuesste er nicht, wen er anrufen soll, und sein Bild + kaeme bei keinem Zuschauer an. */ + const g = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast }); + ok(Array.isArray(g.j?.zuschauer), + "wer auf der Buehne steht, bekommt die Liste (sonst sieht ihn niemand)"); + ok(g.j?.ich?.gast === true, "und er weiss, dass er Gast ist"); + + /* Stummschalten gilt fuer alle -- das steht am Server, nicht im + Browser des Hosts. */ + const s = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast}/stumm`, { + method: "PUT", keks: k.admin, rumpf: { stumm: true }, + }); + ok(s.code === 200 && s.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true, + "stummschalten wirkt"); + const s2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 }); + ok(s2.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true, + "und alle anderen sehen es auch (nicht nur der Host)"); + + /* Ein Gast kann sich nicht selbst holen oder entfernen. */ + const f = await roh(CREW, "/workspace/api/reaktion/gast", { + method: "POST", keks: k.gast, rumpf: { person_id: IDS.modi }, + }); + ok(f.code === 403, `ein Gast holt niemanden dazu (${f.code})`); + + const w = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast2}`, { + method: "DELETE", keks: k.admin, + }); + ok(w.code === 200 && w.j?.gaeste?.length === 1, `entfernen geht (${w.code})`); + + /* Der frei gewordene Platz wird WIEDERVERWENDET. Mit „Anzahl + 1" + bliebe Platz 1 fuer immer leer, sobald einmal jemand gegangen + ist -- und das faellt erst im dritten Stream auf. */ + const neu = await roh(CREW, "/workspace/api/reaktion/gast", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi }, + }); + const plaetze = (neu.j?.gaeste || []).map((x) => x.platz).sort(); + ok(plaetze.join(",") === "1,2", + `der frei gewordene Platz wird wiederverwendet (${plaetze.join(",")})`); +} + +/* ======================================================================= + 7. DER LIVE-CHAT UND SEINE MODERATION + ======================================================================= */ +melde(""); +melde("=== 7. Chat und Moderation ==="); +let fremderBeitrag = 0; +{ + const a = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast2, rumpf: { text: "Das ist gut!" }, + }); + ok(a.code === 201 && a.j?.id > 0, `schreiben geht (${a.code})`); + fremderBeitrag = a.j.id; + + const leer = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast2, rumpf: { text: " " }, + }); + ok(leer.code === 400, `ein leerer Beitrag wird abgelehnt (${leer.code})`); + + const liste = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast }); + ok(liste.code === 200 && liste.j.beitraege.some((b) => b.id === fremderBeitrag), + "und steht in der Liste"); + + /* Ein Zuschauer nimmt einen FREMDEN Beitrag nicht weg. */ + const nein = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, { + method: "DELETE", keks: k.gast, + }); + ok(nein.code === 403, `ein Zuschauer nimmt fremde Beitraege nicht weg (${nein.code})`); + + /* Sein eigener geht. */ + const eigen = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "ups" }, + }); + const weg = await roh(CREW, `/workspace/api/reaktion/chat/${eigen.j.id}`, { + method: "DELETE", keks: k.gast, + }); + ok(weg.code === 200, `den eigenen schon (${weg.code})`); + + /* Ein Modi moderiert. */ + const mod = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, { + method: "DELETE", keks: k.modi, + }); + ok(mod.code === 200, `ein Modi moderiert (${mod.code})`); + + const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast }); + ok(!nach.j.beitraege.some((b) => b.id === fremderBeitrag), + "und der Beitrag ist aus der Liste"); + + /* WEGGENOMMEN UND NICHT GELOESCHT -- wer moderiert, soll spaeter + sagen koennen, was er weggenommen hat. */ + const { DatabaseSync: DB2 } = await import("node:sqlite"); + const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true }); + const zeile = d2.prepare("SELECT weg_am, weg_von, text FROM reaktion_chat WHERE id = ?") + .get(fremderBeitrag); + d2.close(); + ok(!!zeile && !!zeile.weg_am && zeile.weg_von === IDS.modi && zeile.text === "Das ist gut!", + "er steht noch da -- mit Zeitpunkt und Namen dessen, der ihn genommen hat"); +} + +/* ---- Die Schreibbremse ---- */ +{ + const { SCHREIB_BREMSE } = await import("./reaktion-tabellen.js"); + let gebremst = 0; + for (let i = 0; i < SCHREIB_BREMSE + 6; i++) { + const a = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.linke, rumpf: { text: "spam " + i }, + }); + if (a.code === 429) gebremst++; + } + ok(gebremst > 0, `die Schreibbremse greift (${gebremst} von ${SCHREIB_BREMSE + 6} abgewiesen)`); +} + +/* ======================================================================= + 8. DIE VERBINDUNGSDATEN GEHEN NUR AN LEUTE, DIE DABEI SIND + ======================================================================= */ +melde(""); +melde("=== 8. Kameras: der Hinweg ==="); +{ + const a = await roh(CREW, "/workspace/api/reaktion/signal", { + method: "POST", keks: k.admin, rumpf: { an: IDS.gast, daten: { typ: "angebot" } }, + }); + ok(a.code === 200, `an jemanden, der dabei ist, geht es (${a.code})`); + + /* Die Creatorin ist nicht dabei -- und im anderen Haus. An sie darf + nichts gehen, sonst waere die Reaction ein Weg, jemanden + anzuklingeln. */ + const b = await roh(CREW, "/workspace/api/reaktion/signal", { + method: "POST", keks: k.admin, rumpf: { an: IDS.creator, daten: { typ: "angebot" } }, + }); + ok(b.code === 409 && b.j?.fehler === "nicht_dabei", + `an jemanden, der nicht dabei ist, nicht (${b.code}/${b.j?.fehler})`); + + const c = await roh(CREW, "/workspace/api/reaktion/signal", { + method: "POST", keks: k.admin, rumpf: { an: IDS.gast }, + }); + ok(c.code === 400, `ohne Inhalt wird abgelehnt (${c.code})`); +} + +/* ======================================================================= + 9. DAS ENDE RAEUMT AUF + ======================================================================= */ +melde(""); +melde("=== 9. Beenden ==="); +{ + const vorher = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin }); + ok(vorher.j.beitraege.length > 0, `vor dem Ende steht etwas im Chat (${vorher.j.beitraege.length})`); + + const a = await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "zu" }, + }); + ok(a.code === 200 && a.j?.stand === "zu", `beenden geht (${a.j?.stand})`); + ok(a.j.gaeste.length === 0, "die Gaeste sind entlassen"); + ok(a.j.dabei === 0, "und niemand sitzt mehr im Saal"); + + const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin }); + ok(nach.j.beitraege.length === 0, + "der Live-Chat dieser Sendung ist abgeraeumt -- die naechste faengt nicht mit den Rufen der letzten an"); + + /* Was gelaufen ist, steht im Verlauf. */ + const { DatabaseSync: DB3 } = await import("node:sqlite"); + const d3 = new DB3(process.env.WORKSPACE_DB, { readOnly: true }); + const v = d3.prepare("SELECT * FROM reaktion_verlauf ORDER BY id DESC LIMIT 1").get(); + d3.close(); + ok(!!v && v.titel === "Wir schauen den Trailer" && v.beitraege > 0, + `die Sendung steht im Verlauf (${v?.titel}, ${v?.beitraege} Beitraege)`); + + /* Bei „zu" ist der Chat zu -- sonst redeten die Leute in einen + leeren Saal. */ + const c = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "Hallo?" }, + }); + ok(c.code === 409 && c.j?.fehler === "geschlossen", + `und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`); +} + +/* ======================================================================= + 10. DIE KACHEL, DIE RECHTE UND DIE INHALTSRICHTLINIE + ======================================================================= */ +melde(""); +melde("=== 10. Was um die Seite herum stimmen muss ==="); +{ + const { SEITEN } = await import("./rechte.js"); + const erlaubt = SEITEN["/workspace/reaktion.html"] || []; + ok(erlaubt.includes("gast"), + `die Community steht in der Rechtetafel (${erlaubt.join(", ")})`); + + /* Die Kachel. Gesucht wird sie am ZIEL und nicht am Namen -- Namen + haben sich in diesem Haus schon zweimal geaendert. */ + const w = await import("./workspace.js"); + const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW); + const kachel = (alle || []).find((b) => b.ziel === "reaktion.html"); + ok(!!kachel, "die Community hat die Reaction-Kachel"); + ok(kachel?.live === true, + "sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)"); + ok(!kachel?.ton, "und ausdruecklich KEINE Ton-Nummer"); + + /* DAS MERKMAL HEISST NICHT `data-live`. Das ist seit dem 21.09. + vergeben -- die Draussen-Kachel bekommt es, sobald Filipe auf + Twitch sendet. Haetten beide denselben Namen, waere der + Draussen-Kachel waehrend jedes Streams die Farbe abhanden + gekommen. */ + const startJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "start.js"), "utf8"); + ok(/dataset\.sendung = 'ja'/.test(startJs), + "die Wand setzt `data-sendung` (und nicht `data-live`, das ist vergeben)"); + const startCss = readFileSync(join(HIER, "..", "workspace", "assets", "css", "start.css"), "utf8"); + ok(!/\.kachel\[data-live="ja"\]\s*\{\s*--ton/.test(startCss), + "und kein Stil greift der Draussen-Kachel ihre Farbe ab"); + + /* DIE INHALTSRICHTLINIE. Ohne YouTube darin startet die Sendung, + alle sind da, und die Leinwand bleibt schwarz -- ohne + Fehlermeldung, nur ein Eintrag in der Browser-Konsole. Genau so + ist am 27.08.2026 der Musik-Knopf ausgefallen. */ + const seite = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast }); + const regel = String(seite.kopf["content-security-policy"] || ""); + ok(/frame-src[^;]*youtube-nocookie\.com/.test(regel), + "der YouTube-Rahmen ist erlaubt"); + ok(/script-src[^;]*www\.youtube\.com/.test(regel), + "und die Einbett-API darf geladen werden"); + ok(/img-src[^;]*ytimg\.com/.test(regel), + "und die Vorschaubilder auch"); +} + +/* ======================================================================= + 11. KEIN WEG HEISST ZWEIMAL GLEICH + ======================================================================= */ +melde(""); +melde("=== 11. Keine doppelten Wege ==="); +{ + /* Beim Notizblock hat genau das eine Stunde gekostet: + `DELETE /workspace/api/notizen/:id` gab es schon woanders, und + express nimmt schweigend die erste passende Route. */ + const { default: app } = await import("./index.js"); + const wege = []; + const sammeln = (stapel, vorn = "") => { + for (const s of stapel) { + if (s.route) { + for (const m of Object.keys(s.route.methods)) { + wege.push(`${m.toUpperCase()} ${vorn}${s.route.path}`); + } + } else if (s.handle?.stack) { + sammeln(s.handle.stack, vorn); + } + } + }; + sammeln(app._router.stack); + ok(wege.length > 300, `${wege.length} Wege im Baum gefunden`); + const doppelt = wege.filter((x, i) => wege.indexOf(x) !== i); + ok(doppelt.length === 0, + doppelt.length ? `DOPPELT: ${[...new Set(doppelt)].join(", ")}` : "kein Weg kommt zweimal vor"); + const reaktionswege = wege.filter((x) => x.includes("/api/reaktion")).length; + ok(reaktionswege >= 11, `die Reaction bringt ${reaktionswege} eigene Wege mit`); +} + +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/reaktion-tabellen.js b/server/reaktion-tabellen.js new file mode 100644 index 00000000..2911f187 --- /dev/null +++ b/server/reaktion-tabellen.js @@ -0,0 +1,169 @@ +/* ===================================================================== + DIE REACTION — die Tabellen (28.09.2026) + + Filipe hat eine Kurznotiz geschickt: eine Reaction-Kachel, die + fuer alle sichtbar ist, normalerweise geschlossen; bei + Vorbereitung ein Vorschaubild und ein Countdown; ein paar Minuten + vorher geht der Wartebereich auf, damit die Leute schon + dasitzen und schreiben koennen; beim Start laufen YouTube-Video + und Host-Kamera gleichzeitig; ein bis zwei Gaeste mit Kamera und + Mikrofon; getrennte Lautstaerken; ein Live-Chat mit Moderation; + und ein Weg, den Host zu unterstuetzen. + + --------------------------------------------------------------------- + EINE SENDUNG, NICHT VIELE + + Es gibt genau EINE Reaction-Zeile. Sie wird nicht angelegt und + geloescht, sie wechselt ihren Stand: zu -> vorbereitung -> live -> + zu. Der Grund ist nicht Bequemlichkeit, sondern Wahrheit: Eine + Kachel, die „offen" oder „geschlossen" anzeigt, muss auf EINE + Zeile zeigen koennen. Bei einer Liste von Sendungen waere die + erste Frage jedes Mal „welche denn?", und die Antwort darauf haette + jede der sieben Stellen einzeln gegeben, die den Stand anzeigen. + + Was vorbei ist, wandert nach `reaktion_verlauf`. Damit bleibt die + Geschichte erhalten, ohne dass die Gegenwart mehrdeutig wird. + + --------------------------------------------------------------------- + WARUM DAS VIDEO NICHT UEBER DEN SERVER LAEUFT + + Naheliegend waere: der Host spielt das YouTube-Video ab, und alle + sehen seinen Bildschirm. Das waere aus zwei Gruenden falsch. + + Erstens rechtlich: Ein YouTube-Video an Dritte weiterzusenden ist + eine oeffentliche Wiedergabe und damit genau die Sache, fuer die + Kanaele gesperrt werden. Jeder Zuschauer laedt das Video deshalb + SELBST von YouTube -- so wie bei jeder Watch-Party. + + Zweitens technisch: Ein weitergesendetes Video kostet Bandbreite + und Qualitaet. Uebertragen wird stattdessen nur, WO das Video + gerade steht (Kennung, laeuft/pausiert, Sekunde). Das sind ein paar + Byte, und jeder sieht es in voller Qualitaet. + + --------------------------------------------------------------------- + DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH + + Der Anrufteil des Hauses (workspace-anruf.js, assets/js/anruf.js) + macht das seit dem 18.09. und hat STUN/TURN eingerichtet. Die + Reaction benutzt denselben Weg, nur mit mehr Empfaengern: Der Host + baut zu jedem Zuschauer eine eigene Verbindung auf. + + DAS HAT EINE GRENZE, UND SIE IST GEMESSEN, NICHT GERATEN. Jede + Verbindung kostet den Host Upload. Bei 360p und rund 350 kbit/s + sind zwoelf Zuschauer etwa 4 Mbit/s -- das traegt eine normale + Leitung. Darueber schaltet die Sendung von selbst auf Ton um: Wer + keine Kamera mehr bekommt, hoert alles, sieht das Video und kann + schreiben. Das ist ehrlicher als eine Verbindung, die stockt, und + es ist sichtbar: Die Zahl steht im Regiepult. + + Heute sind es elf Menschen im ganzen Haus (gemessen am 28.09.2026: + 1 admin, 1 hand, 1 linke, 4 modi, 4 gast). Die Grenze ist also + weit weg -- sie steht trotzdem hier, weil sie sonst erst auffaellt, + wenn es zu spaet ist. + ===================================================================== */ + +/** Wie lange ein beendeter Verlaufseintrag aufgehoben wird. */ +export const VERLAUF_TAGE = 180; + +/** Hoechstens so viele Zuschauer bekommen Kamerabild. Darueber: nur Ton. */ +export const SICHT_PLAETZE = Number(process.env.REAKTION_SICHT_PLAETZE || 12); + +/** Hoechstens so viele Gaeste gleichzeitig (Filipes Notiz: 1-2). */ +export const GAESTE_MAX = 2; + +/** Ein Chatbeitrag in der Reaction. */ +export const TEXT_MAX = 400; + +/** So viele Beitraege je Minute und Person. */ +export const SCHREIB_BREMSE = 20; + +/** Die Staende, die es gibt -- und nur diese. */ +export const STAENDE = ["zu", "vorbereitung", "live"]; + +/** Die Anordnungen des Bildes. */ +export const LAYOUTS = ["kino", "gleich", "kamera"]; + +export function reaktionTabellen(d) { + d.exec(` + /* Genau EINE Zeile. Die Sperre steht in der Tabelle und nicht im + Programm: Ein Programm kann man umgehen, eine Bedingung nicht. */ + CREATE TABLE IF NOT EXISTS reaktion ( + id INTEGER PRIMARY KEY CHECK (id = 1), + stand TEXT NOT NULL DEFAULT 'zu' + CHECK (stand IN ('zu','vorbereitung','live')), + titel TEXT NOT NULL DEFAULT '', + video TEXT NOT NULL DEFAULT '', + vorschau TEXT NOT NULL DEFAULT '', + beginnt_am TEXT, + host_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + layout TEXT NOT NULL DEFAULT 'kino', + laeuft INTEGER NOT NULL DEFAULT 0, + sekunde REAL NOT NULL DEFAULT 0, + stand_seit TEXT NOT NULL DEFAULT '', + gestartet_am TEXT, + geaendert TEXT NOT NULL DEFAULT '' + ); + + /* Wer gerade mit Kamera dabei ist. Der Host steht NICHT hier -- + er ist an der Sendung selbst vermerkt. Ein Gast, der geht, + wird geloescht; wer stumm ist, bleibt stehen. */ + CREATE TABLE IF NOT EXISTS reaktion_gaeste ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + seit TEXT NOT NULL, + stumm INTEGER NOT NULL DEFAULT 0, + platz INTEGER NOT NULL DEFAULT 1 + ); + + /* Wer gerade zusieht. Wird beim Verlassen geloescht und von der + Pflege entfernt, wenn sich jemand zwei Minuten nicht meldet. + Daraus kommt die Zuschauerzahl -- und die Entscheidung, wer + noch ein Kamerabild bekommt. */ + CREATE TABLE IF NOT EXISTS reaktion_dabei ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + seit TEXT NOT NULL, + gesehen TEXT NOT NULL, + sicht INTEGER NOT NULL DEFAULT 0 + ); + + /* Der Live-Chat. Eigene Tabelle und nicht der Hauschat: Was hier + gesagt wird, gehoert zu dieser einen Sendung und ist danach + vorbei. Im Hauschat wuerde es die Gespraeche der Leute + ueberschwemmen, und die Moderation waere eine andere. */ + CREATE TABLE IF NOT EXISTS reaktion_chat ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + name TEXT NOT NULL DEFAULT '', + rolle TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + erstellt TEXT NOT NULL, + weg_am TEXT, + weg_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + + /* Was gelaufen ist. Eine Sendung je Zeile, erst beim Beenden. */ + CREATE TABLE IF NOT EXISTS reaktion_verlauf ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL DEFAULT '', + video TEXT NOT NULL DEFAULT '', + host_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + begonnen TEXT, + beendet TEXT NOT NULL, + zuschauer INTEGER NOT NULL DEFAULT 0, + beitraege INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_reaktion_chat_zeit + ON reaktion_chat (weg_am, id); + CREATE INDEX IF NOT EXISTS idx_reaktion_dabei_gesehen + ON reaktion_dabei (gesehen); + `); + + /* DIE EINE ZEILE ANLEGEN, WENN ES SIE NOCH NICHT GIBT. + + `INSERT OR IGNORE` und nicht „erst fragen, dann schreiben": Zwei + Anfragen gleichzeitig beim ersten Start wuerden sonst beide + „gibt es nicht" lesen und beide schreiben wollen. */ + d.prepare(`INSERT OR IGNORE INTO reaktion + (id, stand, stand_seit, geaendert) VALUES (1, 'zu', ?, ?)`) + .run(new Date().toISOString(), new Date().toISOString()); +} diff --git a/server/rechte.js b/server/rechte.js index 032ed40f..3bbcd129 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -524,6 +524,11 @@ export const SEITEN = { Seite gehoert zum Haus von Team Dogi und ist auf der Agenturadresse ein 404 (GEHOERT_ZU_ADRESSE in crew-adresse.js). */ "/workspace/notizen.html": ["admin", "hand", "linke", "modi"], + /* Die Reaction ist fuer ALLE im Haus -- Filipes Notiz sagt + woertlich „fuer alle sichtbar". Die Community steht deshalb + ausdruecklich mit drin; wer nicht Host ist, sieht dort die + Zuschauerseite. */ + "/workspace/reaktion.html": ["admin", "hand", "linke", "modi", "gast"], "/workspace/rechte.html": ["admin", "hand"], }; diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index c1610a38..7f5f8438 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -46,6 +46,8 @@ import { AUFBEWAHRUNG_TAGE as HILFE_TAGE, FALL_STAND } from "./hilfe-tabellen.js import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js"; import { supportAufraeumen } from "./workspace-support.js"; import { notizenAufraeumen } from "./workspace-notizen.js"; +import { reaktionAufraeumen } from "./workspace-reaktion.js"; +import { VERLAUF_TAGE } from "./reaktion-tabellen.js"; import { PAPIERKORB_TAGE as NOTIZ_TAGE } from "./notiz-tabellen.js"; import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js"; @@ -323,6 +325,23 @@ export const FRISTEN = [ vorTagen(NOTIZ_TAGE)), raeumen: () => notizenAufraeumen(), }, + { + schluessel: "reaktion_verlauf", + was: "Verlauf der Reaction-Sendungen", + art: "raeumen", + frist: `${VERLAUF_TAGE} Tage`, + zweck: "Nachsehen können, was wir gemeinsam geschaut haben – " + + "und ob überhaupt jemand dabei war.", + grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung", + wirkung: "Die Zeile verschwindet: Titel, Video, Datum, wie viele " + + "zugesehen und wie viel geschrieben wurde. Der Live-Chat einer " + + "Sendung ist ohnehin schon beim Beenden weg – was hier " + + "läuft, ist nur die Erinnerung, dass es sie gab.", + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM reaktion_verlauf WHERE beendet < ?", + vorTagen(VERLAUF_TAGE)), + raeumen: () => reaktionAufraeumen(), + }, { schluessel: "unterstuetzung_striche", was: "Tagesstriche der Unterstützen-Seite", diff --git a/server/workspace-chat.js b/server/workspace-chat.js index ebc40202..d7bf3f2b 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -3797,3 +3797,38 @@ export { chatEreignis }; export function siehtZu(personId) { return (zuschauer.get(Number(personId)) || []).size > 0; } + +/* ===================================================================== + DER KANAL FUER ANDERE MODULE (28.09.2026) + + Der Ereignisstrom liegt beim Chat, weil er dort entstanden ist -- + aber er ist laengst der einzige offene Draht zwischen Server und + Browser. `chatEreignis()` schickt an die Teilnehmer eines CHATRAUMS; + wer keinen Raum hat, kommt damit nicht weiter. + + Die Reaction hat keinen Chatraum. Sie hat einen Kreis von Leuten, + der sich jede Minute aendert -- wer gerade zusieht. Genau dafuer + ist das hier: Empfaenger werden uebergeben, nicht nachgeschlagen. + + EIN ZWEITER STROM WAERE DER FALSCHE WEG. Jeder Browser haelt dann + zwei offene Verbindungen, jede mit eigenem Puls, eigenem + Wiederverbinden und eigener Luecke danach. Und Anker: Manche + Browser lassen nur sechs gleichzeitige Verbindungen je Adresse zu -- + zwei davon fuer Ereignisse wegzugeben, ist teuer. + ===================================================================== */ +export function stromAn(personIds, art, daten) { + let erreicht = 0; + for (const roh of personIds) { + const id = Number(roh); + for (const antwort of zuschauer.get(id) || []) { + try { + antwort.write(`event: ${art} +data: ${JSON.stringify(daten)} + +`); + erreicht++; + } catch { /* die Verbindung raeumt sich selbst ab */ } + } + } + return erreicht; +} diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js new file mode 100644 index 00000000..13352772 --- /dev/null +++ b/server/workspace-reaktion.js @@ -0,0 +1,665 @@ +/* ===================================================================== + DIE REACTION — die Wege (28.09.2026) + + Der Ablauf aus Filipes Notiz, von links nach rechts: + + Kachel sichtbar -> geschlossen -> Vorbereitung -> + Wartebereich/Chat -> Countdown -> LIVE -> + Reaction + Gaeste + Chat + PayPal -> Ende + + Das sind drei Staende und nicht sieben. „Wartebereich" und + „Countdown" sind keine eigenen Zustaende, sondern das, was + „vorbereitung" auf dem Bildschirm TUT: Der Chat ist offen, der + Countdown laeuft, das Vorschaubild steht. Drei Staende, die sich + gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich + vier ueberlappen, sind es nicht. + + --------------------------------------------------------------------- + WER WAS DARF + + Host = DogFather. Er stellt ein, startet, beendet, holt und + entfernt Gaeste, schaltet stumm, bestimmt die Anordnung. + Moderation= DogFather, rechte und linke Hand, Modis. Sie koennen + Beitraege aus dem Live-Chat nehmen. + Gast = wen der Host holt. Jede Rolle des Hauses. + Zuschauer = jeder im Team-Dogi-Haus, auch die Community. + + Die Reaction gibt es NUR im Team-Dogi-Haus. Auf der Agenturadresse + antwortet jeder Weg mit 404 -- nicht mit 403, denn ein „verboten" + verraet, dass es hier etwas gibt. + + --------------------------------------------------------------------- + DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER + + Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert, + Sekunde. Jeder Browser laedt das Video selbst von YouTube. Die + Begruendung steht in reaktion-tabellen.js. + + Aufrufen laesst sich alles ohne Sonderfall: Wer zusieht, meldet + sich alle 30 Sekunden mit `/dabei`. Wer sich zwei Minuten nicht + meldet, faellt heraus -- ein sauberes „Tschuess" gibt es im Netz + nicht, ein Handy geht einfach aus. + ===================================================================== */ +import express from "express"; +import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js"; +import { stromAn } from "./workspace-chat.js"; +import { einstellung } from "./workspace.js"; +import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; +import { + SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, + VERLAUF_TAGE, +} from "./reaktion-tabellen.js"; + +export const reaktionRouter = express.Router(); + +/** Wer die Sendung fuehrt. */ +const HOST_ROLLEN = new Set(["admin"]); +/** Wer Beitraege aus dem Chat nehmen darf. */ +const MODERIERT = new Set(["admin", "hand", "linke", "modi"]); + +export const REAKTION_HOST_ROLLEN = HOST_ROLLEN; + +const jetzt = () => new Date().toISOString(); + +/* ---------- Schranken --------------------------------------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +/** Nicht das andere Haus. + * + * Gefragt wird „ist es die Agentur?" und nicht „ist es crew?" -- + * auf einer Pruefadresse hat niemand ein Haus, und eine Schranke, + * die das mit dem fremden Haus verwechselt, macht die Seite dort + * still tot. Genau dieser Fehler ist am 27.09. beim Notizblock + * passiert und von pruef-handy gefunden worden. */ +function nurTeamDogi(req, res, next) { + if (req.person?.haus === "agentur") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + next(); +} + +function nurHost(req, res, next) { + if (!HOST_ROLLEN.has(req.person?.rolle)) { + return res.status(403).json({ fehler: "nur_host" }); + } + next(); +} + +/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den + uebrigen Modulen. */ +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +/* ---------- Die Schreibbremse ------------------------------------------- + Ein Live-Chat ist der eine Ort im Haus, an dem jemand in zehn + Sekunden dreissig Zeilen schreiben kann -- aus Begeisterung oder + aus Aerger. Die Bremse zaehlt je Person und Minute. */ +const bremse = new Map(); +function zuSchnell(personId) { + const minute = Math.floor(Date.now() / 60_000); + const s = bremse.get(personId); + if (!s || s.minute !== minute) { bremse.set(personId, { minute, n: 1 }); return false; } + s.n += 1; + return s.n > SCHREIB_BREMSE; +} + +/* ---------- Hilfen ------------------------------------------------------ */ + +const sendung = () => db().prepare("SELECT * FROM reaktion WHERE id = 1").get(); + +function gaesteListe() { + return db().prepare(` + SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle + FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id + ORDER BY g.platz`).all(); +} + +/** Wer gerade dabei ist -- und ob noch ein Kameraplatz frei war. + * + * ZWEI MINUTEN OHNE LEBENSZEICHEN HEISST WEG. Ein Browser, der + * zugeklappt wird, schickt kein „ich gehe". Ohne diese Grenze + * waechst die Zuschauerzahl den ganzen Abend und stimmt nie. */ +function dabeiListe() { + const grenze = new Date(Date.now() - 120_000).toISOString(); + db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze); + return db().prepare(` + SELECT d.person_id AS id, d.sicht, d.seit, p.name, p.rolle + FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id + ORDER BY d.seit`).all(); +} + +/** Alle, die von einer Aenderung erfahren sollen. */ +function empfaenger() { + const ids = new Set(dabeiListe().map((d) => d.id)); + for (const g of gaesteListe()) ids.add(g.id); + const s = sendung(); + if (s?.host_id) ids.add(s.host_id); + return [...ids]; +} + +/** Was jeder sehen darf -- egal ob Zuschauer, Gast oder Host. */ +function lage(person) { + const s = sendung(); + const gaeste = gaesteListe(); + const dabei = dabeiListe(); + const istHost = HOST_ROLLEN.has(person.rolle); + const binGast = gaeste.some((g) => g.id === person.id); + return { + stand: s.stand, + titel: s.titel, + video: s.video, + vorschau: s.vorschau, + beginnt_am: s.beginnt_am, + layout: s.layout, + laeuft: !!s.laeuft, + sekunde: s.sekunde, + gestartet_am: s.gestartet_am, + host: s.host_id + ? db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id) + : null, + gaeste: gaeste.map((g) => ({ id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz })), + dabei: dabei.length, + /* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ========== + + Namen der Zusehenden bekommt, wer auf der BUEHNE steht (Host, + Gaeste) oder moderiert. Ein Zuschauer bekommt sie nicht: Er + soll nicht ablesen koennen, wer sonst noch zusieht -- im Live + sieht man einander am Chat, und das reicht. + + DIE GAESTE STEHEN HIER NICHT AUS HOEFLICHKEIT. Wer sendet, + muss wissen, wen er anrufen soll: Zuschauer rufen grundsaetzlich + nie an (sonst rufen zwei gleichzeitig an, und beide + scheitern -- „glare"). Ohne die Liste kaeme das Bild eines + Gastes deshalb bei keinem Zuschauer an. Zwei fuer sich richtige + Regeln, die zusammen ein Loch ergeben. */ + zuschauer: (istHost || binGast || MODERIERT.has(person.rolle)) + ? dabei.map((d) => ({ id: d.id, name: d.name, rolle: d.rolle, sicht: !!d.sicht })) + : null, + ich: { + id: person.id, + name: person.name, + rolle: person.rolle, + host: istHost, + gast: binGast, + moderiert: MODERIERT.has(person.rolle), + sicht: !!(dabei.find((d) => d.id === person.id)?.sicht), + }, + plaetze: SICHT_PLAETZE, + gaeste_max: GAESTE_MAX, + text_max: TEXT_MAX, + }; +} + +function melden(art, daten) { + stromAn(empfaenger(), art, daten); +} + +/* ====================================================================== + DIE WEGE + ====================================================================== */ +reaktionRouter.use("/workspace/api/reaktion", angemeldet, nurTeamDogi, gleicheHerkunft); + +/** Wie steht es? — der eine Aufruf, den jede Seite beim Laden macht. */ +reaktionRouter.get("/workspace/api/reaktion", (req, res) => { + try { + res.json(lage(req.person)); + } catch (fehler) { + console.error("[reaktion] Lage:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */ +reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }), + (req, res) => { + try { + const titel = String(req.body?.titel ?? "").trim().slice(0, 120); + const video = videoKennung(String(req.body?.video ?? "")); + const vorschau = String(req.body?.vorschau ?? "").trim().slice(0, 400); + const beginn = req.body?.beginnt_am ? String(req.body.beginnt_am).slice(0, 40) : null; + + if (req.body?.video && !video) { + return res.status(400).json({ fehler: "video_unklar" }); + } + /* EIN BEGINN IN DER VERGANGENHEIT IST KEIN BEGINN. Der Countdown + zaehlte sonst rueckwaerts ins Minus, und niemand koennte + sagen, ob das Absicht war. */ + if (beginn && Number.isFinite(Date.parse(beginn)) && Date.parse(beginn) < Date.now() - 60_000) { + return res.status(400).json({ fehler: "beginn_vorbei" }); + } + + db().prepare(`UPDATE reaktion SET titel = ?, video = ?, vorschau = ?, + beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`) + .run(titel, video, vorschau, beginn, req.person.id, jetzt()); + + const l = lage(req.person); + melden("reaktion", { was: "eingestellt", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Einstellen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Der Stand wechselt: zu / vorbereitung / live. */ +reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ limit: "4kb" }), + (req, res) => { + try { + const neu = String(req.body?.stand ?? ""); + if (!STAENDE.includes(neu)) return res.status(400).json({ fehler: "stand_unbekannt" }); + const s = sendung(); + + /* OHNE VIDEO KEIN LIVE. Der Knopf waere sonst da, das Ergebnis + ein schwarzes Rechteck -- und die Leute sitzen davor. */ + if (neu === "live" && !s.video) { + return res.status(400).json({ fehler: "kein_video" }); + } + + const felder = { stand: neu, stand_seit: jetzt(), geaendert: jetzt() }; + if (neu === "live" && s.stand !== "live") { + felder.gestartet_am = jetzt(); + felder.laeuft = 1; + felder.sekunde = 0; + } + if (neu === "zu" && s.stand === "live") { + /* WAS VORBEI IST, WANDERT IN DEN VERLAUF -- und der Live-Chat + dieser Sendung ist damit Geschichte. Wuerde er stehen + bleiben, faenge die naechste Sendung mit den Rufen der + letzten an. */ + const zahl = db().prepare("SELECT COUNT(*) AS n FROM reaktion_chat WHERE weg_am IS NULL").get(); + db().prepare(`INSERT INTO reaktion_verlauf + (titel, video, host_id, begonnen, beendet, zuschauer, beitraege) + VALUES (?,?,?,?,?,?,?)`) + .run(s.titel, s.video, s.host_id, s.gestartet_am, jetzt(), + dabeiListe().length, zahl?.n || 0); + db().exec("DELETE FROM reaktion_chat"); + db().exec("DELETE FROM reaktion_gaeste"); + felder.laeuft = 0; + felder.sekunde = 0; + felder.gestartet_am = null; + } + if (neu === "zu") db().exec("DELETE FROM reaktion_dabei"); + + const spalten = Object.keys(felder).map((k) => `${k} = ?`).join(", "); + db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`) + .run(...Object.values(felder)); + + protokolliere(req.person.id, "reaktion_stand", `${s.stand} -> ${neu}`, echteIp(req)); + const l = lage(req.person); + melden("reaktion", { was: "stand", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Stand:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Ich bin da / ich bin noch da. Alle 30 Sekunden. */ +reaktionRouter.post("/workspace/api/reaktion/dabei", express.json({ limit: "2kb" }), + (req, res) => { + try { + const s = sendung(); + if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + + const schon = db().prepare("SELECT * FROM reaktion_dabei WHERE person_id = ?") + .get(req.person.id); + if (schon) { + db().prepare("UPDATE reaktion_dabei SET gesehen = ? WHERE person_id = ?") + .run(jetzt(), req.person.id); + } else { + /* DER KAMERAPLATZ WIRD BEIM EINTRETEN VERGEBEN, nicht beim + Zeichnen. Sonst haetten zwei gleichzeitig eintretende + Zuschauer denselben letzten Platz gelesen und beide + gerechnet, sie haetten ihn. */ + const mitSicht = db().prepare( + "SELECT COUNT(*) AS n FROM reaktion_dabei WHERE sicht = 1").get()?.n || 0; + db().prepare("INSERT INTO reaktion_dabei (person_id, seit, gesehen, sicht) VALUES (?,?,?,?)") + .run(req.person.id, jetzt(), jetzt(), mitSicht < SICHT_PLAETZE ? 1 : 0); + melden("reaktion", { was: "dabei", lage: oeffentlich(lage(req.person)) }); + } + res.json(lage(req.person)); + } catch (fehler) { + console.error("[reaktion] Dabei:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +function abmelden(req, res) { + try { + db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(req.person.id); + melden("reaktion", { was: "weg", lage: oeffentlich(lage(req.person)) }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Weg:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +} + +reaktionRouter.delete("/workspace/api/reaktion/dabei", abmelden); + +/* DERSELBE WEG NOCH EINMAL ALS POST. + + Beim Schliessen des Fensters wird ein gewoehnlicher Abruf + abgebrochen -- dafuer gibt es `navigator.sendBeacon`. Der schickt + aber IMMER POST; ein DELETE waere nie angekommen, und jeder haette + danach zwei Minuten lang als anwesend gegolten. Die Zuschauerzahl + waere den ganzen Abend gestiegen und nie gefallen. + + Kein zweiter Code, nur ein zweiter Eingang: Beide zeigen auf + dieselbe Funktion. */ +reaktionRouter.post("/workspace/api/reaktion/dabei-weg", abmelden); + +/* ---------- Die Verbindungsadressen ------------------------------------- + STUN sagt einem Browser, wie er von aussen aussieht; TURN leitet + weiter, wenn eine direkte Verbindung nicht zustande kommt. + + WARUM DIE REACTION EINE EIGENE TUER HAT: Vor den Anruf-Wegen steht + `darfTelefonieren`, und die Community ist dort ausdruecklich nicht + dabei -- ein Zuschauer soll kein Teammitglied anklingeln koennen. + Das ist richtig und bleibt. Hier sitzt derselbe Mensch aber im + Saal, und ohne Adressen bliebe sein Bild schwarz. + + Dieselbe Einstellung, dieselben Helfer, andere Tuer. Und die Tuer + ist nur offen, solange etwas laeuft: Bei „zu" gibt es nichts zu + holen -- sonst waere das ein dauerhaft offener Weg zu den + TURN-Zugangsdaten fuer jeden im Haus. */ +reaktionRouter.get("/workspace/api/reaktion/adressen", (req, res) => { + try { + if (sendung().stand === "zu") return res.json({ adressen: [] }); + let liste = []; + try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; } + /* Als Kennung die Personennummer und nicht der Name -- sie landet + im Protokoll des TURN-Servers, und das ist kein Ort fuer + Klarnamen. Wortgleich zum Anrufteil. */ + const { adressen } = mitZugangsdaten(wegeAusfaechern(liste), `r${req.person.id}`); + res.json({ adressen }); + } catch (fehler) { + console.error("[reaktion] Adressen:", fehler?.message); + /* KEIN 503. Ohne eigene Adressen nimmt der Browser seine Vorgabe + und die Sendung laeuft weiter -- eine leere Liste ist hier die + ehrlichere Antwort als ein Fehler, der nach Ausfall aussieht. */ + res.json({ adressen: [] }); + } +}); + +/* ---------- Gaeste ------------------------------------------------------ */ + +reaktionRouter.post("/workspace/api/reaktion/gast", nurHost, express.json({ limit: "4kb" }), + (req, res) => { + try { + const id = Number(req.body?.person_id); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ fehler: "person_unklar" }); + const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id); + if (!p) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (id === req.person.id) return res.status(400).json({ fehler: "host_ist_kein_gast" }); + + const da = db().prepare("SELECT COUNT(*) AS n FROM reaktion_gaeste").get()?.n || 0; + if (da >= GAESTE_MAX) return res.status(409).json({ fehler: "gaeste_voll" }); + + /* Der Platz ist die kleinste freie Zahl -- nicht „Anzahl + 1". + Wer Gast 1 entfernt und dann jemanden holt, bekaeme sonst + wieder Platz 2, und Platz 1 bliebe fuer immer leer. */ + const belegt = new Set(gaesteListe().map((g) => g.platz)); + let platz = 1; + while (belegt.has(platz)) platz++; + + db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)") + .run(id, jetzt(), platz); + protokolliere(req.person.id, "reaktion_gast_dazu", `${p.name} (Platz ${platz})`, echteIp(req)); + const l = lage(req.person); + melden("reaktion", { was: "gaeste", lage: oeffentlich(l) }); + stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) }); + res.status(201).json(l); + } catch (fehler) { + console.error("[reaktion] Gast:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.delete("/workspace/api/reaktion/gast/:id", nurHost, (req, res) => { + try { + const id = Number(req.params.id); + const weg = db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(id); + if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" }); + const l = lage(req.person); + melden("reaktion", { was: "gaeste", lage: oeffentlich(l) }); + stromAn([id], "reaktion", { was: "du_bist_kein_gast", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Gast weg:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +reaktionRouter.put("/workspace/api/reaktion/gast/:id/stumm", nurHost, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id); + const stumm = req.body?.stumm ? 1 : 0; + const a = db().prepare("UPDATE reaktion_gaeste SET stumm = ? WHERE person_id = ?") + .run(stumm, id); + if (!a.changes) return res.status(404).json({ fehler: "nicht_gefunden" }); + const l = lage(req.person); + melden("reaktion", { was: "gaeste", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Stumm:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Das Video -------------------------------------------------- + Der Host sagt, wo das Video steht. Alle anderen folgen. + + GESCHICKT WIRD DER SPIELSTAND, NICHT DAS BILD -- die Begruendung + steht in reaktion-tabellen.js. Hier steht nur die Folge davon: + Diese Route ist die einzige, die waehrend der Sendung oft laeuft, + und sie schreibt deshalb so wenig wie moeglich. */ +reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ limit: "2kb" }), + (req, res) => { + try { + const laeuft = req.body?.laeuft ? 1 : 0; + const sekunde = Math.max(0, Number(req.body?.sekunde) || 0); + const video = req.body?.video === undefined ? null : videoKennung(String(req.body.video)); + if (req.body?.video !== undefined && !video) { + return res.status(400).json({ fehler: "video_unklar" }); + } + if (video) { + db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1") + .run(video, laeuft, sekunde, jetzt()); + } else { + db().prepare("UPDATE reaktion SET laeuft = ?, sekunde = ? WHERE id = 1") + .run(laeuft, sekunde); + } + const s = sendung(); + melden("reaktion", { + was: "video", + video: s.video, laeuft: !!s.laeuft, sekunde: s.sekunde, + /* Die Uhrzeit des Servers geht mit: Der Empfaenger rechnet + die Laufzeit seit dem Absenden dazu. Ohne das waere jeder + genau so weit hinten, wie sein Netz langsam ist. */ + gesendet: Date.now(), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Video:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }), + (req, res) => { + try { + const l = String(req.body?.layout ?? ""); + if (!LAYOUTS.includes(l)) return res.status(400).json({ fehler: "layout_unbekannt" }); + db().prepare("UPDATE reaktion SET layout = ? WHERE id = 1").run(l); + melden("reaktion", { was: "layout", layout: l }); + res.json({ ok: true, layout: l }); + } catch (fehler) { + console.error("[reaktion] Layout:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Der Live-Chat ----------------------------------------------- */ + +reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => { + try { + const reihen = db().prepare(` + SELECT id, person_id, name, rolle, text, erstellt + FROM reaktion_chat WHERE weg_am IS NULL ORDER BY id DESC LIMIT 120`).all(); + res.json({ beitraege: reihen.reverse() }); + } catch (fehler) { + console.error("[reaktion] Chat:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" }), + (req, res) => { + try { + const s = sendung(); + if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX); + if (!text) return res.status(400).json({ fehler: "leer" }); + if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" }); + + const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_chat + (person_id, name, rolle, text, erstellt) VALUES (?,?,?,?,?)`) + .run(req.person.id, req.person.name, req.person.rolle, text, jetzt()); + const beitrag = db().prepare( + "SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?") + .get(Number(lastInsertRowid)); + melden("reaktion_chat", { was: "neu", beitrag }); + res.status(201).json(beitrag); + } catch (fehler) { + console.error("[reaktion] Schreiben:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Moderation: einen Beitrag wegnehmen. + * + * WEGGENOMMEN UND NICHT GELOESCHT. Wer moderiert, soll spaeter + * sagen koennen, was er weggenommen hat und warum -- eine geloeschte + * Zeile kann das nicht. */ +reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => { + try { + const id = Number(req.params.id); + const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id); + if (!b || b.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const eigen = b.person_id === req.person.id; + if (!eigen && !MODERIERT.has(req.person.rolle)) { + return res.status(403).json({ fehler: "nicht_erlaubt" }); + } + db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?") + .run(jetzt(), req.person.id, id); + if (!eigen) { + protokolliere(req.person.id, "reaktion_chat_weg", `Beitrag ${id} von ${b.name}`, echteIp(req)); + } + melden("reaktion_chat", { was: "weg", id }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[reaktion] Wegnehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Die Kameras ------------------------------------------------ + Nur der Hinweg. Was in `signal` steht, liest dieser Server nicht -- + es geht unveraendert an den Empfaenger. Genau so macht es der + Anrufteil seit dem 18.09. + + GESCHICKT WIRD NUR AN LEUTE, DIE GERADE DABEI SIND. Ohne das + koennte jemand aus dem Haus Verbindungsdaten an jeden Beliebigen + schicken, und die Reaction waere ein Weg, jemanden anzuklingeln. */ +reaktionRouter.post("/workspace/api/reaktion/signal", express.json({ limit: "256kb" }), + (req, res) => { + try { + const an = Number(req.body?.an); + const daten = req.body?.daten; + if (!Number.isInteger(an) || daten === undefined) { + return res.status(400).json({ fehler: "unvollstaendig" }); + } + const kreis = new Set(empfaenger()); + if (!kreis.has(an) || !kreis.has(req.person.id)) { + return res.status(409).json({ fehler: "nicht_dabei" }); + } + const wie_viele = stromAn([an], "reaktion_signal", { + von: req.person.id, name: req.person.name, daten, + }); + res.json({ ok: true, zugestellt: wie_viele }); + } catch (fehler) { + console.error("[reaktion] Signal:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ====================================================================== + HILFEN + ====================================================================== */ + +/** Aus allem, was jemand einfuegen koennte, die YouTube-Kennung. + * + * Angenommen werden die vier Formen, die man wirklich in der + * Zwischenablage hat: die lange Adresse, die kurze, der + * Einbett-Pfad und die nackte Kennung. Alles andere wird + * abgelehnt -- eine falsche Kennung ist ein schwarzes Rechteck vor + * Publikum, und das faellt erst auf, wenn alle zusehen. */ +export function videoKennung(roh) { + const t = String(roh || "").trim(); + if (!t) return ""; + if (/^[A-Za-z0-9_-]{11}$/.test(t)) return t; + const muster = [ + /[?&]v=([A-Za-z0-9_-]{11})/, + /youtu\.be\/([A-Za-z0-9_-]{11})/, + /youtube\.com\/embed\/([A-Za-z0-9_-]{11})/, + /youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/, + /youtube\.com\/live\/([A-Za-z0-9_-]{11})/, + ]; + for (const m of muster) { + const t2 = t.match(m); + if (t2) return t2[1]; + } + return ""; +} + +/** Die Lage ohne die Namensliste -- fuer den Rundruf. + * + * `lage()` gibt einem Host die Namen aller Zusehenden. Genau diese + * Antwort geht per Strom an ALLE. Ohne dieses Sieb bekaeme jeder + * Zuschauer die Liste, die nur die Leitung sehen darf -- und zwar + * ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja + * nicht anzeigt. */ +function oeffentlich(l) { + const { zuschauer, ich, ...rest } = l; + return rest; +} + +/** Aufraeumen: alter Verlauf und liegengebliebene Zusehende. */ +export function reaktionAufraeumen() { + try { + const grenze = new Date(Date.now() - VERLAUF_TAGE * 86400_000).toISOString(); + const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze); + const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?") + .run(new Date(Date.now() - 600_000).toISOString()); + if (a.changes || b.changes) { + console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende.`); + } + } catch (fehler) { + console.error("[reaktion] Aufraeumen:", fehler?.message); + } +} diff --git a/server/workspace.js b/server/workspace.js index b975cf00..fa90cd3b 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -35,6 +35,7 @@ import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen. import { hilfeTabellen } from "./hilfe-tabellen.js"; import { supportTabellen } from "./support-tabellen.js"; import { notizTabellen } from "./notiz-tabellen.js"; +import { reaktionTabellen } from "./reaktion-tabellen.js"; import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js"; import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN, TEAM_DOGI_ROLLEN, CREW_ADRESSE, WORKSPACE_ADRESSE, AGENTUR_ROLLEN, BEIDE_HAEUSER_ROLLEN, @@ -1510,6 +1511,25 @@ const TREFF_BEREICHE = [ `?raum=treff` öffnet den Raum der Community direkt. Für die Community selbst ändert sich am Namen nichts Wesentliches -- sie hat nur diese eine. */ + /* ==== DIE REACTION STEHT GANZ VORNE ============================ + (28.09.2026, nach Filipes Kurznotiz.) + + Sie ist die einzige Kachel im Haus, die einen ZUSTAND hat -- + zu, gleich, jetzt. Was gerade laeuft, gehoert an die erste + Stelle; was immer da ist, kann warten. Steht sie geschlossen + da, sagt sie das und nimmt trotzdem den Platz: Eine Kachel, + die je nach Lage woanders steht, ist keine Kachel, die man + wiederfindet. + + KEINE TON-NUMMER. Der Farbraum ist bei 46 Toenen voll (siehe + tools/kachelton-regeln.mjs), und eine 47. Farbe wuerde den + Mindestabstand reissen. Sie braucht auch keine: Ihre Farbe IST + ihr Zustand -- grau geschlossen, bernstein in Vorbereitung, + rot auf Sendung. `live: true` sagt der Wand, dass diese Kachel + ihre Flaeche selbst mitbringt, genau wie `papier` beim + Notizblock und `silber` bei den Aufgaben. */ + { ...TREFF_GRUPPE, name: "Reaction", unter: "Zusammen schauen \u2013 live mit Kamera und Chat", + zeichen: "reaktion", live: true, ziel: "reaktion.html", szene: "arena" }, { ...TREFF_GRUPPE, name: "Rudel-Chat", unter: "Miteinander reden – nachts schläft er", zeichen: "chat", ton: 41, ziel: "chat.html?raum=treff", szene: "lounge" }, /* HIGHLIGHTS AN DIE SPITZE (22.09.2026). @@ -6037,6 +6057,7 @@ export function db() { /* Der Notizblock (26.09.2026). Haengt an personen (CASCADE) und kommt deshalb hierher, nicht in den Bauplan darueber. */ notizTabellen(d); + reaktionTabellen(d); /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen (geaendert_von) und kommen deshalb hierher, nicht in den Bauplan darüber. */ diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 191f2208..c41fd152 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`. --> - - + + - +