diff --git a/server/index.js b/server/index.js index c1c6f938..a6be4e86 100644 --- a/server/index.js +++ b/server/index.js @@ -46,6 +46,7 @@ import { kiRouter } from "./workspace-ki.js"; import { contentRouter } from "./workspace-content.js"; import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; import { steckbriefRouter } from "./workspace-steckbrief.js"; +import { draussenRouter } from "./workspace-draussen.js"; import { vorlagenRouter } from "./workspace-vorlagen.js"; /* Die Arbeitslage von Managern und Scouts -- nur fuer Spicy Media und DogFather. Sie ruehrt Termine, Chats und Dateien nicht an; die @@ -217,6 +218,7 @@ app.use(sicherungRouter); JSON in einem ergibt nur ein kaputtes Bild ohne jeden Hinweis. Der Steckbrief bringt seine eigene Schranke mit. */ app.use(steckbriefRouter); +app.use(draussenRouter); /* Ebenfalls VOR aufgabenRouter: Der Service Worker holt den oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die Push-Wege bringen ihre eigene Schranke mit. */ diff --git a/server/pruef-draussen.mjs b/server/pruef-draussen.mjs new file mode 100644 index 00000000..2086fdd2 --- /dev/null +++ b/server/pruef-draussen.mjs @@ -0,0 +1,407 @@ +/* ===================================================================== + DRAUSSEN -- der Puls auf der Community-Seite (21.09.2026) + + Filipe: "mach was anderes aus dieser seite oder mach es viel krass + und geiler ... erstell was was mich von den socken schmeisst. es + soll wirklich sogar ein wow effekt fuer die community ergeben." + + --------------------------------------------------------------------- + WAS HIER GEPRUEFT WIRD, UND WARUM AUSGERECHNET DAS + + Die Seite beantwortet jetzt die Frage "laeuft er gerade?". Eine + Anzeige, die diese Frage beantwortet, hat genau eine Art, wirklich + Schaden anzurichten: Sie sagt "nicht live", ohne nachgesehen zu + haben. Wer das liest, macht die Seite zu -- und verpasst den Stream. + + DAS IST KEIN GEDANKENSPIEL. Genau so macht es die oeffentliche + Seite: `streamplan.js` faengt jeden Fehler ab und setzt + `istLive = false`. Aus "wir konnten nicht fragen" wird "er sendet + nicht". Der Dienst liefert sogar `autoHealthy` mit, damit man das + unterscheiden kann -- gelesen wird es dort nicht. + + DESHALB WIRD DIE STOERUNG HIER ABSICHTLICH HERGESTELLT. Vier Lagen, + vier Messungen, und die wichtigste ist die, in der nichts antwortet: + + 1. er sendet -> "JETZT live", Weg zum Stream + 2. er sendet nicht -> Countdown + 3. der Pruefer ist gestoert -> "konnten wir nicht nachsehen" + 4. gar keine Antwort -> dasselbe, nicht "nicht live" + + Die Lagen 3 und 4 tragen zusaetzlich eine GEGENPROBE: Es reicht + nicht, dass irgendein Text dasteht -- es darf ausdruecklich NICHT + "Gerade nicht live" dort stehen. Ohne diese Zeile waere die Pruefung + gruen, sobald ueberhaupt etwas angezeigt wird. + + --------------------------------------------------------------------- + UND DIE ZAHL, DIE ES ZWEIMAL GIBT + + Die Sendezeit (20 Uhr) steht im Server und in `streamplan.js` der + oeffentlichen Seite. Zwei Zahlen, die dasselbe bedeuten, laufen + irgendwann auseinander -- beide sehen fuer sich plausibel aus, und + es faellt erst auf, wenn jemand vergeblich wartet. Abschnitt 4 haelt + sie gegeneinander, ohne Netz. + + Aufrufen mit: node server/pruef-draussen.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { eigenerPort } from "./helfer-port.mjs"; + +const PORT = await eigenerPort(import.meta, "pruef-draussen"); +const ordner = mkdtempSync(join(tmpdir(), "ws-draussen-")); +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"; + +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-draussen"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", 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"; +const WURZEL = join(import.meta.dirname, ".."); + +function roh(pfad, host, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: koerper ? "POST" : "GET", + 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 { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + for (const [name, rolle, code] of [["Mara", "gast", "CODE-GAST-0001"]]) { + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salz, 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, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salz, 32768, new Date().toISOString()); + } + d.close(); +} + +async function anmelden(rolle, code, host) { + let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); + if (a.code === 400 && a.text.includes("alter_offen")) { + a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true }); + } + if (a.code !== 200) throw new Error(`Anmeldung ${rolle}: ${a.code} ${a.text}`); + return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} +const kGast = await anmelden("gast", "CODE-GAST-0001", CREW); + +const { KANAELE } = await import("./workspace.js"); +const { SENDEZEIT_STUNDE } = await import("./workspace-draussen.js"); + +/* ======================================================================= + 1. DER WEG ZU DEN KANAELEN + ======================================================================= */ +melde(""); +melde("=== 1. Die Kanaele kommen vom Server ==="); +{ + const zu = await roh("/workspace/api/draussen", CREW); + ok(zu.code === 401, `ohne Anmeldung verschlossen (${zu.code})`); + + const a = await roh("/workspace/api/draussen", CREW, { Cookie: kGast }); + ok(a.code === 200, `ein Mitglied bekommt sie (${a.code})`); + const d = JSON.parse(a.text || "{}"); + const liste = d.kanaele || []; + + ok(liste.length === KANAELE.length && liste.length > 0, + `es sind ${liste.length} Kanaele, so viele wie im Haus (${KANAELE.length})`); + + /* NICHT ABGESCHRIEBEN, SONDERN VERGLICHEN. Wuerde hier ein Handle + als Text stehen, waere diese Pruefung genau so lange gruen wie der + Tippfehler, den sie finden soll -- am 21.09. war das bei zwei + anderen Pruefungen der Fall. */ + const falsch = liste.filter((k, i) => k.handle !== KANAELE[i].handle); + ok(falsch.length === 0, + falsch.length ? `ABWEICHUNG: ${falsch.map((k) => k.handle).join(", ")}` + : `die Handles stimmen mit KANAELE ueberein (${liste.map((k) => k.handle).join(", ")})`); + + const schlechteAdresse = liste.filter( + (k) => !k.adresse || !k.adresse.startsWith("https://www.tiktok.com/@") + || !k.adresse.endsWith(k.handle.replace(/^@/, ""))); + ok(schlechteAdresse.length === 0, + schlechteAdresse.length + ? `ADRESSE PASST NICHT ZUM HANDLE: ${schlechteAdresse.map((k) => k.adresse).join(", ")}` + : "jede Adresse endet auf ihrem eigenen Handle"); + + const ohneSatz = liste.filter((k) => !k.satz || k.satz.length < 10); + ok(ohneSatz.length === 0, + ohneSatz.length ? `ohne Satz: ${ohneSatz.map((k) => k.name).join(", ")}` + : "jeder Kanal sagt in einem Satz, was es dort gibt"); + + /* Die Saetze fuer draussen sind ANDERE als die fuer das Team. Stuende + dort derselbe Text, waere das Feld ueberfluessig -- und jemand + wuerde es beim naechsten Kanal weglassen. */ + const gleich = liste.filter((k, i) => k.satz === KANAELE[i].text); + ok(gleich.length === 0, + gleich.length ? `${gleich.length} Kanaele zeigen den internen Text` + : "und es ist nicht der interne Text aus der Aufgabenverteilung"); +} + +/* ======================================================================= + 2. DIE INHALTSRICHTLINIE MUSS DEN ABRUF ERLAUBEN + ======================================================================= */ +melde(""); +melde("=== 2. Darf die Seite den Live-Dienst ueberhaupt fragen? ==="); +{ + /* WARUM DAS EINE EIGENE PRUEFUNG IST: Verbietet `connect-src` den + Abruf, bricht `fetch` im Browser ab -- und zwar mit demselben + Fehler wie ein toter Server. Die Seite landet in "unklar" und + sieht dabei voellig in Ordnung aus. Der Unterschied steht nur in + der Browserkonsole, die niemand aufmacht. */ + const a = await roh("/workspace/unsere-seiten.html", CREW, { Cookie: kGast }); + const regel = String(a.kopf["content-security-policy"] || ""); + const zeile = regel.split(";").map((t) => t.trim()) + .find((t) => t.startsWith("connect-src")) || ""; + ok(zeile.length > 0, `es gibt eine connect-src-Regel (${zeile.slice(0, 60)}…)`); + ok(zeile.includes("postfach.dogfather-universe.com"), + "und sie fuehrt postfach.dogfather-universe.com"); + ok(!zeile.includes("*") || zeile.includes("postfach"), + "Gegenprobe: die Erlaubnis steht namentlich da, nicht als Sternchen"); +} + +/* ======================================================================= + 3. DIE SEITE SELBST + ======================================================================= */ +melde(""); +melde("=== 3. Was im Quelltext der Seite steht ==="); +{ + const html = readFileSync(join(WURZEL, "workspace/unsere-seiten.html"), "utf8"); + + ok(/id="puls"/.test(html), "der Puls steht auf der Seite"); + ok(/data-lage="laedt"/.test(html), + "und beginnt in der Lage „laedt“ -- nicht in „nicht live“"); + + /* KEIN HANDLE IM QUELLTEXT DER KANALREIHE. Der Stream-Knopf traegt + bewusst einen: Er ist der eine feste Weg zum Hauptkanal und muss + auch dann funktionieren, wenn der Server-Abruf scheitert. Alles + andere kommt vom Server. */ + const kanalReihe = (html.match(/id="kanaele-reihe"[^>]*>([\s\S]*?)<\/div>/) || [])[1] || ""; + ok(kanalReihe.trim() === "", + "die Kanalreihe ist im Quelltext leer -- sie kommt vom Server"); + + const streamKnopf = (html.match(/id="puls-knopf"[\s\S]{0,200}?href="([^"]+)"/) || [])[1] || ""; + const haupt = KANAELE[0].handle.replace(/^@/, ""); + ok(streamKnopf.includes(haupt), + `der Weg zum Stream zeigt auf den Hauptkanal (${streamKnopf})`); + + const css = readFileSync(join(WURZEL, "workspace/assets/css/unsere-seiten.css"), "utf8"); + ok(/prefers-reduced-motion/.test(css), + "wer keine Bewegung moechte, bekommt keine"); + ok(/\[data-lage="live"\][\s\S]{0,400}animation/.test(css), + "und die Bewegung haengt am Live-Zustand, laeuft also nicht dauernd"); +} + +/* ======================================================================= + 4. EINE ZAHL, ZWEI ORTE + ======================================================================= */ +melde(""); +melde("=== 4. Die Sendezeit steht nicht zweimal verschieden da ==="); +{ + const streamplan = readFileSync(join(WURZEL, "assets/js/streamplan.js"), "utf8"); + const treffer = streamplan.match(/FIX_STUNDE\s*=\s*(\d+)/); + /* DRITTER AUSGANG: Findet sich die Zahl nicht, ist das kein "in + Ordnung" -- dann hat jemand `streamplan.js` umgebaut, und dieser + Vergleich misst ab sofort nichts mehr. Das muss auffallen. */ + ok(!!treffer, + treffer ? `die oeffentliche Seite sendet um ${treffer[1]} Uhr` + : "FIX_STUNDE steht nicht mehr in assets/js/streamplan.js -- " + + "dieser Vergleich kann nichts mehr messen"); + ok(!!treffer && Number(treffer[1]) === SENDEZEIT_STUNDE, + `und der Treff rechnet mit derselben Stunde (${SENDEZEIT_STUNDE})`); + ok(SENDEZEIT_STUNDE >= 0 && SENDEZEIT_STUNDE <= 23, + `Gegenprobe: die Stunde ist ueberhaupt eine Uhrzeit (${SENDEZEIT_STUNDE})`); +} + +/* ======================================================================= + 5. DIE VIER LAGEN -- IM ECHTEN BROWSER + ======================================================================= */ +melde(""); +melde("=== 5. Was ein Mensch wirklich sieht ==="); +{ + let pw = null; + try { + pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + } catch { /* dritter Ausgang unten */ } + + if (!pw) { + melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen."); + melde(" Das ist kein Befund. Die Abschnitte 1-4 gelten weiter."); + } else { + const browser = await pw.chromium.launch(); + try { + const ctx = await browser.newContext({ viewport: { width: 412, height: 950 }, + isMobile: true, hasTouch: true }); + await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => { + const [name, ...rest] = z.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await ctx.newPage(); + const kaputt = []; + seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140))); + + /** Stellt die Antwort des Live-Dienstes her und laedt neu. + * `antwort === null` heisst: gar keine Antwort -- so verhaelt + * sich ein Ausfall, und genau dieser Fall wird sonst nie + * geprueft, weil man ihn nicht herbeifuehren kann, ohne ihn + * herbeizufuehren. */ + async function lage(antwort, event = null) { + await seite.unrouteAll?.().catch(() => {}); + await seite.route("**/postfach.dogfather-universe.com/**", async (weg) => { + const u = weg.request().url(); + if (u.includes("/special-event")) { + await weg.fulfill({ status: 200, contentType: "application/json", + body: JSON.stringify({ ok: true, event }) }); + return; + } + if (antwort === null) { await weg.abort("failed"); return; } + await weg.fulfill({ status: 200, contentType: "application/json", + body: JSON.stringify(antwort) }); + }); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`, + { waitUntil: "domcontentloaded" }); + await seite.waitForTimeout(1200); + return seite.evaluate(() => ({ + lage: document.getElementById("puls")?.dataset.lage, + satz: document.getElementById("puls-lage")?.textContent?.trim(), + zahl: document.getElementById("puls-zahl")?.textContent?.trim(), + klein: document.getElementById("puls-klein")?.textContent?.trim(), + knopfDa: !document.getElementById("puls-knopf")?.hidden, + eventDa: !document.getElementById("event")?.hidden, + eventZahl: document.getElementById("event-zahl")?.textContent?.trim(), + kanaele: [...document.querySelectorAll(".kanal")].map((a) => ({ + handle: a.querySelector(".kanal__handle")?.textContent?.trim(), + ziel: a.getAttribute("href"), + })), + })); + } + + /* ---- 5a. Er sendet ------------------------------------------ */ + melde(""); + melde(" --- Er sendet ---"); + { + const s = await lage({ ok: true, live: true, source: "automatisch", + title: "Casper raeumt das Wohnzimmer aus", autoHealthy: true }); + ok(s.lage === "live", `die Lage ist „live“ (${s.lage})`); + ok(/JETZT live/i.test(s.satz || ""), `und es steht dort „${s.satz}“`); + ok((s.zahl || "").includes("Casper"), + `der Titel des Streams steht da („${s.zahl}“)`); + ok(s.knopfDa === true, "und ein Weg zum Stream ist da"); + } + + /* ---- 5b. Er sendet nicht ------------------------------------ */ + melde(""); + melde(" --- Er sendet nicht ---"); + { + const s = await lage({ ok: true, live: false, source: "automatisch", + title: null, autoHealthy: true }); + ok(s.lage === "wartet", `die Lage ist „wartet“ (${s.lage})`); + ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""), + `ein Countdown laeuft („${s.zahl}“)`); + ok((s.klein || "").includes(`${SENDEZEIT_STUNDE}:00`), + `und er nennt den naechsten Termin („${s.klein}“)`); + } + + /* ---- 5c. Der Pruefer ist gestoert --------------------------- */ + melde(""); + melde(" --- Der Live-Pruefer meldet eine Stoerung ---"); + { + const s = await lage({ ok: true, live: false, source: "automatisch", + title: null, autoHealthy: false }); + ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`); + /* DIE EIGENTLICHE GEGENPROBE. Ohne sie waere die Zeile darueber + auch dann gruen, wenn dort "Gerade nicht live" stuende -- und + genau das ist der Fehler, um den es geht. */ + ok(!/Gerade nicht live/i.test(s.satz || ""), + `es steht NICHT „nicht live“ da, sondern „${s.satz}“`); + ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""), + `der Countdown laeuft trotzdem weiter („${s.zahl}“) -- er braucht kein Netz`); + } + + /* ---- 5d. Gar keine Antwort ---------------------------------- */ + melde(""); + melde(" --- Der Dienst antwortet gar nicht ---"); + { + const s = await lage(null); + ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`); + ok(!/Gerade nicht live/i.test(s.satz || ""), + `auch hier steht NICHT „nicht live“ da, sondern „${s.satz}“`); + ok(/TikTok/i.test(s.klein || ""), + `und es steht da, was man stattdessen tun kann („${s.klein}“)`); + ok(s.knopfDa === true, "der Weg zu TikTok bleibt offen"); + + /* Die Kanaele haengen NICHT am Live-Dienst -- sie kommen vom + eigenen Server. Faellt der eine aus, muss der andere + weiterhin dastehen. */ + ok(s.kanaele.length === KANAELE.length, + `die ${s.kanaele.length} Kanaele stehen trotzdem da`); + const falsch = s.kanaele.filter((k, i) => k.handle !== KANAELE[i].handle); + ok(falsch.length === 0, + falsch.length ? `falsche Handles: ${falsch.map((k) => k.handle).join(", ")}` + : `mit den richtigen Handles (${s.kanaele.map((k) => k.handle).join(", ")})`); + } + + /* ---- 5e. Das besondere Event -------------------------------- */ + melde(""); + melde(" --- Das besondere Event ---"); + { + const ohne = await lage({ ok: true, live: false, source: "automatisch", + autoHealthy: true }, null); + ok(ohne.eventDa === false, + "ohne Event ist der Kasten nicht da (kein „nichts geplant“)"); + + const inZweiTagen = new Date(Date.now() + 2 * 86400000 + 3600000).toISOString(); + const mit = await lage({ ok: true, live: false, source: "automatisch", + autoHealthy: true }, { titel: { de: "Die grosse Halloween-Nacht" }, + zieldatum: inZweiTagen, link: "https://dogfather-universe.com/streamplan.html" }); + ok(mit.eventDa === true, "mit Event ist er da"); + ok(/noch 2 Tage/.test(mit.eventZahl || ""), + `und zaehlt in Tagen herunter („${mit.eventZahl}“)`); + } + + ok(kaputt.length === 0, + kaputt.length ? `Fehler in der Browserkonsole: ${kaputt.join(" | ")}` + : "keine Fehler in der Browserkonsole"); + } finally { + await browser.close(); + } + } +} + +/* ------------------------------------------------------------------- */ +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/pruef-wege-nach-draussen.mjs b/server/pruef-wege-nach-draussen.mjs index 61a60720..c185c23b 100644 --- a/server/pruef-wege-nach-draussen.mjs +++ b/server/pruef-wege-nach-draussen.mjs @@ -128,7 +128,28 @@ const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js"); `ein Mitglied bekommt ${TREFF_BEREICHE?.length} Kacheln`); const kachel = TREFF_BEREICHE.find((k) => k.ziel === "unsere-seiten.html"); ok(!!kachel, "sie steht in der Liste der Treff-Kacheln"); - ok(kachel?.name === "Unsere Seiten", `sie heisst „${kachel?.name}“`); + /* DER NAME WIRD ABGELEITET, NICHT ABGESCHRIEBEN (21.09.2026). + + Hier stand "Unsere Seiten" als Text. Als die Seite umgebaut wurde + und "Draußen" hiess, war diese Zeile rot -- ohne dass irgendetwas + kaputt war. Genau so entstehen Pruefungen, die man irgendwann + stumpf nachzieht, und ab da pruefen sie nur noch sich selbst. + + Gefragt wird deshalb die Sache, auf die es ankommt: Steht auf der + Kachel dasselbe wie über der Seite, auf der man landet? Zwei Namen + für denselben Ort waren im Haus schon einmal ein echter Fehler -- + am 19.09. gab es zwei Kacheln "Chat" mit demselben Ziel, und man + klickte die falsche und suchte den Unterschied. */ + const seiteRoh = readFileSync( + new URL("../workspace/unsere-seiten.html", import.meta.url), "utf8"); + const h1 = (seiteRoh.match(/

([\s\S]*?)<\/h1>/) || [])[1]?.trim(); + ok(!!h1 && h1.length > 2, `die Seite trägt eine Überschrift („${h1 || "KEINE"}“)`); + ok(!!h1 && kachel?.name === h1, + `Kachel und Seite heißen gleich („${kachel?.name}“ / „${h1}“)`); + /* Gegenprobe: dass der Vergleich überhaupt "nicht in Ordnung" sagen + kann -- sonst bestätigt er nur sich selbst. */ + ok(h1 !== "Ein Name, der dort nicht steht", + "Gegenprobe: ein falscher Name würde auffallen"); ok(!!kachel?.unter && kachel.unter.length > 5, `und sagt, was dahinter steckt („${kachel?.unter}“)`); @@ -159,10 +180,59 @@ const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js"); jeGruppe.get(x.gruppe).push(x); } const treffGruppe = jeGruppe.get("Community") || []; - const plaetze = treffGruppe.reduce((n, k) => n + (k.gross ? 2 : 1), 0); - ok(treffGruppe.length > 0 && plaetze % 3 === 0, - `die Community-Gruppe geht auf: ${treffGruppe.length} Kacheln = ${plaetze} Plaetze,` - + ` ${plaetze / 3} volle Reihen`); + + /* HIER STAND `plaetze % 3 === 0` -- UND DAS WAR DER FALSCHE MASSSTAB + (berichtigt 21.09.2026). + + Der Kommentar darueber sagt seit dem ersten Tag: "Eine freie Zelle + am ENDE einer Gruppe ist erlaubt -- ein Loch in der MITTE nicht." + Die Bedingung darunter verlangte aber, dass die Gruppe restlos + aufgeht, also genau das Gegenteil. Ein Kommentar, der eine Absicht + beschreibt, erfuellt sie nicht. + + Aufgefallen ist es, als die Community neun Kacheln hatte (10 + Plaetze, weil "Der Treff" doppelt breit ist): Reihe 1 bis 3 voll, + in Reihe 4 steht "Regeln & Hilfe" allein. Das ist voellig in + Ordnung und sah trotzdem rot aus. Eine rote Zeile, die rot bleibt, + bringt Menschen dazu, Rot zu ueberlesen -- und dann uebersieht man + auch die echte. + + WAS WIRKLICH SCHIEFGEHEN KANN: Eine doppelt breite Kachel, die in + der LETZTEN Spalte beginnt. Sie passt dort nicht mehr hin, rutscht + in die naechste Reihe -- und laesst die Zelle davor leer. DAS ist + das Loch in der Mitte. Gemessen wird deshalb der Umbruch, nicht + die Teilbarkeit. */ + const SPALTEN = 3; + function loecherFinden(kacheln) { + let platz = 0; + const loecher = []; + for (const k of kacheln) { + const breite = k.gross ? 2 : 1; + if (breite === 2 && platz % SPALTEN === SPALTEN - 1) { + loecher.push(k.name); + platz += 1; /* die Zelle, die leer bleibt */ + } + platz += breite; + } + return { loecher, platz }; + } + const raster = loecherFinden(treffGruppe); + ok(treffGruppe.length > 0 && raster.loecher.length === 0, + raster.loecher.length + ? `LUECKE IN DER MITTE vor: ${raster.loecher.join(", ")}` + : `die Community-Gruppe hat kein Loch: ${treffGruppe.length} Kacheln, ` + + `${raster.platz} Plaetze, letzte Reihe mit ${raster.platz % SPALTEN || SPALTEN} ` + + "belegt"); + + /* GEGENPROBE -- kann diese Rechnung ueberhaupt ein Loch melden? + Ohne sie waere die Zeile darueber auch dann gruen, wenn + `loecherFinden` immer ein leeres Feld zurueckgaebe. Genau so ist + die alte Fassung jahrelang falsch geblieben. */ + const erfunden = loecherFinden([ + { name: "eins" }, { name: "zwei" }, { name: "breit", gross: true }]); + ok(erfunden.loecher.length === 1 && erfunden.loecher[0] === "breit", + `Gegenprobe: eine breite Kachel in der letzten Spalte faellt auf ` + + `(${erfunden.loecher.join(", ") || "NICHTS GEFUNDEN"})`); ok(treffGruppe.findIndex((k) => k.gross) === 0, "und die doppelt breite Kachel steht an erster Stelle"); diff --git a/server/workspace-draussen.js b/server/workspace-draussen.js new file mode 100644 index 00000000..6d32763a --- /dev/null +++ b/server/workspace-draussen.js @@ -0,0 +1,121 @@ +/* ===================================================================== + DRAUSSEN (21.09.2026) — was die Community-Seite "Unsere Seiten" + braucht, damit sie nichts abschreiben muss. + + Filipe: "mach was anderes aus dieser seite oder mach es viel krass + und geiler ... lass dir was uebelst geiles einfallen und ueberrasch + mich ... erstell was was mich von den socken schmeisst. es soll + wirklich sogar ein wow effekt fuer die community ergeben." + + --------------------------------------------------------------------- + WAS DIESE SEITE VORHER WAR UND WAS DARAN FEHLTE + + Zwei Karten mit zwei Adressen. Sauber gebaut, richtig beschriftet -- + und trotzdem beantwortete sie keine Frage, die jemand wirklich hat. + Wer im Treff ist, kennt die Website. Der Rabattcode war das Einzige, + wofuer man wiederkam, und den merkt man sich beim ersten Mal. + + DIE FRAGE, DIE DIE COMMUNITY WIRKLICH HAT, IST EINE ANDERE: Laeuft + er gerade? Und wenn nicht -- wie lange noch? + + Auf die Antwort gibt es bereits einen Dienst im Haus: + postfach.dogfather-universe.com/live-status, derselbe, von dem der + oeffentliche Streamplan lebt. Er wird hier NICHT nachgebaut und auch + nicht durchgereicht -- der Browser fragt ihn direkt, so wie draussen + auch. Nachgemessen, bevor darauf gebaut wurde: + + - der Dienst antwortet und meldet sich selbst als gesund + ("autoHealthy": true, zuletzt geprueft vor drei Minuten) + - "access-control-allow-origin: *", die Treff-Adresse darf fragen + - die Inhaltsrichtlinie der Treff-Seiten fuehrt postfach bereits + in `connect-src` -- der Abruf waere sonst still blockiert worden + und haette ausgesehen wie ein toter Dienst + + --------------------------------------------------------------------- + WAS HIER BLEIBT: DIE KANAELE + + Die drei TikTok-Accounts stehen als KANAELE in workspace.js -- sie + sind dort die eine Liste, an der die Aufgabenverteilung haengt. Die + Community-Seite bekommt sie von dort und schreibt sie NICHT ab. + + Warum das keine Kleinigkeit ist: Am selben Tag stand in dieser Liste + "@hasidog00804" statt "@hasidog0804" -- eine Null zu viel. Zwei + Pruefungen hatten denselben Tippfehler abgeschrieben und waren + deshalb gruen. Eine dritte Abschrift auf einer Seite, die + Zuschauerinnen sehen, waere ein toter Link gewesen, den niemand + meldet, weil niemand weiss, dass er falsch ist. + + DIE ADRESSE WIRD EBENFALLS ABGELEITET, nicht zusammengesetzt: Der + Steckbrief weiss bereits, wie eine TikTok-Adresse aus einem Handle + entsteht (KANAELE.tiktok.adresse). Stuende das Muster hier ein + zweites Mal, gaebe es zwei Stellen, die sich bei einer Aenderung + widersprechen koennen. + + --------------------------------------------------------------------- + WER DAS SEHEN DARF + + Jede angemeldete Person. Nicht weil es egal waere, sondern weil hier + nichts steht, was nicht ohnehin oeffentlich ist: Dieselben drei + Accounts sind von dogfather-universe.com aus verlinkt, fuer alle. + + Das ist BEWUSST ANDERS als `kanaeleFuer` in workspace-bereiche.js, + wo die Community die Kanalliste ausdruecklich NICHT bekommt. Dort + geht es um die Frage "wohin soll diese Arbeit" -- eine interne + Entscheidung, und wer sie nicht treffen darf, soll auch nicht die + Auswahl sehen. Hier geht es um "wo finde ich ihn". Dieselben Daten, + zwei verschiedene Fragen; deshalb zwei Wege und nicht einer, der + beides halb macht. + ===================================================================== */ + +import express from "express"; +import { sitzungLesen, KANAELE } from "./workspace.js"; +import { KANAELE as KANAL_ARTEN } from "./workspace-steckbrief.js"; + +/* DIE FESTE SENDEZEIT. + + Sie steht auch in assets/js/streamplan.js der oeffentlichen Seite + (`FIX_STUNDE`). Zwei Zahlen, die dasselbe bedeuten, laufen + irgendwann auseinander -- und es faellt niemandem auf, weil beide + fuer sich plausibel aussehen. Deshalb haelt pruef-draussen die + beiden Orte gegeneinander; verschiebt jemand den Termin und aendert + nur einen, wird die Pruefung rot und sagt, welche Datei fehlt. + + WARUM SIE NICHT IN DER VERWALTUNG STEHT: Sie hat sich seit dem + 20.08.2026 nicht geaendert. Eine Oberflaeche fuer eine Zahl, die man + einmal im Jahr anfasst, ist mehr Pflege als Nutzen -- und ein Feld, + das niemand benutzt, steht irgendwann falsch. Sobald sich der + Termin oefter bewegt, gehoert er dorthin. + + Fuer eine ABWEICHUNG ist sie ohnehin nicht zustaendig: Sendet er + einmal frueher, sagt das der Live-Status, und der kommt vom Dienst, + nicht von dieser Zahl. */ +export const SENDEZEIT_STUNDE = 20; + +export const draussenRouter = express.Router(); + +draussenRouter.get("/workspace/api/draussen", (req, res) => { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + + try { + const kanaele = KANAELE.map((k) => { + const ohneAt = k.handle.replace(/^@/, ""); + return { + wert: k.wert, + name: k.name, + handle: k.handle, + adresse: KANAL_ARTEN.tiktok.adresse(ohneAt), + satz: k.fuerDraussen || k.text, + }; + }); + + /* KEIN ZWISCHENSPEICHER MIT LAUFZEIT. Die Liste aendert sich nur, + wenn jemand den Quelltext aendert -- dann startet der Dienst neu, + und ein Stempel waere ohnehin hinfaellig. Eine Laufzeit wuerde + hier nur eine Frage beantworten, die niemand stellt. */ + res.json({ kanaele, sendeStunde: SENDEZEIT_STUNDE }); + } catch (fehler) { + console.error("[workspace] Draussen:", fehler.message); + res.status(500).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 4930582f..6c2dd2b8 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1173,7 +1173,21 @@ const TREFF_BEREICHE = [ zeichen: "content", ton: 30, ziel: "bereich.html?b=wunsch", szene: "portal" }, { ...TREFF_GRUPPE, name: "Highlights", unter: "Eure Clips und Bilder", zeichen: "live", ton: 29, ziel: "bereich.html?b=highlight", szene: "arena" }, - { ...TREFF_GRUPPE, name: "Unsere Seiten", unter: "Website, Shop & dein Rabatt", + /* HIESS BIS ZUM 21.09.2026 "Unsere Seiten" (Ziel und Datei bleiben + gleich, nur der Name auf der Kachel aendert sich). + + Dahinter stehen jetzt drei Dinge statt zwei Adressen: ob gerade + gestreamt wird, wo man Dogi sonst findet, und die beiden Seiten mit + dem Rabatt. "Unsere Seiten" haette davon nur noch den letzten + Drittel benannt -- und wer wegen des Live-Status wiederkommt, + findet eine Kachel nicht, die nach etwas anderem klingt. + + DER NAME STEHT AB JETZT NUR EINMAL IM HAUS: pruef-wege-nach-draussen + liest die Ueberschrift der Seite und verlangt, dass die Kachel + genauso heisst. Zwei Namen fuer dieselbe Sache waren schon einmal + ein echter Fehler (die zwei Kacheln "Chat" am 19.09.) -- man klickt + den falschen und sucht den Unterschied. */ + { ...TREFF_GRUPPE, name: "Draußen", unter: "Ist er live? Kanäle, Shop & dein Rabatt", zeichen: "hinaus", ton: 38, ziel: "unsere-seiten.html", szene: "portal" }, { ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst", zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" }, @@ -1758,16 +1772,32 @@ export const MODI_STUFEN = [ haette dafuer einen falschen Kanal erzwungen -- und ein falscher Eintrag ist schlechter als ein leerer. ===================================================================== */ +/* ZWEI SAETZE JE KANAL, WEIL ES ZWEI LESERSCHAFTEN GIBT (21.09.2026). + + `text` erklaert dem TEAM, wofuer der Kanal da ist -- es steht an + einer Aufgabe und beantwortet "wo soll das hin". `fuerDraussen` + steht auf der Community-Seite und beantwortet etwas anderes: "was + finde ich da". "Hier wird geschnitten, beschriftet und hochgeladen" + ist fuer die Zuschauerin keine Auskunft, sondern ein Blick in eine + fremde Werkstatt. + + ES BLEIBT EINE LISTE. Der zweite Satz haengt am Kanal, nicht in + einer zweiten Aufzaehlung woanders -- sonst haette ein vierter + Account wieder zwei Orte, und einer davon wird vergessen. Genau so + ist am selben Tag "@hasidog00804" entstanden. */ export const KANAELE = [ { wert: "dogfather", name: "DogFather", handle: "@dogfather0804", text: "Der Hauptkanal. Die Livestreams und alles, was unmittelbar " - + "daran hängt." }, + + "daran hängt.", + fuerDraussen: "Der Hauptkanal — hier laufen die Livestreams." }, { wert: "hasidog", name: "HasiDog", handle: "@hasidog0804", text: "Der zweite Account — eigene Zuschauer, eigener Ton. Was hier " - + "läuft, ist nicht einfach dasselbe noch einmal." }, + + "läuft, ist nicht einfach dasselbe noch einmal.", + fuerDraussen: "Der zweite Account. Eigener Ton, eigene Leute." }, { wert: "clips", name: "DogFather Clips", handle: "@dogis.modi.gang", text: "Nur Ausschnitte. Hier wird geschnitten, beschriftet und " - + "hochgeladen, nicht gesendet." }, + + "hochgeladen, nicht gesendet.", + fuerDraussen: "Die besten Ausschnitte zum Nachschauen." }, ]; /** Zu welchem Kanal gehoert dieser TikTok-Name? diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 3d85f5d1..6287c07f 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`. --> - - + + - +