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