diff --git a/server/mess-foyer.mjs b/server/mess-foyer.mjs index 624cef15..8d418bd9 100644 --- a/server/mess-foyer.mjs +++ b/server/mess-foyer.mjs @@ -25,6 +25,10 @@ * Und geht der ganze Weg durch: Gast schickt ein Video, DogFather sieht es MIT Namen, nimmt es -- und es steht links im Plan. + * Steht das Regie-Tor bei JEDEM da (Filipes ausdruecklicher + Wunsch) -- und kommt trotzdem nur durch, wer den richtigen + Code hat? Gemessen werden alle vier Lagen: Gast (kein Feld), + Host verschlossen, Host mit falschem Code, Host mit richtigem. ZU JEDER MESSUNG, DIE "in Ordnung" SAGEN KANN, GEHOERT EINE GEGENPROBE. Am Ende wird jede der tragenden Messungen absichtlich @@ -347,6 +351,29 @@ const massLesen = (seite) => seite.evaluate(() => { }; }); +/** Was sagt das Regie-Tor? */ +const torLesen = (seite) => seite.evaluate(() => { + const tor = document.getElementById('regie-tor'); + if (!tor) return null; + const r = tor.getBoundingClientRect(); + const t = (k) => tor.querySelector(k)?.textContent.trim() || ''; + return { + da: !!tor.offsetParent, + darf: tor.dataset.darf, + offen: tor.dataset.offen, + titel: t('.regie-tor__titel'), + satz: t('.regie-tor__satz'), + marke: t('.regie-tor__marke'), + breit: Math.round(r.width), + hoch: Math.round(r.height), + oben: Math.round(r.top), + /* Die Farbe, die das Licht traegt -- sie ist die Aussage ueber + den Zustand und nicht nur Zierde. */ + ton: getComputedStyle(tor).getPropertyValue('--ton').trim(), + vorhangDa: !document.getElementById('regie-vorhang').hidden, + }; +}); + /** Was steht in der Mitte? */ const mitteLesen = (seite) => seite.evaluate(() => { const t = (id) => document.getElementById(id)?.textContent.trim() || ""; @@ -593,13 +620,187 @@ console.log("\n=== 6. Anraten, sehen, nehmen ==="); } /* ===================================================================== - 7. DIE GEGENPROBEN + 7. DAS REGIE-TOR + + Filipe: „die soll jeder sehen aber nur vanvan und dogfather sollen + da rein kommen bitte." Beide Haelften werden gemessen -- dass sie + jeder SIEHT und dass nur die zwei HINEINKOMMEN. Eine davon allein + waere keine Aussage. + ===================================================================== */ +console.log("\n=== 7. Das Regie-Tor ==="); +{ + const gast = await alsWer("gast", 1440, 900); + const t = await torLesen(gast.seite); + ok(!!t && t.da, "der Gast SIEHT das Tor"); + ok(t?.darf === "nein", `fuer ihn steht darf=nein (${t?.darf})`); + ok(t?.marke === "Nur ihr zwei", `das Schild sagt es (${t?.marke})`); + ok(/nicht hinein|T\u00fcr zu/i.test(t?.satz || ""), + `und der Satz auch (${t?.satz})`); + + /* ==== ES DARF KEINE DATEN VERRATEN ============================ + + Der erste Entwurf suchte hier auch das WORT „Warteschlange" -- + und fand es, im unsichtbaren Geruest der linken Reihe, wo es als + Ueberschrift steht. Das war ein Fehlalarm aus zwei Gruenden: + + * Eine Beschriftung ist keine Auskunft. Dass es eine + Warteschlange GIBT, steht ohnehin im Regie-Tor, und das + sieht seit heute jeder -- auf Filipes ausdruecklichen + Wunsch. Eine Messung, die genau dieses Wort als Leck + zaehlt, widerspricht dem Entwurf. + * Und sie haette bei jedem Lauf angeschlagen. Eine Warnung, + die immer kommt, nimmt irgendwann die echte mit. + + Gefragt ist das Schaerfere: Kommen DATEN an? Titel und + Videokennungen aus der Warteschlange duerfen im Dokument eines + Gastes nicht vorkommen -- und gebaute Zeilen schon gar nicht. */ + const leck = await leckLesen(gast.seite, + [...liste0.map((z) => z.titel), ...liste0.map((z) => z.video)]); + ok(leck.length === 0, "das Tor verraet keine Daten aus der Regie" + + (leck.length ? " -> " + leck.join(", ") : "")); + const zeilen = await gast.seite.evaluate(() => ({ + plan: document.querySelectorAll("#plan-liste .plan-zeile").length, + leerzeile: document.querySelectorAll("#plan-liste .plan-leer").length, + })); + ok(zeilen.plan === 0 && zeilen.leerzeile === 0, + `und es ist auch keine Planzeile gebaut worden ` + + `(${zeilen.plan} Zeilen, ${zeilen.leerzeile} Leerhinweise)`); + + /* EIN DRUCK OEFFNET IHM KEIN FELD. Sonst waere eine Kachel, die + jeder sieht, eine Ratestelle fuer jeden im Haus. */ + await gast.seite.click("#regie-tor"); + await gast.seite.waitForTimeout(150); + const danach = await torLesen(gast.seite); + ok(danach?.vorhangDa === false, "ein Druck oeffnet ihm kein Codefeld"); + await bild(gast.seite, "10-tor-gast"); + await gast.kontext.close(); +} +{ + const chef = await alsWer("admin", 1440, 900); + const t = await torLesen(chef.seite); + ok(t?.darf === "ja", `DogFather darf (${t?.darf})`); + ok(t?.offen === "nein", `und es ist zu (${t?.offen})`); + ok(t?.marke === "Verschlossen", `das Schild sagt es (${t?.marke})`); + const r = await reihenLesen(chef.seite); + ok(!!t && t.breit > r.saal.breit, + `das Tor ist breiter als die breiteste Reihe (${t?.breit} zu ${r.saal?.breit} px)`); + ok(!!t && !!r.saal && t.oben > r.saal.oben, + `und es steht UNTER den Reihen (${t?.oben} nach ${r.saal?.oben} px)`); + + /* ---- Falscher Code ---- */ + await chef.seite.click("#regie-tor"); + await chef.seite.waitForSelector("#regie-vorhang:not([hidden])", { timeout: 5000 }); + ok(true, "ein Druck oeffnet ihm das Codefeld"); + await bild(chef.seite, "11-tor-vorhang"); + /* ==== DIE TABULATORTASTE DARF NICHT HINTER DEN VORHANG ======== + Esc schliesst, ein Druck daneben schliesst, der Finger steht im + Feld -- und trotzdem war der Vorhang fuer jemanden ohne Maus + eine Falle: Die Tabulatortaste lief durch die Knoepfe DAHINTER + weiter, sichtbar war aber das Codefeld. Gemessen wird deshalb + nicht, ob `inert` gesetzt ist, sondern wo der Fokus LANDET. */ + { + /* `body` IST KEIN BEDIENELEMENT, und das ist hier der Unterschied + zwischen einem Befund und einem Fehlalarm. Am Ende des + Tabulatorkreises gibt der Browser den Fokus an die Leiste des + Browsers ab; im Dokument steht dann `document.body`. Mein + erster Entwurf verlangte „immer drin" und wurde deshalb rot, + obwohl die Sperre tadellos arbeitete -- gemeldet wurde + „drin, body", und kein einziges Bedienelement ausserhalb. + + Gefragt ist also: Wird je ein BEDIENELEMENT ausserhalb des + Fensters erreicht? Deshalb sagt die Messung auch, WELCHES -- + ein Befund, der nur „draussen" meldet, schickt einen suchen. */ + const woher = () => chef.seite.evaluate(() => { + const e = document.activeElement; + if (!e || e === document.body || e === document.documentElement) return "body"; + return e.closest("#regie-vorhang") + ? "drin" + : "DRAUSSEN:" + (e.id || e.className || e.tagName); + }); + const wo = []; + for (let i = 0; i < 6; i++) { await chef.seite.keyboard.press("Tab"); wo.push(await woher()); } + ok(wo.includes("drin") && !wo.some((x) => x.startsWith("DRAUSSEN")), + `kein Bedienelement hinter dem Vorhang ist erreichbar ` + + `(6x Tab: ${[...new Set(wo)].join(", ")})`); + + /* GEGENPROBE: Ohne die Stilllegung MUSS ein Element dahinter + auftauchen. Sonst bewiese die Zeile darueber nur, dass sechsmal + Tab irgendwo landet -- und nicht, dass die Sperre es ist. */ + await chef.seite.evaluate(() => { + for (const e of document.body.children) e.inert = false; + }); + const raus = []; + for (let i = 0; i < 8; i++) { await chef.seite.keyboard.press("Tab"); raus.push(await woher()); } + ok(raus.some((x) => x.startsWith("DRAUSSEN")), + `Gegenprobe: ohne die Sperre wird eines erreicht ` + + `(${raus.find((x) => x.startsWith("DRAUSSEN")) || [...new Set(raus)].join(", ")})`); + await chef.seite.evaluate(() => { + for (const e of document.body.children) { + if (e.id !== "regie-vorhang") e.inert = true; + } + }); + } + + await chef.seite.fill("#regie-code", "CODE-XXX-999999"); + await chef.seite.click("#regie-los"); + await chef.seite.waitForSelector("#regie-fehler:not([hidden])", { timeout: 8000 }); + const meldung = await chef.seite.textContent("#regie-fehler"); + ok(/Code stimmt nicht/.test(meldung || ""), + `ein falscher Code wird abgewiesen, in ganzen Saetzen (${meldung})`); + const geleert = await chef.seite.inputValue("#regie-code"); + ok(geleert === "", "und das Feld ist danach leer"); + const nochZu = await torLesen(chef.seite); + ok(nochZu?.offen === "nein", "das Tor bleibt zu"); + + /* ---- Richtiger Code ---- */ + await chef.seite.fill("#regie-code", CODES.admin); + await Promise.all([ + chef.seite.waitForURL("**/reaktion.html", { timeout: 15000 }), + chef.seite.click("#regie-los"), + ]); + ok(/reaktion\.html/.test(chef.seite.url()), + `der richtige Code fuehrt in den Saal (${chef.seite.url().split("/").pop()})`); + await chef.kontext.close(); +} +{ + /* ---- Und danach ist es offen, auch in einem neuen Fenster ---- + Das ist der Punkt der ganzen Freigabe: Sie haengt an der Person + und nicht an dem Reiter, in dem getippt wurde. */ + const chef = await alsWer("admin", 1440, 900); + const t = await torLesen(chef.seite); + ok(t?.offen === "ja", `das Tor steht offen (${t?.offen})`); + ok(t?.marke === "Offen", `das Schild sagt es (${t?.marke})`); + ok(/Offen bis/.test(t?.satz || ""), `und bis wann (${t?.satz})`); + await bild(chef.seite, "12-tor-offen"); + + /* VanVan hat ihre eigene Tuer. Waere die Freigabe an der Rolle + festgemacht statt an der Person, stuende sie jetzt auch bei ihr + offen -- und im Protokoll staende, SIE sei hineingegangen. */ + const van = await alsWer("hand", 1440, 900); + const tv = await torLesen(van.seite); + ok(tv?.darf === "ja", `VanVan darf auch (${tv?.darf})`); + ok(tv?.offen === "nein", + `aber ihre Tuer ist ihre eigene und noch zu (${tv?.offen})`); + await van.kontext.close(); + + /* ---- Wieder abschliessen ---- */ + await chef.seite.evaluate(() => fetch('/workspace/api/reaktion/regie', + { method: 'DELETE', credentials: 'same-origin' })); + await chef.seite.reload({ waitUntil: "networkidle" }); + await chef.seite.waitForTimeout(400); + const zu = await torLesen(chef.seite); + ok(zu?.offen === "nein", `abschliessen geht auch wieder (${zu?.offen})`); + await chef.kontext.close(); +} + +/* ===================================================================== + 8. DIE GEGENPROBEN Fuenf Messungen oben koennen "in Ordnung" sagen. Hier wird jede absichtlich kaputtgemacht -- schlaegt sie nicht an, ist sie wertlos, und alle gruenen Haken davor beweisen nichts. ===================================================================== */ -console.log("\n=== 7. Gegenproben ==="); +console.log("\n=== 8. Gegenproben ==="); { const chef = await alsWer("admin", 390, 844); @@ -655,6 +856,16 @@ console.log("\n=== 7. Gegenproben ==="); await gast.seite.waitForTimeout(120); const sichtbar = await reihenLesen(gast.seite); ok(sichtbar.plan !== null, "Gegenprobe: eine aufgedeckte Reihe gilt als sichtbar"); + + /* (g) Und der Tor-Leser muss ein verschwundenes Tor auch + verschwunden nennen -- sonst waere „der Gast sieht es" oben nur + die Folge davon, dass er immer „da" sagt. */ + await gast.seite.evaluate(() => { + document.getElementById("regie-tor").style.display = "none"; + }); + await gast.seite.waitForTimeout(120); + const weg = await torLesen(gast.seite); + ok(weg?.da === false, "Gegenprobe: ein ausgeblendetes Tor gilt als weg"); await gast.kontext.close(); } @@ -669,8 +880,8 @@ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } einzige Messung, waere "0 Fehler" das beste Ergebnis ueberhaupt -- und voellig wertlos. */ console.log(`\n${geprueft} Messungen, ${fehler} Fehler`); -if (geprueft < 40) { - console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 40.`); +if (geprueft < 55) { + console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 55.`); process.exit(3); } if (fehler) { diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index ff55179c..89419683 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -3508,6 +3508,189 @@ melde("=== 20. Das Foyer: Plan, Eintritt, Vorschlaege ==="); } } +/* ======================================================================= + 21. DAS REGIE-TOR (08.10.2026) + + Filipe: „unter den [Reihen] fehlt eine kachel, die viel kraesser und + spezieller aussehen soll, wo nur dogfather oder vanvan reinkommen. + mit ihren zugangscodes fuer die seite." Und auf die Rueckfrage nach + der Staerke des Schlosses: „punkt 2 aber die soll jeder sehen aber + nur vanvan und dogfather sollen da rein kommen bitte." + + ZWEI HAELFTEN, UND BEIDE WERDEN GEMESSEN: dass JEDER die Kachel + sieht, und dass nur die zwei HINEINKOMMEN. Eine davon allein waere + keine Aussage. + ======================================================================= */ +melde(""); +melde("=== 21. Das Regie-Tor ==="); +{ + /* Eigener Griff auf die Datenbank: `d` oben ist nach dem Anlegen + der Personen geschlossen, und jeder Abschnitt, der nachsieht, + oeffnet seinen eigenen. */ + const db = new DatabaseSync(process.env.WORKSPACE_DB); + /* ---- Wer was zu sehen bekommt ---- */ + for (const rolle of ["gast", "modi", "linke"]) { + const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k[rolle] }); + ok(a.code === 200 && a.j?.darf === false, + `${rolle}: sieht das Tor, darf aber nicht hinein (${a.code}, darf=${a.j?.darf})`); + /* ES VERRAET NICHTS. Wer nicht hinein darf, bekommt auch nicht + zu wissen, ob gerade jemand drin ist. */ + ok(a.j?.offen === false && a.j?.bis === null, + `${rolle}: und erfaehrt nichts ueber den Zustand dahinter`); + } + for (const rolle of ["admin", "hand"]) { + const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k[rolle] }); + ok(a.code === 200 && a.j?.darf === true && a.j?.offen === false, + `${rolle}: darf hinein, Tor ist noch zu (darf=${a.j?.darf}, offen=${a.j?.offen})`); + } + + /* ---- Wer nicht darf, erzeugt KEINEN Fehlversuch ---------------- + Sonst koennte irgendwer im Haus die Bremse fuer seine eigene IP + vollaufen lassen und sich damit von der ANMELDUNG aussperren -- + beide zaehlen in derselben Tabelle. */ + { + const vorher = db.prepare("SELECT COUNT(*) AS n FROM versuche").get().n; + const a = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.gast, rumpf: { code: CODES.admin } }); + ok(a.code === 403 && a.j?.fehler === "regie_gehoert_den_zweien", + `ein Gast wird abgewiesen (${a.code} ${a.j?.fehler})`); + const nachher = db.prepare("SELECT COUNT(*) AS n FROM versuche").get().n; + ok(nachher === vorher, + `und sein Versuch zaehlt nicht gegen die Bremse (${vorher} -> ${nachher})`); + /* UND ZWAR AUCH DANN NICHT, WENN ER DEN RICHTIGEN CODE HAETTE. + Oben stand `CODES.admin` -- der Gast hat also den gueltigen + Code eines Hosts geschickt und kommt trotzdem nicht durch. + Die Rolle entscheidet VOR dem Code. */ + const z = db.prepare("SELECT COUNT(*) AS n FROM reaktion_regie").get().n; + ok(z === 0, `und niemand steht in der Freigabetabelle (${z})`); + } + + /* ---- Falscher Code ---- */ + { + const a = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.admin, rumpf: { code: "CODE-XXX-999999" } }); + ok(a.code === 401 && a.j?.fehler === "code_falsch", + `ein falscher Code wird abgewiesen (${a.code} ${a.j?.fehler})`); + const b = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin }); + ok(b.j?.offen === false, "und das Tor bleibt zu"); + } + + /* ---- DER CODE DER ANDEREN OEFFNET DIE EIGENE TUER NICHT -------- + Die Anmeldung geht alle Personen einer ROLLE durch; dort ist der + Code die Kennung. Hier waere das falsch: VanVans Code wuerde + DogFathers Tuer oeffnen, und im Protokoll stuende, ER sei + hineingegangen. */ + { + const a = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.admin, rumpf: { code: CODES.hand } }); + ok(a.code === 401 && a.j?.fehler === "code_falsch", + `VanVans Code oeffnet DogFathers Tuer nicht (${a.code})`); + } + + /* ---- Richtiger Code ---- */ + { + const a = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.admin, rumpf: { code: CODES.admin } }); + ok(a.code === 200 && a.j?.offen === true, + `der eigene Code oeffnet (${a.code}, offen=${a.j?.offen})`); + /* ZWOELF STUNDEN, und die Zahl kommt aus der Datei -- nicht aus + dieser Pruefung. Eine abgeschriebene Zahl waere die, die beim + naechsten Umbau stehen bleibt. */ + const t = await import("./reaktion-tabellen.js"); + const stunden = (new Date(a.j.bis) - Date.now()) / 3600_000; + ok(Math.abs(stunden - t.REGIE_FREI_STUNDEN) < 0.2, + `und zwar fuer ${t.REGIE_FREI_STUNDEN} Stunden (gemessen ${stunden.toFixed(2)})`); + ok(a.j?.stunden === t.REGIE_FREI_STUNDEN, + "die Seite erfaehrt die Dauer vom Server und rechnet sie nicht selbst"); + } + + /* ---- Die Freigabe haengt an der PERSON, nicht an der Rolle ---- */ + { + const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.hand }); + ok(a.j?.darf === true && a.j?.offen === false, + `VanVans Tuer ist ihre eigene und noch zu (offen=${a.j?.offen})`); + } + + /* ---- Und sie gilt ueber eine neue Anfrage hinweg ---- */ + { + const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin }); + ok(a.j?.offen === true, "beim naechsten Nachsehen steht sie offen"); + } + + /* ---- Abgelaufen heisst zu, und die Zeile wird weggeraeumt ------ + Zurueckdatiert statt zwoelf Stunden gewartet. */ + { + db.prepare("UPDATE reaktion_regie SET bis = ? WHERE person_id = ?") + .run(new Date(Date.now() - 60_000).toISOString(), IDS.admin); + const a = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin }); + ok(a.j?.offen === false, "eine abgelaufene Freigabe gilt nicht mehr"); + const n = db.prepare("SELECT COUNT(*) AS n FROM reaktion_regie WHERE person_id = ?") + .get(IDS.admin).n; + ok(n === 0, `und die Zeile ist weg statt nur ignoriert (${n})`); + } + + /* ---- Wieder abschliessen ---- */ + { + await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.admin, rumpf: { code: CODES.admin } }); + const auf = await roh(CREW, "/workspace/api/reaktion/regie", { keks: k.admin }); + ok(auf.j?.offen === true, "Gegenprobe: aufgeschlossen ist sie wirklich offen"); + const zu = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "DELETE", keks: k.admin }); + ok(zu.code === 200 && zu.j?.offen === false, + `und sie laesst sich wieder abschliessen (${zu.code})`); + } + + /* ---- Die Bremse ------------------------------------------------ + Acht Fehlversuche je IP in zehn Minuten, gemeinsam mit der + Anmeldung gezaehlt. Geprueft wird, DASS sie greift -- und danach + wird die Tabelle geleert, damit die naechsten Abschnitte nicht + gegen eine volle Bremse laufen. */ + { + let gebremst = 0; + let letzter = 0; + for (let i = 0; i < 12; i++) { + const a = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.admin, rumpf: { code: `CODE-FAL-00000${i}` } }); + letzter = a.code; + if (a.code === 429) gebremst++; + } + ok(gebremst > 0, `nach genug Fehlversuchen bremst sie (${gebremst} von 12, zuletzt ${letzter})`); + /* GEGENPROBE: Nach dem Leeren geht es wieder -- sonst bewiese die + Zeile darueber nur, dass irgendwann 429 kommt, und nicht, dass + es an der Bremse lag. */ + db.prepare("DELETE FROM versuche").run(); + const b = await roh(CREW, "/workspace/api/reaktion/regie", + { method: "POST", keks: k.admin, rumpf: { code: CODES.admin } }); + ok(b.code === 200, `und nach dem Zuruecksetzen geht es wieder (${b.code})`); + await roh(CREW, "/workspace/api/reaktion/regie", { method: "DELETE", keks: k.admin }); + } + + /* ---- Was auf der Seite steht ---- */ + { + const html = readFileSync( + join(HIER, "..", "workspace", "reaktion-foyer.html"), "utf8"); + const js = readFileSync( + join(HIER, "..", "workspace", "assets", "js", "reaktion-foyer.js"), "utf8"); + const css = readFileSync( + join(HIER, "..", "workspace", "assets", "css", "foyer.css"), "utf8"); + ok(/id="regie-tor"/.test(html), "das Tor steht in der Seite"); + ok(/]*class="regie-tor"/.test(html), + "und ist ein Knopf und kein Link -- er fuehrt erst nach dem Code irgendwohin"); + ok(/type="password"/.test(html), + "das Codefeld zeigt nichts an (Bildschirm teilen)"); + ok(/id="regie-vorhang"/.test(html) && html.indexOf("id=\"regie-vorhang\"") > html.indexOf(""), + "der Vorhang steht NEBEN main -- sonst laege er unter der Kopfleiste"); + ok(!/rolle\s*===\s*['"]admin/.test(js), + "das Tor fuehrt keine eigene Rollenliste, es fragt den Server"); + ok(/regie\?\.darf/.test(js), "es fragt, ob die Auskunft es erlaubt"); + ok(/prefers-reduced-motion/.test(css), + "wer weniger Bewegung eingestellt hat, bekommt das Licht ohne Lauf"); + ok(/\.regie-tor\[data-darf="nein"\]/.test(css), + "und wer nicht hinein darf, sieht es gedaempft"); + } +} + melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js index ef8ca926..f3260b1d 100644 --- a/server/reaktion-tabellen.js +++ b/server/reaktion-tabellen.js @@ -148,6 +148,26 @@ export const VORSCHLAG_BREMSE_SEKUNDEN = 45; /** Die Zustaende eines Vorschlags. */ export const VORSCHLAG_STAENDE = ["offen", "uebernommen", "abgelehnt"]; +/* ==== WIE LANGE DAS REGIE-TOR OFFEN BLEIBT (08.10.2026) ============ + + Filipe hat sich fuer die mittlere Staerke entschieden: Der Code + oeffnet die Tuer, danach ist die Regie offen wie bisher. + + ZWOELF STUNDEN, UND DIE ZAHL IST NICHT GERATEN. Kuerzer waere eine + Sicherung, die mitten in einem Sendeabend noch einmal fragt -- + genau die, die man nach dem dritten Mal umgeht. Laenger waere eine, + die am naechsten Morgen noch offen steht, ohne dass jemand daran + denkt. Ein Abend passt hinein, eine Nacht nicht. + + WICHTIG, DAMIT NIEMAND MEHR HINEINLIEST, ALS DRINSTEHT: Diese + Freigabe ist eine TUER und kein Schloss an den Regieknoepfen. Die + Routen der Sendung pruefen weiterhin nur die Rolle (`nurHost`) -- + wer `reaktion.html` direkt aufruft, kommt wie bisher an die Regie. + Das war die bewusste Wahl: Eine Sperre, die mitten in einer + Uebertragung zuschnappen kann, richtet mehr Schaden an, als sie + verhindert. Was sie wirklich leistet, steht im Foyer daneben. */ +export const REGIE_FREI_STUNDEN = 12; + /** Ein Chatbeitrag in der Reaction. */ export const TEXT_MAX = 400; @@ -548,6 +568,23 @@ export function reaktionTabellen(d) { CREATE INDEX IF NOT EXISTS idx_reaktion_vorschlag_stand ON reaktion_vorschlaege (stand, id); + /* ==== WER DAS REGIE-TOR AUFGESCHLOSSEN HAT (08.10.2026) ===== + + Eine Zeile je Person, nicht eine je Oeffnung: Gefragt ist + „steht die Tuer fuer dich gerade offen", nicht „wie oft warst + du schon drin". Wer die Geschichte will, findet sie im + Protokoll -- dort steht jedes Oeffnen und jedes Abschliessen. + + IN DER DATENBANK UND NICHT IM ARBEITSSPEICHER. Im Speicher + waere es nach jedem Dienstneustart weg, und ein Neustart + mitten in der Vorbereitung haette genau dann nach dem Code + gefragt, wenn am wenigsten Zeit dafuer ist. */ + CREATE TABLE IF NOT EXISTS reaktion_regie ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + seit TEXT NOT NULL, + bis TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_reaktion_liste_platz ON reaktion_liste (platz, id); CREATE INDEX IF NOT EXISTS idx_reaktion_chat_zeit diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index cebd9ac6..8c531e04 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -41,7 +41,7 @@ nicht, ein Handy geht einfach aus. ===================================================================== */ import express from "express"; -import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js"; +import { db, sitzungLesen, protokolliere, echteIp, codeGeprueft } from "./workspace.js"; import { stromAn } from "./workspace-chat.js"; import { buehneMelden } from "./workspace-buehne.js"; /* Der Spender-Glanz im Chat. EINE RICHTUNG: Die Reaction fragt die @@ -54,6 +54,7 @@ import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, EINLADUNG_SEKUNDEN, TEXT_MAX, SCHREIB_BREMSE, VORSCHLAEGE_JE_PERSON, VORSCHLAG_NOTIZ_MAX, VORSCHLAG_BREMSE_SEKUNDEN, + REGIE_FREI_STUNDEN, STAENDE, LAYOUTS, TEMPI, KAMERA_GROESSEN, KAMERA_ECKEN, MELDUNG_TAGE, @@ -1739,6 +1740,162 @@ reaktionRouter.delete("/workspace/api/reaktion/vorschlag/:id(\\d+)", (req, res) } }); +/* ======================================================================= + DAS REGIE-TOR (08.10.2026) + + Filipe: „unter den [Reihen] fehlt eine kachel, die viel kraesser und + spezieller aussehen soll, wo nur dogfather oder vanvan reinkommen. + mit ihren zugangscodes fuer die seite. und das soll die regie kachel + sein." Und auf die Rueckfrage, wie fest das Schloss sein soll: + „punkt 2 aber die soll jeder sehen aber nur vanvan und dogfather + sollen da rein kommen bitte." + + ----------------------------------------------------------------------- + JEDER SIEHT SIE -- UND DAS IST KEIN VERSEHEN + + Die linke Reihe steht fuer andere gar nicht erst da; hier ist es + umgekehrt, weil Filipe es ausdruecklich so wollte. Die Kachel + verraet deshalb NICHTS: kein Titel, kein Stand, keine Zahl. Sie ist + eine verschlossene Tuer mit einem Schild darauf. Was dahinter liegt, + steht nirgends in der Antwort fuer jemanden, der nicht hinein darf. + + ----------------------------------------------------------------------- + ZWEI SCHLOESSER HINTEREINANDER, UND NUR EINES DAVON IST GEHEIM + + (1) DIE ROLLE. Wer nicht admin oder rechte Hand ist, bekommt gar + kein Eingabefeld -- die Antwort sagt `darf: false`, und der + Server lehnt den Versuch ab, OHNE den Code anzusehen. Das ist + wichtig: Eine Kachel, die jeder sieht, waere sonst eine + Ratestelle fuer jeden im Haus. + (2) DER CODE. Fuer die zwei, die durchduerfen, zusaetzlich der + eigene Zugangscode -- geprueft mit `codeGeprueft()`, also mit + derselben Rechenvorschrift und derselben Bremse wie die + Anmeldung. Acht Fehlversuche je IP in zehn Minuten, gemeinsam + mit der Anmeldung gezaehlt. + + ----------------------------------------------------------------------- + WAS DIESE TUER LEISTET -- UND WAS NICHT + + Sie haelt den neugierigen Blick auf. Sie haelt NICHT jemanden auf, + der an einem offenen Rechner sitzt und `reaktion.html` direkt + aufruft: Die Regieknoepfe dort pruefen weiterhin nur die Rolle. + Das ist die bewusste Wahl (Filipes „Punkt 2") und keine vergessene + Stelle. Eine Sperre, die mitten in einer Uebertragung zuschnappen + kann, richtet mehr Schaden an, als sie verhindert. + + Damit das niemand falsch liest, steht es auch IM FOYER als Satz + neben der Kachel -- nicht nur hier im Quelltext. Eine Sicherung, + die staerker aussieht, als sie ist, ist schlechter als gar keine. + ======================================================================= */ + +/** Steht das Tor fuer diese Person gerade offen? */ +function regieFrei(personId) { + const z = db().prepare("SELECT seit, bis FROM reaktion_regie WHERE person_id = ?") + .get(personId); + if (!z) return null; + /* ABGELAUFENES WIRD WEGGERAEUMT, nicht nur ignoriert. Sonst stuende + in der Tabelle mit der Zeit eine Zeile je Person, die einmal + drin war -- und beim Nachsehen, wer gerade Zugang hat, muesste + man jedes Mal rechnen. */ + if (z.bis < jetzt()) { + db().prepare("DELETE FROM reaktion_regie WHERE person_id = ?").run(personId); + return null; + } + return z; +} + +/** Was die Kachel im Foyer wissen muss. */ +function regieLage(person) { + const darf = HOST_ROLLEN.has(person.rolle); + const z = darf ? regieFrei(person.id) : null; + return { + /* OB JEMAND HINEIN DARF, sagt der Server -- die Seite soll keine + zweite Rollenliste fuehren. */ + darf, + offen: !!z, + bis: z?.bis || null, + stunden: REGIE_FREI_STUNDEN, + }; +} + +reaktionRouter.get("/workspace/api/reaktion/regie", (req, res) => { + try { + res.json(regieLage(req.person)); + } catch (fehler) { + console.error("[reaktion] Regie-Lage:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +reaktionRouter.post("/workspace/api/reaktion/regie", express.json({ limit: "2kb" }), + (req, res) => { + try { + const ip = echteIp(req); + /* ERST DIE ROLLE, DANN DER CODE -- und ohne den Code auch nur + anzusehen. Wer nicht hinein darf, soll keinen Fehlversuch + erzeugen koennen: Sonst koennte irgendwer im Haus die Bremse + fuer seine eigene IP vollaufen lassen und sich damit selbst + von der Anmeldung aussperren. */ + if (!HOST_ROLLEN.has(req.person.rolle)) { + /* DIE WOERTER SIND SO GEWAEHLT, DASS SIE AUF DEM BILDSCHIRM + LESBAR SIND. Die Personenseite macht aus dem Schluessel den + Anzeigetext: Unterstriche werden Leerzeichen, der erste + Buchstabe gross, der Rest klein. „regie_code_falsch" waere + dort zu „Regie code falsch" geworden -- Maschinensprache mit + einem grossen Anfangsbuchstaben. Zwei Woerter, Hauptwort und + Mittelwort, ergeben dagegen einen Satz. */ + protokolliere("regie_unbefugt", { + personId: req.person.id, rolle: req.person.rolle, ip, + }); + return res.status(403).json({ fehler: "regie_gehoert_den_zweien" }); + } + + const urteil = codeGeprueft(req.person, req.body?.code, ip); + if (urteil === "zu_viele") return res.status(429).json({ fehler: "zu_viele_versuche" }); + if (urteil === "unbekannt") return res.status(503).json({ fehler: "nicht_verfuegbar" }); + if (urteil !== "ok") { + protokolliere("regie_verweigert", { + personId: req.person.id, rolle: req.person.rolle, ip, + }); + return res.status(401).json({ fehler: "code_falsch" }); + } + + const seit = jetzt(); + const bis = new Date(Date.now() + REGIE_FREI_STUNDEN * 3600_000).toISOString(); + db().prepare(`INSERT INTO reaktion_regie (person_id, seit, bis) VALUES (?,?,?) + ON CONFLICT(person_id) DO UPDATE SET seit = excluded.seit, bis = excluded.bis`) + .run(req.person.id, seit, bis); + protokolliere("regie_aufgeschlossen", { + personId: req.person.id, rolle: req.person.rolle, ip, + /* UND DAS DETAIL AUCH LESBAR. Hier stand der Zeitpunkt als + ISO-Zeichenkette -- also „2026-10-08T23:43:11.004Z" mitten + in einer Zeile, die ein Mensch ueberfliegt. Die Dauer sagt + dasselbe und braucht keine Umrechnung im Kopf. */ + detail: `${REGIE_FREI_STUNDEN} Stunden`, + }); + res.json(regieLage(req.person)); + } catch (fehler) { + console.error("[reaktion] Regie oeffnen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* WIEDER ABSCHLIESSEN. Eine Tuer, die man nur aufmachen kann, ist + keine: Wer den Rechner einem anderen gibt, muss sie zumachen + koennen, ohne zwoelf Stunden zu warten oder sich abzumelden. */ +reaktionRouter.delete("/workspace/api/reaktion/regie", (req, res) => { + try { + db().prepare("DELETE FROM reaktion_regie WHERE person_id = ?").run(req.person.id); + protokolliere("regie_abgeschlossen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + }); + res.json(regieLage(req.person)); + } catch (fehler) { + console.error("[reaktion] Regie schliessen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /** Eins hoch oder runter. * * GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim diff --git a/server/workspace.js b/server/workspace.js index e8a69f9b..3f5189ea 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -6910,6 +6910,92 @@ function zuVieleVersuche(ip) { return anzahl >= VERSUCHE_MAX; } +/* ===================================================================== + NOCH EINMAL DEN EIGENEN CODE -- FUER EINE TUER HINTER DER ANMELDUNG + (08.10.2026) + + Gebaut fuer das Regie-Tor im Foyer der Reaction. Filipe: die Kachel + soll jeder sehen, hinein kommen aber nur DogFather und VanVan, und + zwar mit ihrem Zugangscode. + + --------------------------------------------------------------------- + WARUM DAS HIER STEHT UND NICHT DORT + + Die Anmeldung weiter oben weiss, wie man einen Code prueft: scrypt + mit genau diesen Parametern, Vergleich in konstanter Zeit, ein + Eintrag in `versuche`, eine Bremse je IP. Wer das in einem anderen + Modul noch einmal schreibt, hat eine ZWEITE Antwort auf dieselbe + Frage -- und die zweite ist die, die beim naechsten Umbau der + Parameter stehen bleibt. Ein Codevergleich, der still mit einem + alten N rechnet, sperrt entweder jeden aus oder laesst jeden + durch, und beides faellt erst spaet auf. + + Deshalb EINE Funktion mit EINER Antwort. Sie bekommt die Person + (die kennt der Aufrufer aus der Sitzung) und gibt vier moegliche + Woerter zurueck. Der Aufrufer entscheidet, was er damit macht. + + --------------------------------------------------------------------- + SIE PRUEFT DEN CODE GENAU DIESER PERSON + + Die Anmeldung geht alle aktiven Personen einer ROLLE durch -- dort + ist der Code die Kennung, es gibt kein Benutzerfeld. Hier ist das + falsch: Wer schon angemeldet ist, hat einen Namen. Wuerde ich auch + hier die Rolle durchgehen, oeffnete VanVans Code die Tuer fuer + DogFathers Sitzung. Das waere kein Loch nach aussen, aber es waere + eine Luege im Protokoll -- dort stuende, DogFather sei hineingegangen. + + --------------------------------------------------------------------- + DIE BREMSE IST DIESELBE WIE AN DER ANMELDUNG + + Acht Fehlversuche je IP in zehn Minuten, gemeinsam gezaehlt. Das + ist Absicht: Wer am Tor raet, soll nicht acht weitere Versuche + bekommen, indem er zwischendurch die Anmeldung probiert. Und ein + gelungener Versuch raeumt die Liste NICHT -- anders als bei der + Anmeldung, wo ein erfolgreicher Login `versuche` leert. Hier sitzt + schon jemand Angemeldetes davor; die Bremse soll ihm gegenueber + nicht nachgiebiger sein als gegenueber einem Fremden. + + @param {{id:number, rolle:string}} person aus der Sitzung + @param {string} code was getippt wurde + @param {string} ip fuer die Bremse + @returns {"ok"|"falsch"|"zu_viele"|"unbekannt"} + "unbekannt" heisst: Die Person gibt es nicht mehr oder ihre + Zugangsdaten sind defekt -- der dritte Ausgang, und er darf + NICHT wie "falsch" aussehen, sonst sucht jemand den Fehler + bei sich. +*/ +export function codeGeprueft(person, code, ip) { + const roh = String(code ?? "").trim(); + if (!person?.id || roh.length < 4 || roh.length > 64) return "falsch"; + if (zuVieleVersuche(ip)) return "zu_viele"; + + const k = db() + .prepare("SELECT id, rolle, code_hash, code_salt, code_n FROM personen WHERE id = ? AND aktiv = 1") + .get(person.id); + if (!k) return "unbekannt"; + + let stimmt = false; + try { + stimmt = gleich(hashe(roh, k.code_salt, k.code_n), k.code_hash); + } catch (f) { + /* Dieselbe Lage wie in der Anmeldung: ein defekter Datensatz ist + ein Problem DIESER Person und keine falsche Eingabe. */ + console.error(`[workspace] Zugangsdaten von Person #${k.id} (${k.rolle}) sind defekt ` + + `-- sie kann keine Tuer mehr oeffnen. Grund: ${f?.message}`); + protokolliere("zugangsdaten_defekt", { + personId: k.id, rolle: k.rolle, ip, + detail: String(f?.message || "").slice(0, 80), + }); + return "unbekannt"; + } + + if (!stimmt) { + db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); + return "falsch"; + } + return "ok"; +} + /* ---------- Sitzung ---------------------------------------------------- */ export function sitzungLesen(req) { diff --git a/workspace/anleitung.html b/workspace/anleitung.html index 5187e34b..bfa30236 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + + @@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 0ee4da73..d939995f 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`. --> - - + + - +