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`. --> - - + + - +