From 7be85c265b72a86367b0d6cdcd4ad88e76fb1e78 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Sat, 19 Sep 2026 11:47:31 +0200 Subject: [PATCH] Der Treff bekommt einen Chat -- mit Nachtruhe, ohne Telefon Filipe, auf die Frage ob die Community einen Chat bekommen soll: "eine geile mischung von punkt 2 und drei. mach es perfekt so das quasi nach mitternacht bis morgens 6 uhr keiner schreiben kann damit auch die privatsphaere beruecksichtig wird ueber nacht ... und die sollen nicht anrufen koennen. nur schreiben." Zur Auswahl standen "gar kein Chat", "nur mit Oeffnungszeiten" und "offen wie im Team". Seine Antwort ist eine vierte, die nicht auf dem Zettel stand, und sie ist die bessere. 1. WARUM DIE NACHTRUHE MEHR IST ALS EINE EINSTELLUNG Sein Grund war Privatsphaere -- und zwar die der Leute, die moderieren. Die Forschung zu ehrenamtlichen Moderatoren sagt genau das: Die beiden meistgenannten Gruende fuers Aufhoeren sind ZU WENIG ZEIT und STREIT IM TEAM. Ein Chat, der nachts um drei weiterlaeuft, erzeugt beides. SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Das ist die unbequeme Entscheidung: Der bequeme Weg waere, DogFather auszunehmen. Aber wenn er um halb vier schreibt, liest es jemand -- und wer liest, fuehlt sich zustaendig. Eine Nachtruhe, von der die Leitung ausgenommen ist, ist keine, sondern eine Bitte. MODERIEREN GEHT WEITER. Verbergen, loeschen, herausnehmen sind keine Nachrichten. Wer nachts etwas Schlimmes sieht, kann es wegnehmen -- er kann nur nicht darueber diskutieren. Und der vertrauliche Meldeweg bleibt offen: Er ist ein Fall mit einer Zustaendigkeit, kein Raum mit dreissig Leuten. GERECHNET IN ORTSZEIT, nicht in UTC. `getHours()` auf einem UTC-Server haette den Chat im Sommer von 2 bis 8 geschlossen -- zwei Stunden daneben, und nur denen sichtbar, die um diese Zeit wach sind. Dieses Haus ist mit der Rechnung schon zweimal hereingefallen. DER RIEGEL SITZT IM SERVER, nicht im Browser. Ein ausgegrautes Feld ist eine Hoeflichkeit; wer die Adresse kennt, spricht die Schnittstelle direkt an. Nachrichten UND Anhaenge werden abgelehnt -- ein offener Anhangweg waere der Umweg, den jeder findet, der es einmal versucht. 2. KEIN TELEFON -- UND WARUM DAS EINEN EIGENEN RIEGEL BRAUCHT Ein Anruf haengt in diesem Haus an der RAUMMITGLIEDSCHAFT, nicht an der Rolle. Solange die Community keinen Raum hatte, war die Frage muessig. Seit sie einen hat, waere Telefonieren erlaubt -- nicht, weil es jemand entschieden haette, sondern weil es niemand verboten hat. Der Riegel steht ganz vorne am Anruf-Router, nicht in den neun Routen dahinter. Alle sieben Wege sind geprueft, einzeln. 3. DER FUND, DER DIE GANZE NACHTRUHE UMGANGEN HAETTE Ein Gast konnte ein PRIVATES ZWEIER-GESPRAECH mit DogFather aufmachen. Die Route antwortete mit 200 und legte den Raum an. `ohneAussen` nimmt die Community aus den Listen ANDERER heraus -- damit kein Creator einen Zuschauer anschreibt. Die Gegenrichtung kam nie vor, weil die Chatseite fuer 'gast' gar nicht offen war. Seit heute ist sie offen, und damit war die Erlaubnis da. Ein Zweier-Gespraech ist kein Kanal: keine Nachtruhe, kein Modi liest mit, niemand koennte moderieren. Aus "die Community bekommt einen Chat" waere ungewollt "jeder Zuschauer bekommt eine Standleitung zu DogFather" geworden. GEFUNDEN HAT ES DIE PRUEFUNG, NICHT DAS LESEN -- und zwar in dem Moment, in dem ich zwei Dateien weiter selbst vor genau dieser Sorte Erlaubnis gewarnt hatte. 4. ZWEI TOTE WEGE, EINER DAVON MEINER pruef-community-sicht sucht nach sichtbaren Wegen, die ins Leere fuehren. Sie fand zwei: a) "Bei dir klingelt nichts" -> anruf-probe.html. Der Hinweis von heute Vormittag, und die Seite steht einem Gast ausdruecklich NICHT offen. Mein Fehler, und einer, der jedem naechsten Hinweis genauso passiert waere. AN DER WURZEL BEHOBEN: Die Hinweise pruefen jetzt auch die ROLLE (`darfSeite`), nicht nur die Adresse. Das ist der 15.09. noch einmal, eine Ebene tiefer -- damals die Adresse, diesmal die Rolle, und dieselbe Lehre: Die Hinweise sind eine DRITTE Stelle neben Seiten und Schnittstellen. b) Der Verweis "Kalender" im Tagesblick. Steht fest im HTML, fuehrt fuer die Community ins Leere -- und zwar SCHON VOR den Aenderungen dieses Tages. Aufgefallen nur, weil (a) daneben stand. Der Weg wird jetzt abgeleitet: Wer die Kachel hat, hat den Weg. 5. DIE PRUEFUNG LAEUFT ZWEIMAL Die Nachtruhe laesst sich nicht in EINEM Lauf in beiden Zustaenden pruefen -- die Zeiten werden beim Laden gelesen, und ein zweiter Server braeuchte denselben Port. pruef-treffchat ruft sich deshalb selbst noch einmal auf, mit einem Fenster, das jetzt gilt. Dieselben Pruefungen, andere Lage. DIE UHR WIRD NICHT GEFAELSCHT. Verschoben wird die GRENZE, nicht die Zeit -- eine Pruefung, die an der Systemzeit dreht, faelscht danach auch anderes mit und ist nur nachts gruen. 6. WAS SICH NEBENBEI GEAENDERT HAT - pruef-treff war SCHON VORHER ROT (8 Fehler): Sie erwartete sieben Kacheln fuer die Community, es waren gestern Nacht schon zehn. Feste Zahlen, Rechnung von gestern. Jetzt abgeleitet -- 72 Pruefungen statt 66, alle gruen. - Ein 404 bei jedem Seitenaufruf der Community: `anruf.js` holte die Verbindungsadressen, bevor es wusste, wer fragt. Der Browser protokolliert das, BEVOR JavaScript abfangen kann -- also darf die Anfrage gar nicht erst gestellt werden. `/api/ich` sagt es jetzt vorher. Ein Fehler, der immer kommt, macht den naechsten echten unsichtbar. - Reaktionen (Daumen) bleiben nachts erlaubt. Ausdruecklich, mit Begruendung im Code: Ein Daumen ist keine Nachricht, er loest keine Meldung aus und kann keinen Streit anfangen. Eine Luecke ohne Begruendung wird beim naechsten Durchsehen entweder geschlossen oder uebersehen -- beides falsch. GEMESSEN: pruef-treffchat 108/0 (neu, laeuft zweimal), pruef-treff 72/0 (vorher 66 mit 8 Fehlern), pruef-chat gruen, pruef-rechtetafel 19/0, pruef-community-sicht gruen. Co-Authored-By: Claude Opus 5 --- server/pruef-treff.mjs | 141 ++++++- server/pruef-treffchat.mjs | 625 ++++++++++++++++++++++++++++++++ server/rechte.js | 14 +- server/workspace-anruf.js | 30 ++ server/workspace-chat.js | 89 +++++ server/workspace-hinweise.js | 28 +- server/workspace-treffchat.js | 284 +++++++++++++++ server/workspace.js | 80 ++++ workspace/anruf-probe.html | 8 +- workspace/app.webmanifest | 8 +- workspace/assets/css/chat.css | 63 ++++ workspace/assets/css/start.css | 1 + workspace/assets/js/anruf.js | 55 ++- workspace/assets/js/chat.js | 182 ++++++++++ workspace/assets/js/start.js | 27 ++ workspace/aufgaben.html | 24 +- workspace/automation.html | 26 +- workspace/befinden.html | 24 +- workspace/bereich.html | 36 +- workspace/bewerben.html | 30 +- workspace/bewerbungen.html | 28 +- workspace/calls.html | 28 +- workspace/chat.html | 54 ++- workspace/content.html | 30 +- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 26 +- workspace/entwicklung.html | 24 +- workspace/hilfe.html | 22 +- workspace/index.html | 8 +- workspace/kalender.html | 28 +- workspace/leistung.html | 26 +- workspace/personen.html | 26 +- workspace/profil.html | 28 +- workspace/rechte.html | 24 +- workspace/report.html | 28 +- workspace/scouting.html | 26 +- workspace/start.html | 24 +- workspace/startcheck.html | 28 +- workspace/steckbrief.html | 28 +- workspace/talente.html | 26 +- workspace/team.html | 26 +- workspace/teamlage.html | 24 +- workspace/teilen.html | 22 +- workspace/treff-moderation.html | 24 +- workspace/treff-regeln.html | 26 +- workspace/uebersicht.html | 26 +- workspace/unsere-seiten.html | 22 +- workspace/werdegang.html | 26 +- workspace/wissen.html | 26 +- 50 files changed, 2066 insertions(+), 461 deletions(-) create mode 100644 server/pruef-treffchat.mjs create mode 100644 server/workspace-treffchat.js diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs index c286d0e4..5ee24bba 100644 --- a/server/pruef-treff.mjs +++ b/server/pruef-treff.mjs @@ -290,13 +290,30 @@ melde("=== 1b. Eine Wand für beide ==="); 2. DIE KACHELN: sieben, und sonst nichts ======================================================================= */ melde(""); -melde("=== 2. Sieben Kacheln — und keine achte ==="); +melde("=== 2. Die Kacheln der Community ==="); let namen = []; { const a = await roh("/workspace/api/ich", CREW, { cookie: gast.keks }); const j = JSON.parse(a.text); - ok(Array.isArray(j.bereiche) && j.bereiche.length === 7, - `die Community bekommt sieben Kacheln (${j.bereiche?.length})`); + /* ==== ABGELEITET STATT ABGESCHRIEBEN (19.09.2026) ================ + + Hier stand siebenmal die Zahl SIEBEN und einmal die Namensliste + ausgeschrieben. Beides war zu diesem Zeitpunkt schon falsch: + Gestern Nacht kamen "Unsere Seiten", "Vertraulich melden" und + "Mein Steckbrief" dazu -- die Pruefung war also bereits rot, ohne + dass irgendetwas kaputt war, und heute kam der Chat als elfte + hinzu. + + Genau die Sorte Zahl, vor der die Hausregel warnt: eine Rechnung + von gestern, die still falsch wird. Gezaehlt wird ab jetzt gegen + die Liste, die der Server wirklich fuehrt. Was diese Pruefung + sichern soll, ist nicht die ZAHL, sondern dass die Community + genau das bekommt, was fuer sie gebaut wurde -- und nichts vom + Team. */ + const { bereicheFuer } = await import("./workspace.js"); + const erwartet = bereicheFuer({ rolle: "gast" }).map((k) => k.name); + ok(Array.isArray(j.bereiche) && j.bereiche.length === erwartet.length, + `die Community bekommt ${erwartet.length} Kacheln (${j.bereiche?.length})`); /* Die Namen werden im naechsten Block noch einmal gebraucht -- dort wird gegen sie geprueft, statt sie ein zweites Mal zu tippen. Zwei Abschriften derselben sieben Namen waeren zwei Gelegenheiten, @@ -315,12 +332,37 @@ let namen = []; Wer sie wieder aendert, aendert beides. `pruef-kachelraster` rechnet zusaetzlich nach, ob daraus ein Loch entsteht. */ - ok(namen.join(" | ") === "Der Treff | Anschlagbrett | Was ansteht | Wunschliste | Highlights | Regeln & Hilfe | Mitmachen", + ok(namen.join(" | ") === erwartet.join(" | "), `und zwar diese: ${namen.join(", ")}`); - ok((j.bereiche || []).every((k) => k.gruppe === "Community"), - "alle in der Gruppe „Community“"); + /* DIE BREITE KACHEL STEHT VORNE, und das bleibt eine echte Aussage: + Das Raster hat drei Spalten, "Der Treff" ist doppelt breit. An + dritter Stelle passt sie nicht mehr in die erste Reihe und reisst + ein Loch hinein -- genau das, was Filipe am 17.09. auf dem + Bildschirmfoto gesehen hat. */ + ok(namen[0] === "Der Treff", `und „Der Treff“ steht vorne (${namen[0]})`); + /* NICHTS VOM TEAM. Der eigentliche Zweck dieses Abschnitts: Eine + Kachel des Teams bei der Community waere lautlos falsch -- sie + saehe aus wie die anderen, und erst beim Klicken sagte der Server + 404. */ + const teamKacheln = ["Aufgaben", "Kalender", "Dateien", "Personen & Zugänge", + "Eure Aufgaben", "Talente", "Entwicklung", "Meldungen & Maßnahmen"]; + ok(!namen.some((n) => teamKacheln.includes(n)), + "und keine einzige Kachel des Teams ist dabei"); + /* ZWEI STEHEN IN "FUER DICH", NICHT IN "COMMUNITY" -- und das ist + die Aussage, nicht die Ausnahme: "Vertraulich melden" und "Mein + Steckbrief" gehoeren der einzelnen Person, nicht dem Raum. Sie + zwischen die Bretter zu stellen hiesse, dass eine vertrauliche + Meldung aussieht wie ein oeffentliches Brett. */ + const PERSOENLICH = ["Vertraulich melden", "Mein Steckbrief"]; + const bretter = (j.bereiche || []).filter((k) => !PERSOENLICH.includes(k.name)); + ok(bretter.every((k) => k.gruppe === "Community"), + `die ${bretter.length} Bretter stehen in der Gruppe „Community“`); + ok((j.bereiche || []).filter((k) => PERSOENLICH.includes(k.name)) + .every((k) => k.gruppe === "Für dich"), + "und die zwei persönlichen in „Für dich“ — eine Meldung ist kein Brett"); const toene = new Set((j.bereiche || []).map((k) => k.ton)); - ok(toene.size === 7, `sieben verschiedene Farben (${[...toene].join(", ")})`); + ok(toene.size === (j.bereiche || []).length, + `${toene.size} Kacheln, ${toene.size} verschiedene Farben (${[...toene].join(", ")})`); ok(j.rolle_name === "Community", `die Rolle heißt „${j.rolle_name}“`); } { @@ -341,16 +383,75 @@ let namen = []; const MOD = "Meldungen & Ma\u00dfnahmen"; const a = JSON.parse((await roh("/workspace/api/ich", CREW, { cookie: kModi })).text); const beimModi = (a.bereiche || []).filter((k) => k.gruppe === "Community").map((k) => k.name); - const fehlt = namen.filter((n) => !beimModi.includes(n)); + /* ==== ZWEI KACHELN SIEHT EIN MODI AUSDRUECKLICH NICHT ============ + + Bis zum 18.09. stimmte "ein Modi sieht alle Kacheln der Community + mit". Seitdem gibt es zwei, die ihm NICHT gehoeren -- und beide + sind Absicht, keine Luecke: + + Vertraulich melden Sehr oft geht es in diesen Meldungen um eine + Moderationsentscheidung. Wer beteiligt ist, + darf die Beschwerde ueber sich nicht lesen. + Mein Steckbrief Jeder hat seinen eigenen. "Der von jemand + anderem" ist hier gar nicht gemeint. + + Die Pruefung war dadurch seit gestern rot, ohne dass etwas kaputt + war. Jetzt steht die Ausnahme ausdruecklich da -- und sie ist + eine ECHTE Pruefung: Taucht "Vertraulich melden" eines Tages bei + einem Modi auf, wird diese Zeile rot. */ + const NUR_COMMUNITY = ["Vertraulich melden", "Mein Steckbrief"]; + /* Verglichen wird die GRUPPE "Community" auf beiden Seiten -- die + zwei persoenlichen Kacheln stehen dort ohnehin nicht drin. */ + const sollteSehen = namen.filter((n) => !NUR_COMMUNITY.includes(n)); + const fehlt = sollteSehen.filter((n) => !beimModi.includes(n)); ok(fehlt.length === 0, - `ein Modi sieht alle sieben der Community mit${fehlt.length ? " -- FEHLT: " + fehlt.join(", ") : ""}`); - ok(beimModi.includes(MOD) && !namen.includes(MOD), - `und genau eine mehr: ${beimModi.filter((n) => !namen.includes(n)).join(", ")}`); + `ein Modi sieht die ${sollteSehen.length} Bretter der Community mit` + + `${fehlt.length ? " -- FEHLT: " + fehlt.join(", ") : ""}`); + const zuViel = NUR_COMMUNITY.filter((n) => beimModi.includes(n)); + ok(zuViel.length === 0, + `aber NICHT den vertraulichen Meldeweg${zuViel.length ? " -- DURCH: " + zuViel.join(", ") : ""}`); + /* "Meldungen & Massnahmen" steht in der Gruppe MODERATION, nicht in + "Community" -- gemessen, nicht angenommen. Gesucht wird deshalb in + seiner ganzen Liste. Dass sie eine EIGENE Gruppe hat, ist richtig: + Sie ist kein Brett der Community, sondern das Werkzeug derer, die + sie betreuen. */ + const modiAlles = [...(a.bereiche || []), ...(a.bereiche_zusatz || [])] + .map((k) => k.name); + ok(modiAlles.includes(MOD) && !namen.includes(MOD), + `und genau eine mehr, die die Community NICHT hat: ${MOD}`); const b = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kDogi })).text); const beiDogi = (b.bereiche_zusatz || []).filter((k) => k.gruppe === "Community").map((k) => k.name); - ok(namen.every((n) => beiDogi.includes(n)) && beiDogi.includes(MOD), - `und DogFather auf seiner Adresse auch (${beiDogi.length})`); + /* DogFather sieht den Meldeweg sehr wohl -- er beantwortet ihn. */ + /* Auch hier: MOD steht in der Gruppe "Moderation", nicht in + "Community". `beiDogi` ist nach Gruppe gefiltert und kann ihn + deshalb gar nicht enthalten -- die alte Bedingung war unerfuellbar, + sobald die Moderationskachel ihre eigene Gruppe bekam. */ + const dogiAlles = [...(b.bereiche || []), ...(b.bereiche_zusatz || [])] + .map((k) => k.name); + ok(sollteSehen.every((n) => beiDogi.includes(n)), + `und DogFather sieht auf seiner Adresse dieselben ${beiDogi.length} Bretter`); + ok(dogiAlles.includes(MOD), `und die Moderationskachel dazu (${MOD})`); + /* DER MELDEWEG BEI DOGFATHER: Er beantwortet ihn, also muss er ihn + haben -- aber er steht bei ihm NICHT in der Gruppe "Community", + sondern dort, wo seine eigenen Werkzeuge stehen. Gesucht wird + deshalb in seiner GANZEN Liste, nicht nur in einer Gruppe. */ + /* DER MELDEWEG LEBT AUF DER TEAM-ADRESSE, nicht auf der Agentur-. + Gemessen: Auf crew. hat DogFather ihn in der Gruppe "Fuer dich"; + auf workspace. steht er nicht, und das ist richtig -- dort geht es + um die Agentur, nicht um den Treff. Geprueft wird deshalb dort, wo + er hingehoert. */ + const cDogi = JSON.parse((await roh("/workspace/api/ich", CREW, { cookie: kDogi })).text); + const dogiAufCrew = [...(cDogi.bereiche || []), ...(cDogi.bereiche_zusatz || [])] + .map((k) => k.name); + ok(dogiAufCrew.includes("Vertraulich melden"), + "auf der Team-Adresse steht der vertrauliche Meldeweg bei ihm — er beantwortet ihn"); + /* GEGENPROBE: bei einem Modi nicht -- und zwar auf DERSELBEN + Adresse. Ohne diese Zeile waere die darueber auch gruen, wenn ihn + jeder saehe; mit einer anderen Adresse waere sie gruen, ohne + etwas zu beweisen. */ + ok(!modiAlles.includes("Vertraulich melden"), + "bei einem Modi auf derselben Adresse nicht — er ist oft der Anlass"); const c = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kMana })).text); const beiMana = (c.bereiche_zusatz || []).filter((k) => k.gruppe === "Community"); @@ -552,7 +653,19 @@ for (const [name, pfad] of [ ["Termine", "/workspace/api/termine"], ["Dateien", "/workspace/api/dateien"], ["Personen", "/workspace/api/personen"], - ["Gespräche", "/workspace/api/chat/raeume"], + /* "Gespräche" IST HIER HERAUSGENOMMEN (19.09.2026). + + Bis heute stimmte die Zeile: Die Community hatte keinen Chat, also + musste dort nichts herauskommen. Filipe hat das geändert -- sie + bekommt genau EINEN Raum, den Treff, mit Nachtruhe und ohne + Anrufe. + + SIE FÄLLT NICHT WEG, SIE ZIEHT UM: Was der Chat für die Community + darf und was nicht, prüft jetzt `pruef-treffchat` -- und zwar + genauer, als diese Zeile es je konnte (ein Raum und nicht mehr, + keine Zweier-Gespräche, kein Telefon, Nachtruhe am Server). + Sie hier stehen zu lassen hiesse, eine bestellte Funktion für + einen Fehler zu halten. */ ]) { const a = await roh(pfad, CREW, { cookie: gast.keks }); let leer = a.code !== 200; diff --git a/server/pruef-treffchat.mjs b/server/pruef-treffchat.mjs new file mode 100644 index 00000000..f269dc70 --- /dev/null +++ b/server/pruef-treffchat.mjs @@ -0,0 +1,625 @@ +/* ===================================================================== + DER TREFF-CHAT MIT NACHTRUHE (19.09.2026) + + Filipe: „eine geile mischung von punkt 2 und drei. mach es perfekt + so das quasi nach mitternacht bis morgens 6 uhr keiner schreiben + kann damit auch die privatsphäre berücksichtig wird über nacht … + und die sollen nicht anrufen können. nur schreiben." + + --------------------------------------------------------------------- + DREI FRAGEN, UND NUR DIE ERSTE IST OFFENSICHTLICH + + 1. Kann die Community überhaupt schreiben — und nur im Treff? + 2. Hält die Nachtruhe auch dann, wenn niemand auf die Oberfläche + schaut? Ein ausgegrautes Feld ist eine Höflichkeit. Geprüft + wird die SCHNITTSTELLE, denn die kann jeder direkt ansprechen. + 3. Ist das Telefon wirklich zu? Ein Anruf hängt in diesem Haus an + der RAUMMITGLIEDSCHAFT, nicht an der Rolle. Seit die Community + einen Raum hat, wäre Telefonieren erlaubt — nicht, weil es + jemand entschieden hätte, sondern weil es niemand verboten + hat. Das ist die gefährlichste Art von Erlaubnis. + + --------------------------------------------------------------------- + DIE ZEIT WIRD NICHT GEFÄLSCHT, SIE WIRD ÜBERGEBEN + + Die Nachtruhe hängt an der Uhr. Eine Prüfung, die die Systemzeit + verstellt, fälscht danach auch anderes mit — und sie wäre nur + nachts grün oder nur tagsüber. Deshalb nimmt `istNachtruhe(d)` ein + Datum entgegen, und geprüft werden feste Zeitpunkte als EINGABE. + Das gilt zu jeder Tages- und Nachtzeit gleich. + + Für den Weg durch die Schnittstelle wird stattdessen die GRENZE + verschoben (Umgebungsvariable), nicht die Uhr. + + Aufrufen mit: node server/pruef-treffchat.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +/* ==== ZWEI LAEUFE, EIN PROGRAMM ===================================== + + Die Nachtruhe laesst sich nicht in EINEM Lauf in beiden Zustaenden + pruefen: Die Zeiten werden beim Laden des Moduls gelesen, und ein + zweiter Server im selben Prozess braeuchte denselben Port. + + Deshalb ruft sich diese Datei am Ende SELBST noch einmal auf -- mit + `TREFF_PRUEF_NACHT=ja` und einem Fenster, das JETZT gilt. Derselbe + Code, dieselben Pruefungen, andere Lage. + + Das ist dieselbe Bauweise wie in pruef-content.mjs (zwei Server + nacheinander) und aus demselben Grund: Eine Gegenprobe, die nur + behauptet wird, ist keine. */ +const NACHTLAUF = process.env.TREFF_PRUEF_NACHT === "ja"; +const PORT = await portMussFreiSein(NACHTLAUF ? 4424 : 4423, + NACHTLAUF ? "pruef-treffchat (Nachtlauf)" : "pruef-treffchat"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-treff-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +/* ==== DIE GRENZE STATT DER UHR ====================================== + + Damit der Weg durch die Schnittstelle in BEIDEN Zuständen prüfbar + ist, ohne die Systemzeit anzufassen: Die Nachtruhe wird auf ein + Fenster gelegt, das JETZT gerade nicht gilt — dann muss Schreiben + gehen. Für den gesperrten Fall wird der Server ein zweites Mal + gestartet, mit einem Fenster, das jetzt gilt. + + Gerechnet wird die aktuelle Stunde in Ortszeit, genau wie im + Programm. Eine feste Zahl hier wäre eine Prüfung, die zu einer + bestimmten Uhrzeit rot wird — und ein Fehlalarm, den man nicht + nachstellen kann, ist der sicherste Weg, eine Prüfung abzuschalten. */ +const jetztStunde = Number(new Intl.DateTimeFormat("de-DE", { + timeZone: "Europe/Berlin", hour: "2-digit", hour12: false, +}).format(new Date()).replace(/\D/g, "")) % 24; + +/* Ein Fenster von zwei Stunden, das JETZT nicht gilt: drei Stunden + voraus. */ +const OFFEN_AB = (jetztStunde + 3) % 24; +const OFFEN_BIS = (jetztStunde + 5) % 24; +/* Und eines, das JETZT gilt: von der aktuellen Stunde bis zwei + spaeter. Die laufende Stunde ist immer enthalten. */ +const ZU_AB = jetztStunde; +const ZU_BIS = (jetztStunde + 2) % 24; +process.env.TREFF_NACHT_AB = String(NACHTLAUF ? ZU_AB : OFFEN_AB); +process.env.TREFF_NACHT_BIS = String(NACHTLAUF ? ZU_BIS : OFFEN_BIS); + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(180_000, "pruef-treffchat"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +/* Die Agenturadresse. Ein Creator gehoert dorthin und kommt auf crew. + gar nicht erst herein -- das ist strenger als alles, was diese Datei + sonst prueft, und es ist der Grund, warum die Gegenprobe unten den + Creator DORT anmeldet. */ +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const T = await import("./workspace-treffchat.js"); +const rechte = await import("./rechte.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +let idDogi, idModi, idGast, idGast2, idCreator; +{ + const d = oeffne(); + const schluessel = randomBytes(32).toString("hex"); + d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + const 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", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idModi = anlegen("Diene", "modi", "CODE-MODI-0001"); + idGast = anlegen("Melli", "gast", "CODE-GAST-0001"); + idGast2 = anlegen("Toni", "gast", "CODE-GAST-0002"); + idCreator = anlegen("Tili", "creator", "CODE-CREA-0001"); + d.close(); +} + +function ruf(pfad, host, methode, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf } }, + (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + /* `alter_ok` NUR fuer die Community. Fuer alle anderen Rollen darf + der Server das Feld gar nicht kennen -- so steht es in + pruef-treff.mjs, und so wird es hier genauso gemacht. Ohne dieses + Feld weist er einen Gast ab, und die Pruefung haette gemeldet + "ein Gast bekommt keine Gespraechsliste": wahr, und aus dem + voellig falschen Grund. */ + const koerper = rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }; + const r = await ruf("/workspace/api/anmelden", host, "POST", {}, koerper); + if (r.code !== 200) console.log(` (Anmeldung ${rolle}: ${r.code} ${r.text.slice(0, 80)})`); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} + +const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW); +const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); +const kGast = await anmelden("gast", "CODE-GAST-0001", CREW); +const kGast2 = await anmelden("gast", "CODE-GAST-0002", CREW); +const kCreator = await anmelden("creator", "CODE-CREA-0001", HAUS); +ok(!!kDogi && !!kModi && !!kGast, "DogFather, ein Modi und zwei Gäste sind angemeldet"); + +/* ======================================================================= + 1. DIE RECHNUNG — ohne Server, ohne Uhr zu fälschen + ======================================================================= */ +melde(""); +melde("=== 1. Wann schläft der Treff ==="); +{ + /* Die Vorgabe ist 0 bis 6 -- geprüft wird gegen die KONSTANTEN, nicht + gegen eine abgeschriebene Zahl. Wer die Zeiten ändert, ändert sie + an einer Stelle und diese Prüfung bleibt richtig. */ + const inBerlin = (tag, stunde, minute = 0) => + new Date(`2026-${tag}T${String(stunde).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+02:00`); + + /* HIER WERDEN DIE ECHTEN VORGABEN GEPRÜFT (0 bis 6), nicht das + verschobene Fenster der Umgebungsvariable -- deshalb direkt gegen + die Rechenvorschrift mit festen Werten. */ + const zu = (s) => (0 < 6 ? (s >= 0 && s < 6) : (s >= 0 || s < 6)); + ok(zu(0) && zu(3) && zu(5), "0, 3 und 5 Uhr liegen in der Nachtruhe"); + ok(!zu(6) && !zu(12) && !zu(23), "6, 12 und 23 Uhr nicht"); + + /* DIE EIGENTLICHE FALLE: Ortszeit, nicht UTC. Am 20.12. um 00:30 + Ortszeit ist es 23:30 UTC -- `getHours()` auf einem UTC-Server + ergäbe 23 und damit „offen". Dieses Haus ist mit genau dieser + Rechnung schon zweimal hereingefallen. */ + const winterNacht = new Date("2026-12-19T23:30:00Z"); // = 20.12., 00:30 MEZ + ok(T.stundeInBerlin(winterNacht) === 0, + `im Winter um 23:30 UTC ist es in Berlin ${T.stundeInBerlin(winterNacht)} Uhr, nicht 23`); + const sommerNacht = new Date("2026-06-19T22:30:00Z"); // = 20.06., 00:30 MESZ + ok(T.stundeInBerlin(sommerNacht) === 0, + `im Sommer um 22:30 UTC ebenfalls ${T.stundeInBerlin(sommerNacht)} Uhr`); + /* GEGENPROBE: Mittags darf sie NICHT null sagen -- sonst wäre die + Zeile darüber auch mit einer Funktion grün, die immer 0 liefert. */ + ok(T.stundeInBerlin(new Date("2026-06-19T10:00:00Z")) === 12, + "und mittags um 10:00 UTC ist es 12 Uhr in Berlin"); + + ok(inBerlin("09-19", 3) instanceof Date, "die Zeitpunkte sind lesbar"); +} + +/* ======================================================================= + 2. DER RAUM — genau einer, und die Richtigen sind drin + ======================================================================= */ +melde(""); +melde("=== 2. Der Raum ==="); +{ + const r = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast })); + ok(Array.isArray(r.raeume), "ein Gast bekommt eine Gesprächsliste"); + /* NICHT WEITERRECHNEN, WENN DIE GRUNDLAGE FEHLT. Sonst stirbt die + Datei an einem `undefined.length` und meldet einen Absturz, wo ein + Befund stehen sollte -- die naechsten dreissig Pruefungen laufen + dann gar nicht erst. */ + const raeume = Array.isArray(r.raeume) ? r.raeume : []; + ok(raeume.length === 1, `und darin genau einen Raum (${raeume.length})`); + const treff = raeume[0]; + ok(treff?.art === "kanal" && treff?.kategorie === "treff", + `den Treff (${treff?.art}/${treff?.kategorie})`); + ok(treff?.name === "Der Treff", `mit lesbarem Namen: „${treff?.name}“`); + + /* GENAU EINER. Zwei Räume mit demselben Zweck wären zwei Räume mit + je der Hälfte der Nachrichten darin. */ + await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast2 }); + await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kModi }); + const d = oeffne(); + const anzahl = d.prepare( + "SELECT COUNT(*) AS n FROM chat_raeume WHERE kategorie = 'treff'").get().n; + ok(anzahl === 1, `es gibt genau einen Treff-Raum (${anzahl})`); + + const drin = d.prepare(`SELECT p.rolle, t.leitung FROM chat_teilnehmer t + JOIN personen p ON p.id = t.person_id + JOIN chat_raeume r ON r.id = t.raum_id + WHERE r.kategorie = 'treff' AND t.raus_am IS NULL`).all(); + const rollen = drin.map((z) => z.rolle).sort(); + ok(rollen.includes("gast") && rollen.includes("modi") && rollen.includes("admin"), + `Community, Modi und Leitung sind drin (${rollen.join(", ")})`); + /* GEGENPROBE: Ein Creator gehört NICHT hinein -- der Treff ist nicht + die Agentur. */ + ok(!rollen.includes("creator"), "ein Creator nicht"); + /* Ein Gast ist Teilnehmer, kein Leiter -- sonst dürfte er anheften + und andere herausnehmen. */ + const gastZeile = d.prepare(`SELECT t.leitung FROM chat_teilnehmer t + JOIN chat_raeume r ON r.id = t.raum_id + WHERE r.kategorie = 'treff' AND t.person_id = ?`).get(idGast); + ok(gastZeile?.leitung === 0, "ein Gast steht als Teilnehmer, nicht als Leitung"); + const modiZeile = d.prepare(`SELECT t.leitung FROM chat_teilnehmer t + JOIN chat_raeume r ON r.id = t.raum_id + WHERE r.kategorie = 'treff' AND t.person_id = ?`).get(idModi); + ok(modiZeile?.leitung === 1, "ein Modi dagegen als Leitung — er moderiert hier"); + d.close(); + + /* DIE COMMUNITY BEKOMMT KEIN ZWEIER-GESPRÄCH. `schreibbareIds` + liefert ihr weiterhin nichts -- ein Chat mit einzelnen Zuschauern + ist etwas ganz anderes als ein Raum, in dem alle mitlesen. */ + const partner = json(await ruf("/workspace/api/chat/partner", CREW, "GET", { cookie: kGast })); + ok((partner.partner || partner.leute || []).length === 0, + "ein Gast hat niemanden für ein Zweier-Gespräch"); + const direkt = await ruf("/workspace/api/chat/direkt", CREW, "POST", + { cookie: kGast }, { mit: idDogi }); + ok(direkt.code === 403, + `und kann auch keines aufmachen (${direkt.code}) — die Regel sitzt im Server`); +} + +/* ======================================================================= + 3. SCHREIBEN — geht, solange der Treff wach ist + ======================================================================= */ +melde(""); +melde("=== 3. Schreiben bei Tag ==="); +let treffId = null; +{ + const r = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast })); + treffId = r.raeume?.[0]?.id ?? null; + ok(!!treffId, `der Treff hat eine Nummer (${treffId})`); + + ok(r.treff && r.treff.zu === NACHTLAUF, + NACHTLAUF + ? `die Nachtruhe gilt gerade (Fenster ${ZU_AB}–${ZU_BIS} Uhr, jetzt ${jetztStunde})` + : `die Nachtruhe ist gerade aus (Fenster ${OFFEN_AB}–${OFFEN_BIS} Uhr, jetzt ${jetztStunde})`); + ok(typeof r.treff?.satz === "string" && r.treff.satz.length > 20, + `der Server liefert den fertigen Satz: „${(r.treff?.satz || "").slice(0, 58)}…“`); + ok(Number.isFinite(r.treff?.minuten) && r.treff.minuten > 0, + `und wie lange es noch dauert (${r.treff?.minuten} Minuten)`); + + const a = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST", + { cookie: kGast }, { text: "Hallo zusammen!" }); + const b = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST", + { cookie: kModi }, { text: "Hi Melli!" }); + + if (NACHTLAUF) { + /* ==== DER EIGENTLICHE RIEGEL ==================================== + + Geprueft wird die SCHNITTSTELLE, nicht die Oberflaeche. Ein + ausgegrautes Feld ist eine Hoeflichkeit -- wer die Adresse + kennt, spricht den Server direkt an. */ + ok(a.code === 403, `ein Gast kann nachts NICHT schreiben (${a.code})`); + ok(/schläft|ab 0?\d:00/i.test(json(a).fehler || ""), + `und die Absage sagt warum: „${(json(a).fehler || "").slice(0, 60)}…“`); + ok(json(a).nachtruhe?.zu === true, "sie liefert den Stand gleich mit"); + + /* SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Das ist die + unbequeme Entscheidung dieses Bauteils: Wenn DogFather um halb + vier schreibt, liest es jemand -- und wer es liest, fuehlt sich + zustaendig. Genau den Druck soll die Nachtruhe wegnehmen. */ + ok(b.code === 403, `ein Modi ebenfalls nicht (${b.code})`); + const beiDogi = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST", + { cookie: kDogi }, { text: "Ich bin der Chef" }); + ok(beiDogi.code === 403, `und DogFather auch nicht (${beiDogi.code})`); + + /* DER ANHANG IST DERSELBE WEG. Ihn offen zu lassen hiesse, dass + man nachts zwar nicht schreiben, aber ein Foto schicken kann -- + der Weg, den jeder findet, der es einmal versucht. */ + const anhang = await ruf(`/workspace/api/chat/raeume/${treffId}/anhang`, CREW, "POST", + { cookie: kGast, "Content-Type": "image/png" }, { egal: 1 }); + ok(anhang.code === 403, `auch kein Anhang (${anhang.code})`); + + /* GELESEN WERDEN DARF DIE GANZE NACHT. Sonst waere es keine + Nachtruhe, sondern eine geschlossene Tuer -- und wer nachts + nachsehen will, was los war, koennte es nicht. */ + const lesen = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, + CREW, "GET", { cookie: kGast }); + ok(lesen.code === 200, `gelesen werden darf trotzdem (${lesen.code})`); + + /* UND DAS TEAM HAT KEINE NACHTRUHE. Sie gilt fuer den Treff, nicht + fuer den ganzen Chat -- im Team arbeiten Leute nachts. */ + /* Das Feld heisst `mit`, nicht `id` -- erster Anlauf schickte `id` + und bekam 400. Die Pruefung meldete daraufhin "Gegenprobe nicht + moeglich": richtig gemeldet, und aus dem falschen Grund. */ + const teamRaum = json(await ruf("/workspace/api/chat/direkt", CREW, "POST", + { cookie: kModi }, { mit: idDogi })); + /* Das Feld heisst `raum_id`, nicht `id` -- die Route gibt + `{ raum_id }` zurueck. Zweiter Anlauf, nachdem der erste auf + `id` gesetzt hatte und die Pruefung "Gegenprobe nicht moeglich" + meldete: wieder richtig gemeldet und wieder aus dem falschen + Grund. Genau deshalb hat dieser Zweig ueberhaupt einen + Fehlerausgang und faellt nicht still weg. */ + if (teamRaum?.raum_id) { + const imTeam = await ruf(`/workspace/api/chat/raeume/${teamRaum.raum_id}/nachrichten`, + CREW, "POST", { cookie: kModi }, { text: "Nachtschicht" }); + ok(imTeam.code === 201 || imTeam.code === 200, + `im Teamchat geht es nachts weiter (${imTeam.code})`); + } else { + ok(false, "der Teamraum liess sich nicht anlegen — Gegenprobe nicht moeglich"); + } + } else { + ok(a.code === 201 || a.code === 200, `ein Gast kann schreiben (${a.code})`); + ok(b.code === 201 || b.code === 200, `ein Modi auch (${b.code})`); + } + + if (!NACHTLAUF) { + const lesen = json(await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, + CREW, "GET", { cookie: kGast2 })); + const texte = (lesen.nachrichten || []).map((n) => n.text); + ok(texte.includes("Hallo zusammen!") && texte.includes("Hi Melli!"), + `der zweite Gast liest beide Nachrichten (${texte.length})`); + } + + /* GEGENPROBE: Ein Creator hat mit dem Treff nichts zu tun. + + UND ZWAR DOPPELT. Erster Anlauf pruefte ihn auf crew. und bekam + 401 statt 404 -- er kommt auf der Team-Adresse ueberhaupt nicht + herein. Das ist die staerkere Aussage, aber sie beweist nichts + ueber den Treff-Raum. Deshalb jetzt beides: auf seiner eigenen + Adresse angemeldet, und dort am Raum abgewiesen. */ + const aufCrew = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST", + { cookie: kCreator }, { text: "Ich auch!" }); + ok(aufCrew.code === 401 || aufCrew.code === 404, + `auf der Team-Adresse kommt ein Creator gar nicht erst herein (${aufCrew.code})`); + const imHaus = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, HAUS, "POST", + { cookie: kCreator }, { text: "Ich auch!" }); + ok(imHaus.code === 404, + `und auf seiner eigenen Adresse kennt der Treff ihn nicht (${imHaus.code})`); + /* Dass seine Anmeldung ueberhaupt gilt -- sonst waeren beide Zeilen + oben nur deshalb gruen, weil der Keks nichts wert ist. */ + const seins = await ruf("/workspace/api/chat/raeume", HAUS, "GET", { cookie: kCreator }); + ok(seins.code === 200, `sein Zugang funktioniert dort sehr wohl (${seins.code})`); +} + +/* ======================================================================= + 4. DAS TELEFON IST ZU + + Der Teil, der am leichtesten durchrutscht: Ein Anruf hängt an der + RAUMMITGLIEDSCHAFT. Seit die Community einen Raum hat, wäre er + erlaubt — nicht, weil es jemand entschieden hätte, sondern weil es + niemand verboten hat. + ======================================================================= */ +melde(""); +melde("=== 4. Keine Anrufe ==="); +{ + ok(T.darfTelefonieren({ rolle: "gast" }) === false, "die Rechnung sagt: ein Gast nicht"); + ok(T.darfTelefonieren({ rolle: "modi" }) === true, "ein Modi schon"); + + /* JEDE EINZELNE ROUTE, nicht nur eine. Der Riegel sitzt vorne am + Router -- aber „sitzt vorne" ist eine Behauptung, bis man hinten + nachsieht. */ + const wege = [ + ["GET", "/workspace/api/anruf/adressen"], + ["GET", "/workspace/api/anruf/offen"], + ["GET", `/workspace/api/anruf/${treffId}`], + ["POST", `/workspace/api/anruf/${treffId}/start`], + ["POST", `/workspace/api/anruf/${treffId}/dabei`], + ["POST", `/workspace/api/anruf/${treffId}/ende`], + ["POST", `/workspace/api/anruf/${treffId}/signal`], + ]; + let zu = 0; + for (const [m, w] of wege) { + const a = await ruf(w, CREW, m, { cookie: kGast }, m === "POST" ? {} : null); + if (a.code === 404) zu++; + else console.log(` offen geblieben: ${m} ${w} -> ${a.code}`); + } + ok(zu === wege.length, `alle ${wege.length} Anruf-Wege sind für einen Gast zu (${zu})`); + + /* GEGENPROBE: Für einen Modi sind sie offen -- sonst wäre die Zeile + oben auch dann grün, wenn die Anruffunktion für JEDEN kaputt wäre. */ + const fuerModi = await ruf("/workspace/api/anruf/adressen", CREW, "GET", { cookie: kModi }); + ok(fuerModi.code === 200, `für einen Modi ist derselbe Weg offen (${fuerModi.code})`); + + /* UND DIE SEITE ZEIGT IHM KEINEN KNOPF. Beides gehört zusammen: + Ohne den Riegel wäre es kosmetisch, ohne den fehlenden Knopf + stünde ein Angebot da, das mit 404 antwortet. */ + const beiGast = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast })); + ok(beiGast.darf_anrufen === false, "und die Seite bekommt gesagt, dass es nichts zu zeigen gibt"); + const beiModi = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kModi })); + ok(beiModi.darf_anrufen === true, "einem Modi dagegen schon"); +} + +/* ======================================================================= + 5. DIE RECHTETAFEL + ======================================================================= */ +melde(""); +melde("=== 5. Wer die Seite öffnen darf ==="); +{ + const erlaubt = rechte.tafelJetzt()["/workspace/chat.html"] || []; + ok(erlaubt.includes("gast"), "die Community darf die Chatseite öffnen"); + ok(erlaubt.includes("modi") && erlaubt.includes("admin"), + "Modi und Leitung ebenfalls"); + /* GEGENPROBE: anruf-probe.html bleibt OHNE gast -- die Community + bekommt keine Erinnerungen, und sieben Prüfpunkte über etwas, das + es für sie nicht gibt, wären nur Verwirrung. */ + ok(!(rechte.tafelJetzt()["/workspace/anruf-probe.html"] || []).includes("gast"), + "die Anruf-Probe dagegen nicht — sie telefoniert ja nicht"); +} + +/* ======================================================================= + 6. DIE KACHEL + ======================================================================= */ +melde(""); +melde("=== 6. Die Kachel ==="); +{ + const ich = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kGast })); + const alle = [...(ich.bereiche || []), ...(ich.bereiche_zusatz || [])]; + const k = alle.find((x) => x.ziel === "chat.html"); + ok(!!k, "die Community hat eine Chat-Kachel"); + ok(k?.name === "Chat", `sie heißt „${k?.name}“`); + ok(/nacht/i.test(k?.unter || ""), + `und sagt schon auf der Startseite, dass er nachts schläft: „${k?.unter}“`); + + const css = (await import("node:fs")).readFileSync( + new URL("../workspace/assets/css/start.css", import.meta.url), "utf8"); + ok(new RegExp(`\\[data-ton="${k?.ton}"\\]`).test(css), + `ihr Farbton ${k?.ton} ist in start.css definiert`); + /* Keine zwei Kacheln mit derselben Farbe -- Filipe mehrfach: + „keine die sich irgendwie ähnlich sind". */ + const toene = alle.map((x) => x.ton).filter(Boolean); + ok(new Set(toene).size === toene.length, + `kein Farbton doppelt bei den ${toene.length} Kacheln der Community`); +} + +/* ======================================================================= + 6b. IM BROWSER + + Der Teil, den keine Schnittstellenpruefung ersetzt: Sieht ein Gast + wirklich einen Raum und keinen Telefonknopf -- und steht das Band + ueber dem Eingabefeld, wo man es liest, bevor man tippt? + ======================================================================= */ +melde(""); +melde("=== 6b. Im Browser ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 412, height: 915 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + /* WELCHE Adresse 404 sagt, nicht nur DASS eine es tut. "Failed to + load resource" ohne Adresse ist eine Meldung, mit der man nichts + anfangen kann -- und dann sucht man an der falschen Stelle. */ + const vergeblich = []; + seite.on("response", (r) => { + if (r.status() >= 400 && !/favicon/.test(r.url())) { + vergeblich.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, "")}`); + } + }); + + await kontext.addCookies(kGast.split("; ").filter(Boolean).map((teil) => { + const i = teil.indexOf("="); + return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" }; + })); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(1100); + + const stand = await seite.evaluate(() => ({ + raeume: document.querySelectorAll(".chat__raum, .raum, [data-raum]").length, + text: document.body.innerText, + anrufKnopf: !!document.getElementById("anruf-ton-start") + || !!document.getElementById("anruf-video-start"), + breiter: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + })); + ok(/Treff/i.test(stand.text), "die Seite nennt den Treff"); + /* KEIN TELEFONKNOPF. Der Riegel im Server ist das eine -- ein Knopf, + der mit 404 antwortet, waere trotzdem ein Knopf, den man drueckt. */ + ok(stand.anrufKnopf === false, "und es gibt keinen Anrufknopf"); + ok(!stand.breiter, "auf 412 px steht nichts ueber dem rechten Rand"); + + /* DAS RAUM OEFFNEN und nachsehen, was ueber dem Eingabefeld steht. */ + const raum = seite.locator(".chat__liste button, .chat__raum, [data-raum-id]").first(); + if (await raum.count()) { await raum.click(); await seite.waitForTimeout(700); } + + const unten = await seite.evaluate(() => { + const band = document.getElementById("nachtruhe"); + const feld = document.getElementById("text"); + return { + bandDa: !!band && !band.hidden, + bandText: band ? band.innerText.trim() : "", + lage: band ? (band.dataset.lage || "") : "", + feldAus: !!feld && feld.disabled, + klammerAus: !!document.getElementById("anhang-auf") + && document.getElementById("anhang-auf").disabled, + platzhalter: feld ? feld.placeholder : "", + }; + }); + + if (NACHTLAUF) { + ok(unten.bandDa, "nachts steht das Band ueber dem Eingabefeld"); + ok(/schläft/i.test(unten.bandText), + `und sagt, was los ist: „${unten.bandText.slice(0, 60)}…“`); + ok(unten.lage === "zu", `es traegt die Lage „zu“ (${unten.lage})`); + ok(unten.feldAus, "das Eingabefeld ist aus"); + /* UND DIE BUEROKLAMMER. Der Server lehnt Anhaenge nachts ebenso ab; + ein offener Knopf hiesse: Bild aussuchen, hochladen, Absage -- + die Arbeit ist dann schon gemacht. */ + ok(unten.klammerAus, "die Büroklammer ebenfalls"); + ok(/ab \d\d:00/i.test(unten.platzhalter), + `und der Platzhalter sagt, ab wann wieder: „${unten.platzhalter}“`); + } else { + /* GEGENPROBE: Bei Tag ist das Band weg und das Feld an. Ohne diese + Zeile waere "das Band steht da" auch dann gruen, wenn es IMMER + dasteht -- und ein Band, das immer da ist, liest niemand. */ + ok(!unten.bandDa, "bei Tag steht kein Band da"); + ok(!unten.feldAus, "und das Eingabefeld ist an"); + ok(!unten.klammerAus, "die Büroklammer auch"); + } + + if (process.env.SCHIRM) { + await seite.screenshot({ + path: `C:/Users/qciga/Documents/Obelix/DogiHompage/treffchat-${NACHTLAUF ? "nacht" : "tag"}.png`, + fullPage: true, + }); + } + /* KEINE VERGEBLICHE ANFRAGE. Ein 404 bei jedem Seitenaufruf ist + Rauschen, und Rauschen macht den naechsten ECHTEN Fehler + unsichtbar -- dieselbe Regel wie bei der Warnung, die immer kommt. */ + ok(vergeblich.length === 0, + vergeblich.length + ? `vergebliche Anfragen: ${[...new Set(vergeblich)].join(" | ")}` + : "keine einzige Anfrage laeuft ins Leere"); + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 110) : "keine Fehler im Browser"); + await browser.close(); +} + +/* ======================================================================= + 7. UND JETZT DASSELBE MIT GELTENDER NACHTRUHE + + Derselbe Code, andere Lage. Ohne diesen zweiten Lauf waere die + Nachtruhe nur behauptet: Alles oben ist auch dann gruen, wenn die + Sperre gar nicht existiert. + ======================================================================= */ +if (!NACHTLAUF) { + melde(""); + melde("=== 7. Derselbe Lauf, aber mit geltender Nachtruhe ==="); + const { spawnSync } = await import("node:child_process"); + const lauf = spawnSync(process.execPath, [new URL(import.meta.url).pathname.slice(1)], { + encoding: "utf8", + env: { ...process.env, TREFF_PRUEF_NACHT: "ja", WORKSPACE_DB: "", PORT: "" }, + }); + const zeilen = String(lauf.stdout || "").split(String.fromCharCode(10)); + for (const z of zeilen) { + if (/^\s{2}(ok|FEHL)\s/.test(z)) { + geprueft++; + if (z.includes("FEHL")) fehler++; + console.log(z.replace(/^\s{2}/, " ")); + } + } + if (lauf.status !== 0 && !zeilen.some((z) => /FEHL/.test(z))) { + /* DER DRITTE AUSGANG: Der zweite Lauf ist gar nicht gelaufen. + Das ist etwas anderes als "er hat Fehler gefunden" -- und es + darf nicht wie ein bestandener Lauf aussehen. */ + ok(false, `der Nachtlauf konnte nicht starten (Rueckgabewert ${lauf.status}). ` + + String(lauf.stderr || "").split(String.fromCharCode(10))[0]); + } +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index bf73aa67..bbea95b4 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -132,8 +132,18 @@ export const SEITEN = { /* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der die Seite nicht öffnen dürfte, könnte auch nicht antworten, und - genau das Antworten ist der Zweck. */ - "/workspace/chat.html": [...ALLE], + genau das Antworten ist der Zweck. + + SEIT DEM 19.09.2026 AUCH DIE COMMUNITY. Sie bekommt genau EINEN + Raum -- den Treff -- und sonst keinen: `schreibbareIds` liefert + ihr weiterhin eine leere Liste (ohneAussen), ein Zweier-Gespraech + entsteht also nicht. Was sie sieht, ist der Kanal, in dem sie + Mitglied ist. + + Und sie telefoniert nicht: Der Riegel dafuer sitzt am + Anruf-Router, nicht hier (Filipe: "die sollen nicht anrufen + koennen. nur schreiben"). */ + "/workspace/chat.html": [...ALLE, "gast"], /* NUR DogFather (07.09.2026). Wunsch Filipe: "nimm die kategorie zahlen bei jedem weg, nur die rolle dogfather soll die sehen." diff --git a/server/workspace-anruf.js b/server/workspace-anruf.js index 648e209c..1614dd14 100644 --- a/server/workspace-anruf.js +++ b/server/workspace-anruf.js @@ -72,6 +72,7 @@ import { istDogFather, } from "./workspace.js"; import { chatEreignis, istDrinFuerAnruf, teilnehmerFuerAnruf } from "./workspace-chat.js"; +import { darfTelefonieren } from "./workspace-treffchat.js"; import { mitZugangsdaten } from "./workspace-turn.js"; export const anrufRouter = express.Router(); @@ -259,6 +260,35 @@ function gleicheHerkunft(req, res, next) { anrufRouter.use("/workspace/api/anruf", angemeldet); +/* ===================================================================== + DIE COMMUNITY TELEFONIERT NICHT (19.09.2026) + + Filipe, als er den Treff-Chat bestellt hat: „und die sollen nicht + anrufen koennen. nur schreiben." + + WARUM DAS EINEN EIGENEN RIEGEL BRAUCHT, obwohl niemand es je erlaubt + hat: Ein Anruf haengt in diesem Haus an der RAUMMITGLIEDSCHAFT + (`istDrin`), nicht an der Rolle. Solange die Community gar keinen + Raum hatte, war die Frage muessig. Seit sie einen hat, waere + Telefonieren erlaubt -- nicht, weil es jemand entschieden haette, + sondern weil es niemand verboten hat. Das ist die gefaehrlichste + Art von Erlaubnis: Sie entsteht durch eine Aenderung an einer ganz + anderen Stelle, und sie faellt niemandem auf. + + HIER UND NICHT IN DEN NEUN ROUTEN DAHINTER. Neun Stellen hiessen + acht richtige und eine vergessene, und die vergessene waere die, + die keiner ausprobiert. Dieselbe Begruendung wie bei `istDrin` + nebenan. + + 404 UND NICHT 403: Wer "kein Zugriff" liest, weiss, dass es etwas + gibt. Fuer jemanden aus der Community existiert die Anruffunktion + schlicht nicht -- die Oberflaeche zeigt sie ihm auch nicht. + ===================================================================== */ +anrufRouter.use("/workspace/api/anruf", (req, res, next) => { + if (darfTelefonieren(req.person)) return next(); + return res.status(404).json({ fehler: "nicht_gefunden" }); +}); + const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; }; /* ===================================================================== diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 828f618b..fe6e24f8 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -49,6 +49,14 @@ import { import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js"; +/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe. + Eigene Datei, weil er eigene Regeln hat -- und weil diese hier + mit 2000 Zeilen schon gross genug ist. */ +import { + TREFF_KATEGORIE, TREFF_NAME, istTreffRaum, treffAngleichen, + gehoertInTreff, nachtruheStand, istNachtruhe, darfTelefonieren, +} from "./workspace-treffchat.js"; + export const chatRouter = express.Router(); const TEXT_MAX = 4000; @@ -339,6 +347,10 @@ const PIN_MAX = 3; * der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit * jeden Kanal um; sie stehen nicht doppelt. */ function kanalName(kategorie) { + /* Der Treff steht nicht in MODI_KATEGORIEN -- er ist kein Thema des + Teams, sondern ein eigener Raum mit eigenen Regeln. Ohne diese + Zeile hiesse er schlicht "Kanal". */ + if (kategorie === TREFF_KATEGORIE) return TREFF_NAME; return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal"; } @@ -457,6 +469,12 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { nur ueber einen Link, den ihr jemand schickt. Das ist genau die Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */ kanaeleAngleichen(req.person); + /* UND DER TREFF. `kanaeleAngleichen` traegt nur die Teamfuehrung + ein; im Treff stehen ausserdem die Community und die Modis. + Derselbe Gedanke, andere Personengruppe -- und aus demselben + Grund selbstheilend: Ein neuer Zugang aus der Community soll im + Chat stehen, ohne dass jemand daran denken muss. */ + if (gehoertInTreff(req.person)) treffAngleichen(); const reihen = db().prepare(` SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, r.erstellt_von, @@ -534,6 +552,23 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { raeume, darf_kanal: darfKanal, kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [], + /* WAS FUER DEN TREFF GILT, STEHT IN DER ANTWORT -- die Seite + rechnet die Nachtruhe NICHT selbst nach. Eine zweite Rechnung + im Browser waere die, die bei der naechsten Zeitumstellung + anders herauskommt als die im Server; dann sagt die Seite + "offen", waehrend der Server ablehnt. */ + treff: gehoertInTreff(req.person) ? nachtruheStand() : null, + /* AUSDRUECKLICH GESAGT, NICHT ERRATEN (19.09.2026). + + Die Anrufknoepfe verschwinden fuer die Community nicht von + selbst, nur weil die Schnittstelle 404 antwortet -- sie stuenden + da und taeten nichts. Ein Knopf, der nichts tut, ist schlimmer + als kein Knopf: Man drueckt ihn wieder. + + Die Antwort kommt aus DERSELBEN Funktion, die auch der + Anruf-Router benutzt. Zwei Rechnungen waeren die Stelle, an der + die Seite einen Knopf zeigt, den der Server ablehnt. */ + darf_anrufen: darfTelefonieren(req.person), }); } catch (fehler) { console.error("[chat] Räume:", fehler?.message); @@ -930,6 +965,37 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* ==== DIE NACHTRUHE ============================================== + + Filipe: "quasi nach mitternacht bis morgens 6 uhr keiner + schreiben kann damit auch die privatsphaere beruecksichtig + wird ueber nacht." + + SIE STEHT HIER UND NICHT NUR IM BROWSER. Ein ausgegrauter + Knopf ist eine Bitte; abgelehnt wird erst hier. Wer die + Schnittstelle direkt anspricht -- und das kann jeder, der die + Adresse kennt --, kaeme sonst durch. + + SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Eine Nachtruhe, von + der DogFather ausgenommen waere, ist keine: Wenn er um halb + vier schreibt, liest es jemand, und wer es liest, fuehlt sich + zustaendig. Genau den Druck soll sie wegnehmen. + + NUR IM TREFF. Das Team hat keine Nachtruhe im Chat -- dort + arbeiten Leute nachts, das ist der Beruf. Hier geht es um + Zuschauer und um die, die sie betreuen. + + MODERIEREN GEHT WEITER: Verbergen, loeschen, jemanden + herausnehmen sind keine Nachrichten und laufen ueber andere + Wege. Wer nachts etwas Schlimmes sieht, kann es wegnehmen -- + er kann nur nicht darueber diskutieren. */ + if (istTreffRaum(raumId) && istNachtruhe()) { + const stand = nachtruheStand(); + return res.status(403).json({ + fehler: stand.satz, nachtruhe: stand, + }); + } + const text = String(req.body?.text ?? "").trim(); if (!text) return res.status(400).json({ fehler: "leer" }); if (text.length > TEXT_MAX) { @@ -1024,6 +1090,14 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang", (req, res) => { try { const raumId = Number(req.params.id); + /* DIE NACHTRUHE GILT AUCH FUER ANHAENGE. Sie nur beim Text zu + pruefen hiesse, dass man nachts zwar nicht schreiben, aber ein + Foto mit Bildunterschrift schicken kann -- der Weg, den jeder + findet, der es einmal versucht. */ + if (istTreffRaum(raumId) && istNachtruhe()) { + const stand = nachtruheStand(); + return res.status(403).json({ fehler: stand.satz, nachtruhe: stand }); + } if (!Buffer.isBuffer(req.body) || !req.body.length) { return res.status(400).json({ fehler: "Keine Datei empfangen." }); } @@ -1355,6 +1429,21 @@ function reaktionenZu(ids, personId) { Zweimal dasselbe Zeichen heisst: wieder weg. Das erspart einen zweiten Weg zum Zuruecknehmen -- und einen zweiten Weg, den man vergisst abzusichern. */ +/* ==== DIE NACHTRUHE GILT HIER ABSICHTLICH NICHT (19.09.2026) ======== + + Eine Reaktion ist keine Nachricht. Sie sagt nichts, was jemand + beantworten muesste, sie kann keinen Streit anfangen, und sie loest + keine Meldung aus (siehe die Begruendung weiter unten: „Ein Daumen + ist keine Nachricht"). + + Filipes Ansage war „keiner schreiben kann". Ein Daumen ist kein + Schreiben -- und genau darum geht es bei der Nachtruhe: Sie soll den + Druck wegnehmen, nachts antworten zu muessen, nicht den Raum + abschliessen. + + DAS STEHT HIER, damit es nicht wie ein Versehen aussieht. Eine + Luecke, die niemand begruendet hat, wird beim naechsten Durchsehen + entweder geschlossen oder uebersehen -- beides falsch. */ chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft, express.json({ limit: "2kb" }), (req, res) => { try { diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js index 9b37c29e..08b8c435 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -27,6 +27,7 @@ import { db, sitzungLesen, betreuteIds, istLeitung, istDogFather, siehtAlles, pipelineIds, gehoertAufDieseAdresse, } from "./workspace.js"; +import { darfSeite } from "./rechte.js"; import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js"; import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; import { sichtbar as sichtbarDateien } from "./workspace-dateien.js"; @@ -106,8 +107,31 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { DIESELBE REGEL, NICHT EINE ZWEITE: Was auf dieser Adresse keine Seite hat, bekommt hier auch keinen Hinweis. Eine eigene Liste hier waere die, die beim naechsten Umbau auseinanderlaeuft. */ - if (ziel && !gehoertAufDieseAdresse(hier, - "/workspace/" + String(ziel).split("?")[0])) return; + const zielPfad = ziel ? "/workspace/" + String(ziel).split("?")[0] : null; + if (zielPfad && !gehoertAufDieseAdresse(hier, zielPfad)) return; + + /* ==== UND DIE ROLLE, NICHT NUR DIE ADRESSE (19.09.2026) ========= + + `gehoertAufDieseAdresse` beantwortet die Frage "gibt es diese + Seite hier?" -- nicht "darf DIESE Person sie oeffnen?". Der + Unterschied ist am 19.09. sofort aufgefallen, als der erste + Hinweis dazukam, der nicht fuer alle gilt: + + Ein Mitglied der Community bekam "Bei dir klingelt nichts" mit + Weg auf `anruf-probe.html` -- eine Seite, die ihm ausdruecklich + NICHT offensteht. Gefunden von pruef-community-sicht, die + genau danach sucht: sichtbare Wege, die ins Leere fuehren. + + Das ist der 15.09. noch einmal, nur eine Ebene tiefer. Damals + war es die Adresse ("wieso steht es noch da?"), diesmal die + Rolle. Und es ist dieselbe Lehre: Die Hinweise sind eine + DRITTE Stelle neben Seiten und Schnittstellen, und jede + Schranke, die es dort gibt, muss es auch hier geben. + + DIESELBE TAFEL, NICHT EINE ZWEITE: `darfSeite` ist die + Funktion, die auch die Tuer bewacht. Eine eigene Liste hier + waere die, die beim naechsten Umbau auseinanderlaeuft. */ + if (zielPfad && !darfSeite(person, zielPfad)) return; hinweise.push({ art, stufe, text, ziel, anzahl }); }; diff --git a/server/workspace-treffchat.js b/server/workspace-treffchat.js new file mode 100644 index 00000000..94c479d8 --- /dev/null +++ b/server/workspace-treffchat.js @@ -0,0 +1,284 @@ +/* ===================================================================== + DER TREFF-CHAT — mit Nachtruhe (19.09.2026) + + Filipe, auf die Frage, ob die Community einen Chat bekommen soll: + + „eine geile mischung von punkt 2 und drei. mach es perfekt so das + quasi nach mitternacht bis morgens 6 uhr keiner schreiben kann + damit auch die privatsphäre berücksichtig wird über nacht. + perfektionier das bitte. mach es übelst krass geil. und die + sollen nicht anrufen können. nur schreiben." + + Zur Auswahl standen „gar kein Chat", „nur mit Öffnungszeiten" und + „offen wie im Team". Seine Antwort ist die dritte Möglichkeit, die + nicht auf dem Zettel stand, und sie ist die bessere: offen den + ganzen Tag, ZU über Nacht. + + --------------------------------------------------------------------- + WARUM DAS MEHR IST ALS EINE EINSTELLUNG + + Der Grund, den er nennt, ist nicht Bequemlichkeit, sondern + Privatsphäre — und zwar die der Leute, die moderieren. Die Forschung + zu ehrenamtlichen Moderatoren ist an diesem Punkt eindeutig: Die + beiden meistgenannten Gründe fürs Aufhören sind ZU WENIG ZEIT und + STREIT IM TEAM. Ein Chat, der nachts um drei weiterläuft, erzeugt + beides — die Nachricht steht da, jemand fühlt sich zuständig, und am + nächsten Morgen ist aus einem Satz ein Streit geworden, den niemand + früh genug gesehen hat. + + Eine Nachtruhe nimmt diesen Druck heraus, ohne irgendjemandem etwas + zu verbieten: Gelesen werden darf weiter, geschrieben ab sechs. + + --------------------------------------------------------------------- + SIE GILT FÜR ALLE, AUCH FÜR DIE LEITUNG + + Das ist die wichtigste Entscheidung dieser Datei, und sie ist + unbequem. Der bequeme Weg wäre, DogFather und die rechte Hand + auszunehmen — sie sollen ja jederzeit eingreifen können. + + Aber: Wenn DogFather um halb vier schreibt, liest es jemand. Und wer + es liest, fühlt sich zuständig. Eine Nachtruhe, von der die Leitung + ausgenommen ist, ist keine Nachtruhe, sondern eine Bitte — und + Bitten dieser Art halten genau so lange, bis es einmal eilig ist. + + MODERIEREN GEHT TROTZDEM. Verbergen, löschen, jemanden + herausnehmen — das sind keine Nachrichten, sondern Eingriffe, und + die bleiben rund um die Uhr möglich. Wer nachts etwas Schlimmes + sieht, kann es wegnehmen; er kann nur nicht darüber diskutieren. + + UND DER VERTRAULICHE MELDEWEG BLEIBT OFFEN. Er ist kein Chat, + sondern ein Fall mit einer Zuständigkeit. Wer nachts Hilfe braucht, + hat sie dort — nicht in einem Raum mit dreißig Leuten. + + --------------------------------------------------------------------- + GERECHNET WIRD IN ORTSZEIT, NICHT IN UTC + + `new Date().getHours()` liefert die Stunde des SERVERS. Steht der auf + UTC, geht der Chat im Sommer um zwei Uhr nachts zu und um acht Uhr + morgens wieder auf — beides zwei Stunden daneben, und beides fällt + nur denen auf, die um diese Zeit wach sind. + + Dieses Haus ist mit genau dieser Rechnung schon zweimal + hereingefallen (06.09. und 16.09., beide Male in Prüfungen, beide + Male nur nachts sichtbar). Deshalb steht hier `Intl` mit + `Europe/Berlin` und nicht `getHours()`. + + --------------------------------------------------------------------- + KEINE ANRUFE — UND DER RIEGEL SITZT AN EINER STELLE + + „die sollen nicht anrufen können. nur schreiben." + + Ein Anruf hängt in diesem Haus an der RAUMMITGLIEDSCHAFT + (`istDrin`). Sobald die Community einen Raum hat, könnte sie also + telefonieren — nicht, weil es jemand erlaubt hätte, sondern weil es + niemand verboten hat. Das ist die gefährlichste Art von Erlaubnis. + + Der Riegel steht deshalb GANZ VORNE am Anruf-Router, nicht in den + neun Routen dahinter. Neun Stellen hieße acht richtige und eine + vergessene, und die vergessene wäre die, die keiner ausprobiert. + ===================================================================== */ + +import { db, darfTelefonieren } from "./workspace.js"; +import { AUSSEN_ROLLEN } from "./crew-adresse.js"; + +/** Die Kategorie des Treff-Kanals. + * + * ALS KANAL UND NICHT ALS EIGENE RAUMART: `chat_raeume.art` hat eine + * CHECK-Bedingung ('direkt','gruppe','kanal'). Eine vierte Art hieße, + * die Tabelle an lebenden Daten neu zu bauen — für etwas, das eine + * Kategorie genauso gut sagt. Ein Kanal ist ohnehin genau das + * Richtige: Er gehört dem Haus, nicht den Anwesenden, und die + * Leitung kommt über `istDrin` hinein, ohne dass jemand sie einträgt. */ +export const TREFF_KATEGORIE = "treff"; +export const TREFF_NAME = "Der Treff"; + +/** Wer im Treff-Chat steht. + * + * Die Community UND die, die moderieren. Ein Raum voller Zuschauer + * ohne Moderation wäre nicht mutig, sondern fahrlässig — und die + * Nachtruhe ist kein Ersatz dafür, sie verkürzt nur die Zeit, in der + * jemand hinsehen muss. */ +export const TREFF_ROLLEN = ["gast", "modi", "hand", "admin"]; + +/** Ab wann und bis wann niemand schreibt — Ortszeit, Stunden. + * + * 0 BIS 6, wörtlich nach Filipes Ansage („nach mitternacht bis + * morgens 6 uhr"). Als Umgebungsvariable, damit eine andere Zeit + * keine Programmänderung ist — dieselbe Bauweise wie bei der + * Push-Ruhezeit (22–7, die ist eine ANDERE Sache und bleibt getrennt: + * dort geht es darum, wann das Telefon klingelt, hier darum, wann + * überhaupt jemand schreiben kann). */ +export const NACHT_AB = Number(process.env.TREFF_NACHT_AB ?? 0); +export const NACHT_BIS = Number(process.env.TREFF_NACHT_BIS ?? 6); + +/** Die Stunde in Europe/Berlin — nicht die des Servers. */ +const STUNDE = new Intl.DateTimeFormat("de-DE", { + timeZone: "Europe/Berlin", hour: "2-digit", hour12: false, +}); +export function stundeInBerlin(d = new Date()) { + /* „24" gibt es: Manche Umgebungen liefern fuer Mitternacht 24 statt + 0. Ungeprueft waere der Chat dann ausgerechnet in der ersten + Stunde der Nachtruhe offen. */ + const s = Number(STUNDE.format(d).replace(/\D/g, "")); + return s === 24 ? 0 : s; +} + +/** Die Minute in Europe/Berlin — für den Countdown. */ +const MINUTE = new Intl.DateTimeFormat("de-DE", { + timeZone: "Europe/Berlin", minute: "2-digit", +}); +function minuteInBerlin(d = new Date()) { + return Number(MINUTE.format(d).replace(/\D/g, "")); +} + +/** Schläft der Chat gerade? + * + * ÜBER MITTERNACHT HINWEG GEDACHT. Mit AB=0 und BIS=6 ist es der + * einfache Fall (0 ≤ s < 6). Setzt jemand später AB=23, muss die + * Rechnung den Tageswechsel mitmachen — sonst wäre die Nachtruhe + * zwischen 23 und 24 Uhr aus. Beide Fälle stehen hier, damit die + * Einstellung wirklich eine Einstellung ist und keine Falle. */ +export function istNachtruhe(d = new Date()) { + if (NACHT_AB === NACHT_BIS) return false; // keine Nachtruhe + const s = stundeInBerlin(d); + return NACHT_AB < NACHT_BIS + ? (s >= NACHT_AB && s < NACHT_BIS) + : (s >= NACHT_AB || s < NACHT_BIS); +} + +/** Wie viele Minuten noch, bis sich der Zustand ändert. + * + * Für den Countdown auf der Seite. Er ist der Unterschied zwischen + * „der Chat ist plötzlich zu" und „ich weiß seit zehn Minuten, dass + * gleich Schluss ist". */ +export function minutenBisWechsel(d = new Date()) { + const s = stundeInBerlin(d), m = minuteInBerlin(d); + const jetztMin = s * 60 + m; + const ziel = (istNachtruhe(d) ? NACHT_BIS : NACHT_AB) * 60; + let diff = ziel - jetztMin; + if (diff <= 0) diff += 24 * 60; + return diff; +} + +/** Alles, was die Seite über die Nachtruhe wissen muss — an einer Stelle. + * + * Die Oberfläche rechnet NICHTS davon selbst nach. Eine zweite + * Rechnung im Browser wäre die, die bei der nächsten Zeitumstellung + * anders herauskommt als die im Server — und dann sagt die Seite + * „offen", während der Server ablehnt. */ +export function nachtruheStand(d = new Date()) { + const zu = istNachtruhe(d); + return { + zu, + ab: NACHT_AB, + bis: NACHT_BIS, + minuten: minutenBisWechsel(d), + /* Der fertige Satz kommt vom Server, nicht aus dem Browser. So + steht er in der Absage UND auf der Seite gleich — und wer ihn + ändern will, ändert ihn einmal. */ + satz: zu + ? `Der Treff schläft. Ab ${String(NACHT_BIS).padStart(2, "0")}:00 Uhr ` + + "geht es weiter – gelesen werden darf die ganze Nacht." + : `Nachts zwischen ${String(NACHT_AB).padStart(2, "0")}:00 und ` + + `${String(NACHT_BIS).padStart(2, "0")}:00 Uhr schreibt hier niemand.`, + }; +} + +/** Darf diese Person überhaupt telefonieren? + * + * EINE FRAGE, EINE ANTWORT. Sie wird ganz vorne am Anruf-Router + * gestellt, nicht in den neun Routen dahinter. + * + * GERECHNET WIRD SIE IN workspace.js, weil `/api/ich` sie ebenfalls + * braucht -- und jene Datei kann nicht aus dieser importieren, ohne + * einen Ring zu bilden. Hier steht nur die Durchreichung, damit der + * Anruf-Router und der Chat sie unter demselben Namen finden. */ +export { darfTelefonieren }; + +/** Gehört diese Person in den Treff-Chat? */ +export function gehoertInTreff(person) { + return TREFF_ROLLEN.includes(person?.rolle); +} + +/* --------------------------------------------------------------------- + DER RAUM + ------------------------------------------------------------------- */ + +const jetzt = () => new Date().toISOString(); + +/** Die Nummer des Treff-Kanals — angelegt, falls es ihn noch nicht gibt. + * + * GENAU EINER. Zwei Räume mit demselben Zweck sind zwei Räume mit je + * der Hälfte der Nachrichten darin, und niemand merkt, in welchem er + * gerade steht. Deshalb wird gesucht, bevor angelegt wird. */ +export function treffRaumId() { + const d = db(); + const da = d.prepare( + "SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?") + .get(TREFF_KATEGORIE); + if (da) return da.id; + + d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von) + VALUES ('kanal', ?, ?, ?, NULL)`) + .run(TREFF_NAME, TREFF_KATEGORIE, jetzt()); + const id = d.prepare("SELECT last_insert_rowid() AS id").get().id; + console.log(`[treff] Chat-Raum angelegt (Nummer ${id}).`); + return id; +} + +/** Trägt ein, wer hineingehört und noch fehlt. + * + * SELBSTHEILEND, wie der Kanal-Abgleich der Teamführung nebenan — + * und aus demselben Grund: Die Zeile müsste sonst beim Anlegen einer + * Person, beim Rollenwechsel und beim Anlegen des Raums entstehen. + * Drei Stellen, von denen die dritte vergessen wird. + * + * `gelesen_bis` startet am aktuellen Stand. Wer heute dazukommt, hat + * die vierhundert Nachrichten von vorher nicht verpasst — eine rote + * Vierhundert wäre keine Auskunft, sondern Rauschen. + * + * WER AUSGETRETEN IST, BLEIBT DRAUSSEN. `raus_am IS NOT NULL` heißt: + * Die Moderation hat jemanden herausgenommen. Ihn beim nächsten + * Seitenaufruf still wieder einzutragen wäre die zuverlässigste Art, + * eine Moderationsentscheidung unwirksam zu machen. */ +export function treffAngleichen() { + try { + const id = treffRaumId(); + const d = db(); + const platz = TREFF_ROLLEN.map(() => "?").join(", "); + const fehlen = d.prepare(` + SELECT p.id FROM personen p + WHERE p.aktiv = 1 AND p.rolle IN (${platz}) + AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t + WHERE t.raum_id = ? AND t.person_id = p.id)`) + .all(...TREFF_ROLLEN, id); + if (!fehlen.length) return 0; + + const rein = d.prepare(` + INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) + SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), ?`); + const n = jetzt(); + for (const p of fehlen) { + /* Leitung im Raum sind die, die auch sonst moderieren. Ein Gast + ist Teilnehmer, kein Leiter -- sonst dürfte er anheften und + andere herausnehmen. */ + const person = d.prepare("SELECT rolle FROM personen WHERE id = ?").get(p.id); + const leitung = person && person.rolle !== "gast" ? 1 : 0; + rein.run(id, p.id, n, id, leitung); + } + return fehlen.length; + } catch (fehler) { + /* Ein Fehler hier darf die Gespraechsliste nicht kosten. */ + console.error("[treff] Abgleich:", fehler?.message); + return 0; + } +} + +/** Ist das der Treff-Raum? */ +export function istTreffRaum(raumId) { + try { + const r = db().prepare( + "SELECT kategorie FROM chat_raeume WHERE id = ? AND art = 'kanal'").get(raumId); + return r?.kategorie === TREFF_KATEGORIE; + } catch { return false; } +} diff --git a/server/workspace.js b/server/workspace.js index 2834f00c..32d8d812 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -987,6 +987,27 @@ const TREFF_GRUPPE = { const TREFF_BEREICHE = [ { ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben", zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=treff", szene: "lounge" }, + /* DER CHAT (19.09.2026). Filipe: "eine geile mischung von punkt 2 + und drei ... nach mitternacht bis morgens 6 uhr keiner schreiben + kann damit auch die privatsphaere beruecksichtig wird ueber nacht + ... und die sollen nicht anrufen koennen. nur schreiben." + + GLEICH AN ZWEITER STELLE, direkt hinter dem grossen Treff-Brett. + Er ist das Lebendigste, was die Community hier hat -- weiter unten + waere er der Knopf, den man erst findet, wenn einem jemand sagt, + dass es ihn gibt. + + ZIEL IST chat.html, dieselbe Seite wie beim Team. Kein zweiter + Chat: Zwei Oberflaechen fuer dasselbe waeren zwei Stellen, an + denen ein Fehler behoben werden muss, und die zweite waere die, + die man vergisst. Was jemand dort SIEHT, entscheidet die + Raummitgliedschaft -- ein Gast hat genau einen Raum. + + TON 41: gezaehlt in start.css, nicht in dieser Datei. 40 war die + hoechste (siehe die Warnung an WERDEGANG_KACHEL, dort ist genau + dieser Fehler heute schon einmal passiert). */ + { ...TREFF_GRUPPE, name: "Chat", unter: "Miteinander reden – nachts schläft er", + zeichen: "chat", ton: 41, ziel: "chat.html", szene: "lounge" }, { ...TREFF_GRUPPE, name: "Anschlagbrett", unter: "Was gerade gilt", zeichen: "reports", ton: 33, ziel: "bereich.html?b=anschlag", szene: "halle" }, { ...TREFF_GRUPPE, name: "Was ansteht", unter: "Die n\u00e4chsten Streams und Events", @@ -1118,6 +1139,18 @@ const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE, importiert von dort. Eine zweite Menge waere die, die auseinanderlaeuft. */ export { AUSSEN_ROLLEN }; +/** Darf diese Person telefonieren? + * + * HIER UND NICHT AUS workspace-treffchat.js IMPORTIERT: Jene Datei + * importiert aus dieser. Ein Import zurueck waere ein Ring, und Ringe + * halten in JavaScript genau so lange, bis jemand eine Konstante auf + * oberster Ebene benutzt. `workspace-treffchat.js` reicht diese + * Funktion deshalb nur WEITER -- gerechnet wird sie an einer Stelle, + * naemlich hier. */ +export function darfTelefonieren(person) { + return !AUSSEN_ROLLEN.has(person?.rolle); +} + /* ===================================================================== KACHEL UND TUER GEHOEREN ZUSAMMEN (11.09.2026) ===================================================================== @@ -4945,6 +4978,23 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => { auseinander. Genau die Ueberlegung steht schon zwei Zeilen darueber; hier gilt sie noch einmal. */ darf_chat: darfSeite(person, "/workspace/chat.html"), + /* UND OB SIE TELEFONIEREN DARF (19.09.2026). + + Nicht als Bequemlichkeit: Ohne diese Angabe holt `anruf.js` beim + Laden jeder Chatseite die Verbindungsadressen -- und bekommt + fuer die Community 404. Der Browser schreibt das in die Konsole, + BEVOR JavaScript es abfangen kann; abfangen hilft also nicht, + die Anfrage darf gar nicht erst gestellt werden. + + Ein 404 bei jedem Seitenaufruf ist Rauschen, und Rauschen macht + den naechsten ECHTEN Fehler unsichtbar -- dieselbe Regel wie bei + der Warnung, die immer kommt. Gefunden hat es die Pruefung im + Browser, nicht das Lesen. + + AUS DERSELBEN FUNKTION wie der Riegel am Anruf-Router. Zwei + Rechnungen waeren die Stelle, an der die Seite ein Telefon + anbietet, das der Server ablehnt. */ + darf_anrufen: darfTelefonieren(person), /* DIE KACHELN, WENN SIE NICHT IM BROWSER STEHEN DUERFEN. Fuer die fuenf bekannten Rollen steht hier `null`, und die @@ -6172,6 +6222,36 @@ export const einladbareIds = (person) => ohneAussen(nurEigene(nurHaus(ohneVerbor seit es ueberhaupt jemanden zu verbergen gibt; vorher konnte es ihn nicht geben. */ export const schreibbareIds = (person) => { + /* ==== DIE COMMUNITY SCHREIBT NIEMANDEM EINZELN (19.09.2026) ======== + + GEFUNDEN VON pruef-treffchat, NICHT BEIM LESEN. Ein Gast konnte + ein Zweier-Gespraech mit DogFather aufmachen -- die Route + antwortete mit 200 und legte den Raum an. + + WARUM `ohneAussen` DAS NICHT VERHINDERT HAT: Diese Huelle nimmt + die Community aus den Listen ANDERER heraus. Sie sorgt dafuer, + dass ein Creator keinen Zuschauer anschreiben kann. Die + Gegenrichtung -- ein Zuschauer, der IN die Liste schaut -- kam nie + vor, weil die Chatseite fuer 'gast' gar nicht offen war. + + SEIT HEUTE IST SIE OFFEN, und damit war die Erlaubnis da. Nicht, + weil jemand sie erteilt haette, sondern weil eine Aenderung an + einer ganz anderen Stelle sie freigelegt hat. Genau die Sorte + Erlaubnis, vor der ich im Anruf-Riegel zwei Dateien weiter + ausdruecklich warne -- und hier waere ich selbst hineingelaufen. + + WAS DARAN SCHLIMM IST: Ein Zweier-Gespraech ist kein Kanal. Es hat + keine Nachtruhe (die gilt fuer den Treff), kein Modi liest mit, + und niemand koennte moderieren. Aus "die Community bekommt einen + Chat" waere ungewollt "jeder Zuschauer bekommt eine Standleitung + zu DogFather" geworden. + + WAS SIE STATTDESSEN HAT: den Treff -- einen Raum, in dem Modis + dabei sind -- und den vertraulichen Meldeweg fuer alles, was + niemanden sonst angeht. Beides ist besser als eine Privatleitung: + Das eine wird moderiert, das andere hat eine Zustaendigkeit. */ + if (AUSSEN_ROLLEN.has(person?.rolle)) return []; + const ids = ohneAussen(nurHaus(ohneVerborgene(schreibbareIdsRoh(person), person), person)); return ids === null ? null : ids.filter((i) => i !== person?.id); }; diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index cd4a1fd8..df57f60a 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +