From aa2e1a4ae2d7cc1e820f349c692b2ccd7b1ebeae Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 24 Sep 2026 14:14:25 +0200 Subject: [PATCH] Eine Kachel "Unterstuetzen" -- und die Gespraechsliste neu gebaut === 1. UNTERSTUETZEN (neue Kachel im Community-Bereich) === Filipe: "ich brauch auch noch eine kachel im community bereich. wo mein paypal und meine amazon liste sein wird. wo die leute alle supporten koennen auf andere art anstatt nur tiktok. jeder soll diese kachel sehen aber nur dogfather soll sie veraendern koennen oder vieles mehr sehen." Neu: unterstuetzen.html + css + js, server/workspace-unterstuetzung.js, server/unterstuetzung-tabellen.js. Ton 45 (#7567fe) ist gerechnet, das Kachelzeichen ist ein Herz ueber zwei offenen Haenden. DIE WEGE STEHEN IN DER DATENBANK, nicht im Quelltext -- ein Recht, das man nur ueber einen Entwickler ausueben kann, ist keines. DogFather schreibt Titel, Text, Knopf und Adresse selbst, blendet Wege aus und nimmt neue dazu. DER PAYPAL-LINK STEHT LEER UND UNSICHTBAR DA. Filipe schrieb "mein paypal kennst du ja schon" -- gesucht im ganzen Haus und im Vault, nirgends gefunden. Eine Zahlungsadresse zu RATEN waere der gefaehrlichste Fehler dieser Seite: Geld an einen Fremden, und niemand merkt es. Also steht dort nichts, der Weg ist ausgeblendet, und die Seite sagt genau EINER Person, dass er fehlt. DREI ARTEN, UND DIE DRITTE IST DIE WICHTIGSTE: geld, geschenk, frei. "Kostet nichts" bekommt dieselbe Kartenform und dieselbe Groesse. Waeren die freien Wege kleiner oder weiter unten, waere die Aussage "das ist zweite Wahl" -- und die Mehrheit derer, die hier lesen, waere damit zweite Wahl. DIE ZAEHLUNG IST ANONYM, UND ZWAR VON DER TABELLE HER. DogFather sieht, wie oft ein Weg geoeffnet wurde (7/30 Tage/gesamt). Was er NICHT sieht, ist WER -- weil es in unterstuetzung_striche keine Spalte dafuer gibt. Geprueft wird das ueber PRAGMA table_info, nicht ueber eine Abfrage: eine Abfrage liesse sich morgen erweitern, eine fehlende Spalte nicht. Schreiben haengt an istDogFather, nicht an istLeitung -- die rechte Hand fuehrt dieses Team mit und kommt trotzdem nicht an diesen Link. Jede Adresse wird beim SCHREIBEN geprueft (nur https:// oder eine Seite dieses Hauses); javascript:, data:, http:// und // werden abgewiesen. Neu: server/pruef-unterstuetzung.mjs -- 70 Pruefungen, alle gruen. Sie misst ueber die CREW-ADRESSE: Beim ersten Lauf waren vier Rollen gruen und der Zuschauer rot, und es sah nach einem Rechtefehler aus. Es war ein Messfehler -- ueber 127.0.0.1 landet man still im Agenturhaus, und dort gibt es die Rolle "gast" gar nicht. === 2. DIE GESPRAECHSLISTE (screen1 + screen2) === Filipe: "ich will dass die komplette kachel viel krasser und geiler aussieht. der hintergrund der kachel soll gleich bleiben." Der Hintergrund ist unangetastet. Zwei echte Fehler auf seinem Bild: - Bei "Das Rudel" stand das "@" rechts oben und die orange "6" eine ZEILE TIEFER. Der Knopf ist ein Raster mit DREI Spalten und bekam VIER Kinder -- das vierte fiel um. Ausgerechnet die wichtigste Auskunft der Liste landete an der unauffaelligsten Stelle. - Jedes Gespraech mit Profilbild haengte das Bild ZWEIMAL ein: ein Block stand Zeichen fuer Zeichen doppelt da. Zu sehen war nichts, gekostet hat es die doppelte Ladelast bei jedem Neuzeichnen. Und eine tote Regel: `.chat-raum__knopf[data-an="ja"]` beschrieb die Schiene am offenen Gespraech -- gesetzt wird aber `data-offen` am
  • . Die Regel griff nie, und daneben stand eine zweite, blassere Fassung derselben Sache. Jetzt steht alles einmal, und die Schiene ist da. Dazu: Zeilen als Karten, 44px-Gesichter, ein Zaehler neben "Gespraeche", Suchfeld als Pille mit Lupe, runder Farbfleck statt Quadrat, und der leere Raum rechts bekommt eine Mitte statt eines Satzes in der Ecke. === 3. VIER FUNDE NEBENBEI === - supportAufraeumen() war exportiert und wurde NIRGENDS gerufen. Die Meldungen samt Bildschirmfotos waeren fuer immer liegen geblieben, obwohl "90 Tage" dokumentiert ist. Jetzt im Loeschkonzept. - aufgaben_zuteilung fehlte ebenfalls im Loeschkonzept (seit 21.09.). pruef-aufbewahrung ist damit wieder gruen. - pruef-start-ansicht war rot, seit "Aufgaben" am 23.09. die silberne Kachel bekam -- die ueberschreibt ihr --ton absichtlich. Die Pruefung nimmt sie jetzt aus UND prueft die Ausnahme selbst. - pruef-buehne meldete auf entwicklung.html 1,79:1 Kontrast bei "Wen gehst du durch?" -- die Ueberschrift lag direkt auf dem Foto. Sie steht jetzt auf einer deckenden Flaeche. OFFEN: pruef-buehne meldet treff-regeln.html mal "0 von 40", mal "3 von 40" -- dieselbe Seite, verschiedene Antworten. Eine Pruefung, die schwankt, ist schlimmer als eine rote. Nicht in diesem Zug behoben. Gemessen: pruef-unterstuetzung 70, pruef-start-ansicht, pruef-buehne (bis auf den Wackler), pruef-workspace-seiten, pruef-aufbewahrung 45, pruef-rechtetafel 19, pruef-css-klassen, pruef-chatkachel 36, pruef-chat-ausbau, pruef-entwicklung-kacheln 31 -- gruen. Neu: server/mess-chat-liste.mjs (zeigt die Liste mit sieben Gespraechen verschiedener Art, prueft nichts). Co-Authored-By: Claude Opus 5 --- server/index.js | 4 + server/mess-chat-liste.mjs | 214 +++++++++++ server/pruef-start-ansicht.mjs | 35 ++ server/pruef-unterstuetzung.mjs | 503 +++++++++++++++++++++++++ server/rechte.js | 17 + server/unterstuetzung-tabellen.js | 179 +++++++++ server/workspace-aufbewahrung.js | 81 ++++ server/workspace-unterstuetzung.js | 366 ++++++++++++++++++ server/workspace.js | 30 ++ workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/chat.css | 299 ++++++++++++--- workspace/assets/css/entwicklung.css | 26 +- workspace/assets/css/start.css | 16 + workspace/assets/css/unterstuetzen.css | 295 +++++++++++++++ workspace/assets/js/bereiche.js | 21 ++ workspace/assets/js/chat.js | 88 ++++- workspace/assets/js/unterstuetzen.js | 476 +++++++++++++++++++++++ workspace/aufgaben.html | 38 +- workspace/automation.html | 32 +- workspace/befinden.html | 34 +- workspace/bereich.html | 44 +-- workspace/bewerben.html | 38 +- workspace/bewerbungen.html | 36 +- workspace/calls.html | 34 +- workspace/chat.html | 69 ++-- workspace/content.html | 34 +- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +- workspace/entwicklung.html | 36 +- workspace/hilfe.html | 30 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +- workspace/leistung.html | 32 +- workspace/material.html | 32 +- workspace/personen.html | 32 +- workspace/profil.html | 36 +- workspace/rechte.html | 34 +- workspace/report.html | 36 +- workspace/scouting.html | 32 +- workspace/start.html | 32 +- workspace/startcheck.html | 34 +- workspace/steckbrief.html | 36 +- workspace/support.html | 28 +- workspace/talente.html | 36 +- workspace/team.html | 32 +- workspace/teamlage.html | 34 +- workspace/teilen.html | 30 +- workspace/treff-moderation.html | 32 +- workspace/treff-regeln.html | 32 +- workspace/uebersicht.html | 32 +- workspace/unsere-seiten.html | 30 +- workspace/unterstuetzen.html | 118 ++++++ workspace/werdegang.html | 32 +- workspace/willkommen.html | 30 +- workspace/wissen.html | 34 +- 57 files changed, 3349 insertions(+), 678 deletions(-) create mode 100644 server/mess-chat-liste.mjs create mode 100644 server/pruef-unterstuetzung.mjs create mode 100644 server/unterstuetzung-tabellen.js create mode 100644 server/workspace-unterstuetzung.js create mode 100644 workspace/assets/css/unterstuetzen.css create mode 100644 workspace/assets/js/unterstuetzen.js create mode 100644 workspace/unterstuetzen.html diff --git a/server/index.js b/server/index.js index 4e38fd10..9ba234b7 100644 --- a/server/index.js +++ b/server/index.js @@ -38,6 +38,7 @@ import { uebernahmeRouter } from "./workspace-uebernahme.js"; import { befindenRouter } from "./workspace-befinden.js"; import { hilfeRouter } from "./workspace-hilfe.js"; import { supportRouter } from "./workspace-support.js"; +import { unterstuetzungRouter } from "./workspace-unterstuetzung.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -311,6 +312,9 @@ app.use(uebernahmeRouter); app.use(befindenRouter); app.use(hilfeRouter); app.use(supportRouter); +/* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas + beitragen kann – lesen alle, ändern nur DogFather. */ +app.use(unterstuetzungRouter); app.use(bewerbungRouter); app.use(reportRouter); app.use(scoutRouter); diff --git a/server/mess-chat-liste.mjs b/server/mess-chat-liste.mjs new file mode 100644 index 00000000..d000c257 --- /dev/null +++ b/server/mess-chat-liste.mjs @@ -0,0 +1,214 @@ +/* ===================================================================== + WIE SIEHT DIE GESPRÄCHSLISTE AUS? (24.09.2026) + + Filipe, mit zwei Bildschirmfotos: "ich will dass die komplette + kachel viel krasser und geiler aussieht ... viel moderner. hau + richtig rein und mach was spezielles." + + --------------------------------------------------------------------- + WARUM ES DIESE DATEI GIBT + + Es gibt acht `pruef-chat*`-Dateien. Sie beweisen, dass der Chat + FUNKTIONIERT -- dass zwei Leute einander erreichen, dass die + Nachtruhe haelt, dass niemand in einen fremden Raum sieht. Keine + davon zeigt, wie die Liste AUSSIEHT, wenn sieben Gespraeche + verschiedener Art darin stehen: ein Kanal mit Erwaehnung und sechs + Ungelesenen, eine Gruppe, vier Zweiergespraeche mit Gesichtern. + + Genau das war der Zustand auf Filipes Bildschirmfoto -- und genau + dort fiel auf, dass die "6" eine Zeile tiefer stand als das "@". + + Diese Datei legt den Bestand an, den er sieht, und macht Bilder. + Sie prueft NICHTS und meldet keinen Fehlschlag. + + Sie heisst `mess-` und nicht `pruef-`: Die Portvergabe leitet sich + aus den `pruef-`-Namen ab, und eine Messdatei darf die Nummern der + Pruefungen nicht verschieben. + + Aufrufen mit: node server/mess-chat-liste.mjs + Bilder: server/mess-chat-liste-breit.png + server/mess-chat-liste-handy.png + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const PORT = 5397; +const ordner = mkdtempSync(join(tmpdir(), "ws-messchat-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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(240_000, "mess-chat-liste"); +await new Promise((r) => setTimeout(r, 800)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +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); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/* Dieselbe Besetzung wie auf Filipes Bildschirmfoto -- damit sich das + Bild unmittelbar mit seinem vergleichen laesst. */ +const ich = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const leute = [ + { name: "VanVan", rolle: "hand" }, + { name: "Ghost", rolle: "modi" }, + { name: "Diene", rolle: "modi" }, + { name: "Marina", rolle: "modi" }, + { name: "BananaStift", rolle: "modi" }, + { name: "Patrick", rolle: "modi" }, +]; +for (const p of leute) p.id = anlegen(p.name, p.rolle, `CODE-${p.name.toUpperCase()}-1`); +d.close(); + +/* ---- Anmelden ------------------------------------------------------- */ +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), + }); + return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +} +const keksIch = await anmelden("admin", "CODE-DOGI-0001"); +for (const p of leute) p.keks = await anmelden(p.rolle, `CODE-${p.name.toUpperCase()}-1`); + +const hin = (weg, keks, koerper) => fetch(BASIS + weg, { + method: "POST", headers: { "Content-Type": "application/json", Cookie: keks }, + body: JSON.stringify(koerper), +}); +const wer = (name) => leute.find((p) => p.name === name); + +/* ---- Die Gespräche -------------------------------------------------- */ +const raeume = {}; +{ + /* Ein Kanal, eine Gruppe, vier Zweiergespräche -- die drei Arten, die + die Liste unterscheiden muss. */ + const k = await hin("/workspace/api/chat/kanal", keksIch, + { kategorie: "", eigener_name: "Chat-Moderation", mit: leute.map((p) => p.id) }); + raeume.kanal = (await k.json()).raum_id; + + const k2 = await hin("/workspace/api/chat/kanal", keksIch, + { kategorie: "", eigener_name: "Das Rudel", mit: leute.map((p) => p.id) }); + raeume.rudel = (await k2.json()).raum_id; + + const g = await hin("/workspace/api/chat/gruppe", keksIch, + { name: "Abmeldungen \u{1F5D2}", mit: leute.map((p) => p.id) }); + raeume.gruppe = (await g.json()).raum_id; + + for (const name of ["VanVan", "Ghost", "BananaStift", "Patrick"]) { + const a = await hin("/workspace/api/chat/direkt", keksIch, { mit: wer(name).id }); + raeume[name] = (await a.json()).raum_id; + } +} + +/* ---- Was darin steht ------------------------------------------------- */ +const schreib = (raum, keks, text) => + hin(`/workspace/api/chat/raeume/${raum}/nachrichten`, keks, { text }); + +/* Sechs Ungelesene und eine Erwähnung -- der Zustand aus dem + Bildschirmfoto. */ +for (let i = 1; i <= 5; i++) { + await schreib(raeume.rudel, wer("Diene").keks, + `Kurze Ansage Nummer ${i} für alle im Rudel.`); +} +await schreib(raeume.rudel, wer("Diene").keks, + "Hallo ihr Lieben, schaut gerne mal rein @Filipe – das wollte ich dir zeigen."); + +await schreib(raeume.VanVan, wer("VanVan").keks, + "Da hat Miss was falsch eingegeben. Ich kümmere mich gleich drum."); +await schreib(raeume.Ghost, wer("Ghost").keks, + "Sorry hab's gefunden ist gelöscht. Hatte ich übersehen."); +await schreib(raeume.kanal, wer("Marina").keks, "Ach man \u{1F97A}\u{1F97A}"); +await schreib(raeume.gruppe, wer("Diene").keks, + "Guten Morgen ihr Lieben ich schaffe es heute leider nicht."); +await schreib(raeume.BananaStift, wer("BananaStift").keks, + "Community Talk \u{1F6EB} fällt der heute aus?"); +await schreib(raeume.Patrick, keksIch, "Alles klar"); + +/* ---- Bilder ---------------------------------------------------------- */ +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +try { + for (const [wie, breite, hoehe] of [["breit", 1280, 900], ["handy", 390, 860]]) { + const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } }); + await kontext.addCookies(keksIch.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); }); + await seite.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" }); + await seite.waitForSelector("#raeume .chat-raum", { timeout: 15_000 }); + await seite.waitForTimeout(700); + + const ziel = await seite.$(wie === "breit" ? ".chat" : ".chat__spalte--liste"); + await ziel.screenshot({ path: `server/mess-chat-liste-${wie}.png` }); + console.log(` -- Bild (${breite} px): server/mess-chat-liste-${wie}.png`); + + if (wie === "breit") { + const z = await seite.evaluate(() => + [...document.querySelectorAll("#raeume .chat-raum")].map((li) => { + const k = li.querySelector(".chat-raum__knopf"); + return { + name: li.querySelector(".chat-raum__name")?.textContent || "", + art: li.dataset.art || "", + neu: li.dataset.neu || "-", + zahl: li.querySelector(".chat-raum__zahl")?.textContent || "", + at: li.querySelector(".chat-raum__erwaehnt") ? "@" : " ", + bilder: li.querySelectorAll(".chat-raum__zeichen img").length, + hoch: Math.round(k.getBoundingClientRect().height), + }; + })); + console.log(""); + for (const x of z) { + console.log(` ${x.name.padEnd(18)} ${x.art.padEnd(7)} ` + + `${x.at} ${String(x.zahl).padStart(3)} ${x.hoch}px Bilder: ${x.bilder}`); + } + const hoehen = [...new Set(z.map((x) => x.hoch))]; + console.log(""); + console.log(` -- Zeilenhoehen: ${hoehen.join(", ")} px ` + + `(${hoehen.length === 1 ? "alle gleich" : "UNTERSCHIEDLICH"})`); + const doppelt = z.filter((x) => x.bilder > 1); + console.log(` -- Zeilen mit doppeltem Gesicht: ${doppelt.length}`); + } + if (konsole.length) console.log(" !! Konsole: " + konsole[0]); + await kontext.close(); + } +} finally { + await browser.close(); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(0); +} diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs index 8e7f1a6c..b2a09ea9 100644 --- a/server/pruef-start-ansicht.mjs +++ b/server/pruef-start-ansicht.mjs @@ -125,6 +125,11 @@ const lesen = (seite) => seite.evaluate(() => ({ name: k.querySelector(".kachel__name")?.textContent, unter: k.querySelector(".kachel__unter")?.textContent, ton: k.dataset.ton, + /* DIE SILBERNE KACHEL ÜBERSCHREIBT IHREN TON (siehe start.css, + 23.09.2026) -- absichtlich, und nur in der Anzeige. Wer das + nicht mitliest, vergleicht ihre Silberfläche mit dem Grün ihres + Bereichs und meldet einen Fehler, den es nicht gibt. */ + silber: k.dataset.silber === "ja", hatZeichen: !!k.querySelector(".kachel__svg"), farbe: k.querySelector(".kachel__svg") ? getComputedStyle(k.querySelector(".kachel__svg")).stroke : null, @@ -547,12 +552,42 @@ for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { /* Und zwar wirklich DIESELBE Farbe, an echten Bildpunkten gemessen -- "hat einen Ton" reicht nicht, sonst faellt nicht auf, wenn alle Zeilen denselben tragen. */ + /* ==== EINE AUSNAHME, UND SIE IST BEGRÜNDET (24.09.2026) ========== + + Diese Zeile war rot, seit „Aufgaben" am 23.09.2026 die silberne + Kachel bekommen hat -- und es war kein Fehler. Die Kachel + überschreibt ihr `--ton` absichtlich (start.css: „Filipe hat nach + einer silbernen KACHEL gefragt, nicht nach einer silbernen + Füllung"). Der Hinweis in „Was ansteht" trägt weiter das Grün + SEINES BEREICHS, und das ist richtig: Er gehört zum Bereich + Aufgaben, nicht zur Gestaltung dieser einen Kachel. + + Damit ist es dasselbe Muster wie bei den acht Prüfungen vom + 23./24.09.: Die Prüfung ist nicht falsch geworden, sie ist + UNZUSTÄNDIG geworden -- eine Zusage, die jemand bewusst + zurückgenommen hat, und sie fordert sie weiter ein. Eine rote + Prüfung, die niemand mehr liest, sichert nichts. + + STATT SIE ZU LOCKERN, WIRD SIE GENAUER: Die Silberkachel fällt + aus dem Farbvergleich heraus -- und bekommt dafür ihre eigene + Prüfung darunter. */ + const silberToene = new Set(r.kacheln.filter((k) => k.silber).map((k) => k.ton)); const kachelFarbe = new Map(r.kacheln.map((k) => [k.ton, k.farbe])); const falsch = r.dranZeilen.filter((z) => z.stufe !== "warnung") .filter((z) => z.ton !== null && z.zahlFarbe !== null) + .filter((z) => !silberToene.has(z.ton)) .filter((z) => z.zahlFarbe !== kachelFarbe.get(z.ton)); ok(falsch.length === 0, `und die Farbe ist die der Kachel (${falsch.map((z) => `${z.ton}: ${z.zahlFarbe} statt ${kachelFarbe.get(z.ton)}`).join(" | ") || "alle"})`); + /* UND DIE AUSNAHME WIRD SELBST GEPRÜFT. Ohne diese zwei Zeilen wäre + „ausgenommen" eine Lücke: Jede Kachel könnte sich `data-silber` + anhängen und dem Farbvergleich entkommen. */ + const silber = r.kacheln.filter((k) => k.silber); + ok(silber.length <= 1, + `höchstens EINE silberne Kachel (${silber.length}: ${silber.map((k) => k.name).join(", ") || "keine"})`); + ok(silber.every((k) => !!k.ton), + `und sie trägt weiterhin ihre Tonnummer (${silber.map((k) => k.ton).join(",") || "—"}) ` + + "– die Farbwerkzeuge rechnen mit Zahlen, nicht mit dem, was man sieht"); /* Ueberfaellig schlaegt Bereichsfarbe -- dort muss Rot stehen, sonst geht die Dringlichkeit in siebzehn Farben unter. */ const warnZeilen = r.dranZeilen.filter((z) => z.stufe === "warnung"); diff --git a/server/pruef-unterstuetzung.mjs b/server/pruef-unterstuetzung.mjs new file mode 100644 index 00000000..eeb635d5 --- /dev/null +++ b/server/pruef-unterstuetzung.mjs @@ -0,0 +1,503 @@ +/* ===================================================================== + UNTERSTÜTZEN — sieht jeder, ändert nur einer (24.09.2026) + + Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie + verändern können oder vieles mehr sehen. alle anderen, rechte und + linke hand, modis und community sollen supporten können." + + --------------------------------------------------------------------- + AUF DIESER SEITE STEHT EIN ZAHLUNGSLINK. Das macht sie zur einzigen + im Haus, bei der ein Rechtefehler unmittelbar Geld kostet: Wer die + Adresse ändern darf, kann sie auf ein fremdes Konto zeigen lassen, + und es fällt niemandem auf. Deshalb wird hier nicht geprüft, ob die + Knöpfe fehlen — sondern ob der Server ABLEHNT. + + --------------------------------------------------------------------- + GEMESSEN WIRD ÜBER DIE CREW-ADRESSE, NICHT ÜBER 127.0.0.1 + + Das ist keine Formsache. Welches „Haus" eine Anmeldung betrifft, + leitet der Server aus dem `Host`-Kopf ab (`istCrewAdresse`). Über + 127.0.0.1 landet man still im Agenturhaus — und dort gibt es die + Rolle `gast` gar nicht. Beim ersten Lauf dieser Datei waren deshalb + vier Rollen grün und der Zuschauer rot, und es sah nach einem + Rechtefehler aus. Es war ein MESSFEHLER: Ich hatte die falsche + Adresse gemessen. + + `fetch` kann das nicht: `Host` ist ein verbotener Kopf und wird + verworfen. Deshalb `node:http` für die Schnittstelle und ein + HTTPS-Vorbau plus `--host-resolver-rules` für den Browser — + dasselbe Vorgehen wie in pruef-alter und pruef-chat-neu. + + --------------------------------------------------------------------- + SECHS ABSCHNITTE + + 1. Jede Rolle kommt auf die Seite und sieht die sichtbaren Wege. + 2. NUR DogFather darf ändern. Die rechte Hand ausdrücklich NICHT — + sie führt das Team mit, und diese eine Tür bleibt trotzdem zu. + 3. Eine Adresse wird geprüft, bevor sie in der Datenbank steht: + `javascript:`, `data:` und `http://` kommen nicht durch. + 4. Was unsichtbar ist, wird auch nicht ausgeliefert — nicht + versteckt im Browser, sondern gar nicht erst geschickt. + 5. Die Zählung ist anonym, und zwar von der TABELLE her: Es gibt + keine Spalte für eine Person. Geprüft über PRAGMA table_info, + nicht über eine Abfrage — eine Abfrage ließe sich ändern. + 6. Die Seite steht im Browser und die Kachel im Community-Bereich. + + MIT GEGENPROBEN: Jede Ablehnung wird gegen einen Fall gehalten, der + durchgehen MUSS. Sonst hieße „abgelehnt" vielleicht nur, dass gar + nichts geht. + + Aufrufen mit: node server/pruef-unterstuetzung.mjs + Bild: SCHIRM=1 node server/pruef-unterstuetzung.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { request as httpAnfrage } from "node:http"; +import { createServer as httpsServer } from "node:https"; +import { eigenerPort } from "./helfer-port.mjs"; + +const PORT = await eigenerPort(import.meta, "pruef-unterstuetzung"); +const HP = PORT + 1; +const CREW = "crew.dogfather-universe.com"; +const ordner = mkdtempSync(join(tmpdir(), "ws-unter-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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(240_000, "pruef-unterstuetzung"); +await new Promise((r) => setTimeout(r, 800)); + +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++; }; + +/* ---- Sprechen über die Crew-Adresse ---------------------------------- */ +function anfrage(pfad, art, koerper, keks) { + return new Promise((fertig, schief) => { + const kopf = { Host: CREW }; + if (keks) kopf.Cookie = keks; + if (koerper) kopf["Content-Type"] = "application/json"; + const a = httpAnfrage( + { host: "127.0.0.1", port: PORT, path: pfad, method: art, headers: 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 null; } }; + +/* ---- Der Bestand ----------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +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); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/* Die fünf Rollen, die Filipe genannt hat — DogFather, rechte und + linke Hand, ein Modi und die Community. */ +const ROLLEN = [ + ["Filipe", "admin", "CODE-DOGI-0001"], + ["VanVan", "hand", "CODE-HAND-0001"], + ["Kessi", "linke", "CODE-LINKE-0001"], + ["Diene", "modi", "CODE-MODI-0001"], + ["Zuschauer", "gast", "CODE-GAST-0001"], +]; +for (const [name, rolle, code] of ROLLEN) anlegen(name, rolle, code); + +/* Die Spalten — Abschnitt 5 prüft dagegen. Gelesen aus dem SCHEMA, + nicht aus einer Abfrage: Eine Abfrage ließe sich morgen erweitern, + eine fehlende Spalte nicht. */ +const STRICH_SPALTEN = d.prepare("PRAGMA table_info(unterstuetzung_striche)") + .all().map((z) => z.name); +const WEG_SPALTEN = d.prepare("PRAGMA table_info(unterstuetzung_wege)") + .all().map((z) => z.name); +d.close(); + +async function anmelden(rolle, code) { + /* `alter_ok` NUR BEI DER COMMUNITY: Sie bestätigt beim ersten + Hereinkommen das Mindestalter (workspace.js). Wer zum Team gehört, + hat seinen Zugang persönlich bekommen. */ + const a = await anfrage("/workspace/api/anmelden", "POST", + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); + if (a.code !== 200) { + /* DER DRITTE AUSGANG: Ohne Anmeldung kann diese Prüfung gar nichts + beantworten — und das ist etwas anderes als „nicht in Ordnung". */ + melde(` ABBRUCH: ${rolle} ließ sich nicht anmelden (HTTP ${a.code}, ` + + `${a.text.slice(0, 90)}). Ohne Anmeldung misst diese Prüfung nichts.`); + process.exit(2); + } + return (a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); +} +const kekse = {}; +for (const [, rolle, code] of ROLLEN) kekse[rolle] = await anmelden(rolle, code); + +const lies = async (keks) => { + const a = await anfrage("/workspace/api/unterstuetzung", "GET", null, keks); + return { status: a.code, daten: a.code === 200 ? json(a) : null }; +}; +const schreib = (keks, id, koerper, art = "PUT") => + anfrage(`/workspace/api/unterstuetzung${id ? "/" + id : ""}`, art, koerper, keks); + +/* ===================================================================== + 1. JEDE ROLLE SIEHT DIE SEITE + ===================================================================== */ +melde(""); +melde("=== 1. Sieht jeder die Wege? ==="); +const gesehen = {}; +for (const [, rolle] of ROLLEN) { + const r = await lies(kekse[rolle]); + gesehen[rolle] = r.daten; + ok(r.status === 200 && Array.isArray(r.daten?.wege), + `${rolle}: bekommt die Wege (HTTP ${r.status}, ${r.daten?.wege?.length ?? "?"} Stück)`); +} +ok(gesehen.gast?.wege.length > 0, `und es sind wirklich welche da (${gesehen.gast?.wege.length})`); +/* Die Wunschliste ist der Weg, den Filipe mitgeliefert hat — sie muss + bei JEDEM ankommen, nicht nur bei der Leitung. */ +const wunsch = gesehen.gast?.wege.find((w) => w.schluessel === "amazon"); +ok(!!wunsch && wunsch.ziel.startsWith("https://www.amazon.de/"), + `die Wunschliste steht auch beim Zuschauer (${wunsch?.ziel.slice(0, 42)}…)`); + +/* GEGENPROBE ZUM GANZEN ABSCHNITT: Ohne Anmeldung gibt es nichts. */ +{ + const a = await anfrage("/workspace/api/unterstuetzung", "GET"); + ok(a.code === 401, `ohne Anmeldung gar nichts (HTTP ${a.code})`); +} + +/* ===================================================================== + 2. ÄNDERN DARF NUR EINER + ===================================================================== */ +melde(""); +melde("=== 2. Wer darf ändern? ==="); +ok(gesehen.admin?.darf_aendern === true, "DogFather: darf_aendern = true"); +for (const rolle of ["hand", "linke", "modi", "gast"]) { + ok(gesehen[rolle]?.darf_aendern === false, `${rolle}: darf_aendern = false`); +} + +const amazonId = gesehen.admin.wege.find((w) => w.schluessel === "amazon").id; +/* UND ZWAR NICHT NUR IM BROWSER. Jede der vier Rollen versucht es + wirklich — eine Oberfläche, die den Knopf versteckt, ist keine + Sperre, sondern eine Bitte. */ +for (const rolle of ["hand", "linke", "modi", "gast"]) { + const a = await schreib(kekse[rolle], amazonId, { titel: "Übernommen" }); + ok(a.code === 404, `${rolle}: PUT wird abgewiesen (HTTP ${a.code})`); +} +{ + const a = await schreib(kekse.hand, amazonId, null, "DELETE"); + ok(a.code === 404, `die rechte Hand kann auch nicht löschen (HTTP ${a.code})`); + const b = await schreib(kekse.hand, null, { titel: "Eigener Weg" }, "POST"); + ok(b.code === 404, `und keinen neuen anlegen (HTTP ${b.code})`); +} +/* GEGENPROBE: DogFather kommt durch. Ohne sie hieße „404 für alle" + vielleicht nur, dass es die Route gar nicht gibt. */ +{ + const a = await schreib(kekse.admin, amazonId, { titel: "Wunschliste" }); + ok(a.code === 200, `DogFather kommt durch (HTTP ${a.code})`); + const nach = await lies(kekse.gast); + ok(nach.daten.wege.find((w) => w.id === amazonId)?.titel === "Wunschliste", + "und die Änderung kommt bei allen an"); +} + +/* ===================================================================== + 3. DIE ADRESSE WIRD GEPRÜFT + ===================================================================== */ +melde(""); +melde("=== 3. Was darf in eine Adresse? ==="); +const BOESE = [ + ["javascript:alert(1)", "ein Skript"], + ["data:text/html,

    x", "eine ganze Seite im Link"], + ["http://example.com/pay", "unverschlüsselt"], + ["//example.com/pay", "ohne Schema"], +]; +for (const [ziel, warum] of BOESE) { + const a = await schreib(kekse.admin, amazonId, { ziel, knopf: "Los" }); + ok(a.code === 400, `abgelehnt: ${warum} (${ziel.slice(0, 24)} → HTTP ${a.code})`); +} +/* GEGENPROBE: Die beiden erlaubten Formen gehen durch. */ +const AMAZON = "https://www.amazon.de/hz/wishlist/ls/3BGGNNHYAO5QL"; +{ + const a = await schreib(kekse.admin, amazonId, + { ziel: AMAZON, knopf: "Wunschliste ansehen" }); + ok(a.code === 200, `https:// geht durch (HTTP ${a.code})`); + const b = await schreib(kekse.admin, + gesehen.admin.wege.find((w) => w.schluessel === "draussen").id, + { ziel: "unsere-seiten.html", knopf: "Zu Draußen" }); + ok(b.code === 200, `eine Seite des Hauses auch (HTTP ${b.code})`); +} +/* EIN KNOPF OHNE ZIEL IST EIN VERSPRECHEN INS LEERE. */ +{ + const a = await schreib(kekse.admin, amazonId, { ziel: "", knopf: "Drück mich" }); + ok(a.code === 400, `Knopf ohne Adresse wird abgelehnt (HTTP ${a.code})`); + await schreib(kekse.admin, amazonId, { ziel: AMAZON, knopf: "Wunschliste ansehen" }); +} + +/* ===================================================================== + 4. UNSICHTBAR HEISST NICHT AUSGELIEFERT + ===================================================================== */ +melde(""); +melde("=== 4. Was unsichtbar ist ==="); +{ + const beiAdmin = (await lies(kekse.admin)).daten.wege; + const beiGast = (await lies(kekse.gast)).daten.wege; + const paypal = beiAdmin.find((w) => w.schluessel === "paypal"); + ok(!!paypal, "DogFather sieht den PayPal-Weg"); + ok(paypal.sichtbar === false && paypal.ziel === "", + "er steht ohne Adresse da und ist ausgeblendet — eine Zahlungsadresse wird nicht geraten"); + ok(!beiGast.some((w) => w.schluessel === "paypal"), + "und beim Zuschauer kommt er gar nicht erst an"); + ok(beiAdmin.length > beiGast.length, + `DogFather sieht mehr (${beiAdmin.length} gegen ${beiGast.length})`); + ok(beiGast.every((w) => w.sichtbar), "beim Zuschauer ist alles Gelieferte auch sichtbar"); + ok((await lies(kekse.admin)).daten.zahlen !== null, "DogFather bekommt die Zahlen"); + for (const rolle of ["hand", "linke", "modi", "gast"]) { + ok((await lies(kekse[rolle])).daten.zahlen === null, `${rolle}: keine Zahlen`); + } +} + +/* ===================================================================== + 5. DIE ZÄHLUNG IST ANONYM — VON DER TABELLE HER + ===================================================================== */ +melde(""); +melde("=== 5. Zählt jemand mit? ==="); +ok(STRICH_SPALTEN.length > 0, + `die Zähltabelle hat ${STRICH_SPALTEN.length} Spalten (${STRICH_SPALTEN.join(", ")})`); +const VERDAECHTIG = ["person_id", "person", "von_id", "ip", "sitzung", "name", "wer"]; +ok(!STRICH_SPALTEN.some((s) => VERDAECHTIG.includes(s)), + "keine Spalte für eine Person, eine IP oder eine Sitzung"); +/* GEGENPROBE: Derselbe Sucher findet so etwas auch wirklich — die + Wege-Tabelle hat `geaendert_von`, und das MUSS er sehen. Ohne diese + Zeile hieße „nichts gefunden" vielleicht nur, dass er nicht sucht. */ +ok(WEG_SPALTEN.includes("geaendert_von"), + "Gegenprobe: in der Wege-Tabelle findet dieselbe Suche sehr wohl eine Person (geaendert_von)"); + +{ + const zuvor = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0; + const a = await anfrage(`/workspace/api/unterstuetzung/${amazonId}/strich`, + "POST", null, kekse.gast); + ok(a.code === 200, `ein Zuschauer darf zählen (HTTP ${a.code})`); + const danach = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0; + ok(danach === zuvor + 1, `und der Strich kommt an (${zuvor} → ${danach})`); + /* EINMAL JE TAG. Ohne diese Sperre wäre die Zahl das Ergebnis von + Dauerdrücken und keine Auskunft. */ + await anfrage(`/workspace/api/unterstuetzung/${amazonId}/strich`, "POST", null, kekse.gast); + const nochmal = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0; + ok(nochmal === danach, `zweimal derselbe zählt nicht doppelt (${danach} → ${nochmal})`); + /* Aber ein ANDERER schon — sonst wäre die Sperre zu breit. */ + await anfrage(`/workspace/api/unterstuetzung/${amazonId}/strich`, "POST", null, kekse.modi); + const dritter = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0; + ok(dritter === nochmal + 1, `ein anderer Mensch zählt sehr wohl (${nochmal} → ${dritter})`); +} +/* EIN UNSICHTBARER WEG WIRD NICHT GEZÄHLT — sonst stünde eine Zahl an + einem Weg, den außer DogFather niemand sehen kann. */ +{ + const paypalId = (await lies(kekse.admin)).daten.wege + .find((w) => w.schluessel === "paypal").id; + const a = await anfrage(`/workspace/api/unterstuetzung/${paypalId}/strich`, + "POST", null, kekse.gast); + ok(a.code === 404, `ein ausgeblendeter Weg lässt sich nicht zählen (HTTP ${a.code})`); +} + +/* ===================================================================== + 6. DIE SEITE UND DIE KACHEL + ===================================================================== + + Der Browser spricht über die Crew-Adresse, sonst misst er das + Agenturhaus (siehe den Kopf). Dafür ein HTTPS-Vorbau mit einem + selbst ausgestellten Zeugnis und `--host-resolver-rules` — dasselbe + Vorgehen wie in pruef-chat-neu. */ +melde(""); +melde("=== 6. Im Browser ==="); +const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl, + "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], +{ stdio: "ignore" }); +const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, (von, nach) => { + const w = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HP}` }, + }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); }); + von.pipe(w); +}); +await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r)); +const SEITE = `https://${CREW}:${HP}`; + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], +}); +try { + for (const rolle of ["admin", "gast"]) { + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + await kontext.addCookies(kekse[rolle].split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: CREW, path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { + if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) { + konsole.push(e.text().slice(0, 120)); + } + }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + String(e).slice(0, 120))); + + /* DIE KACHEL — ohne sie findet niemand die Seite. */ + await seite.goto(`${SEITE}/workspace/start.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + const kachel = await seite.evaluate(() => { + const a = [...document.querySelectorAll('a[href^="unterstuetzen.html"]')][0]; + if (!a) return null; + return { + name: a.querySelector(".kachel__name")?.textContent + || a.textContent.trim().slice(0, 40), + zeichen: !!a.querySelector("svg"), + }; + }); + ok(!!kachel, `${rolle}: die Kachel steht auf der Startseite (${kachel?.name || "—"})`); + ok(!!kachel?.zeichen, `${rolle}: und sie trägt ihr Zeichen`); + + await seite.goto(`${SEITE}/workspace/unterstuetzen.html`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".u-weg", { timeout: 15_000 }); + await seite.waitForTimeout(500); + + const b = await seite.evaluate(() => ({ + karten: document.querySelectorAll(".u-weg").length, + gruppen: [...document.querySelectorAll(".u-gruppe__titel")].map((x) => x.textContent), + leitung: !document.getElementById("leitung")?.hidden, + knoepfe: [...document.querySelectorAll(".u-weg__knopf")].map((a) => ({ + ziel: a.getAttribute("href"), rel: a.getAttribute("rel") || "", + neu: a.getAttribute("target") || "", + hoch: Math.round(a.getBoundingClientRect().height), + })), + })); + ok(b.karten >= 4, `${rolle}: ${b.karten} Wege stehen da`); + ok(b.gruppen.length === 2, `${rolle}: zwei Gruppen (${b.gruppen.join(" / ")})`); + ok(b.leitung === (rolle === "admin"), + `${rolle}: der Leitungskasten steht ${rolle === "admin" ? "da" : "NICHT da"}`); + + /* JEDER WEG NACH DRAUSSEN BRAUCHT `noopener` — ohne ihn kann die + geöffnete Seite über `window.opener` auf diese zugreifen. */ + const raus = b.knoepfe.filter((k) => /^https:/.test(k.ziel)); + ok(raus.length > 0, `${rolle}: ${raus.length} Weg(e) führen nach draußen`); + ok(raus.every((k) => k.rel.includes("noopener") && k.rel.includes("noreferrer")), + `${rolle}: und alle tragen rel="noopener noreferrer"`); + ok(raus.every((k) => k.neu === "_blank"), `${rolle}: und öffnen ein neues Fenster`); + /* AM FINGER TREFFBAR. Dieser Knopf führt zu einer Zahlung; ihn zu + verfehlen ist ärgerlicher als anderswo. */ + ok(b.knoepfe.length > 0 && b.knoepfe.every((k) => k.hoch >= 44), + `${rolle}: jeder Knopf ist mindestens 44 px hoch ` + + `(${b.knoepfe.map((k) => k.hoch).join(", ")})`); + + /* ==== DER WEG, DEN FILIPE WIRKLICH GEHEN WIRD ================ + + Sein PayPal-Link steht nirgends im Haus ("mein paypal kennst du + ja schon" -- gesucht, nicht gefunden), und geraten wird er + nicht. Er traegt ihn also selbst ein, hier, auf dieser Seite. + + GENAU DIESER ABLAUF WIRD GEMESSEN und nicht nur die Route + darunter: Knopf druecken, Felder ausfuellen, speichern, und + danach muss der Weg bei einem ZUSCHAUER ankommen. Eine Route, + die 200 sagt, waehrend das Formular das falsche Feld schickt, + waere gruen und trotzdem kaputt. */ + if (rolle === "admin") { + await seite.click("#aendern-an"); + await seite.waitForTimeout(400); + const felder = await seite.evaluate(() => + document.querySelectorAll('.u-weg .u-form [data-feld="ziel"]').length); + ok(felder >= 4, `Änderungsmodus: ${felder} Karten zeigen ihre Felder`); + + const getippt = await seite.evaluate(() => { + const karte = [...document.querySelectorAll(".u-weg")] + .find((k) => k.dataset.schluessel === "paypal"); + if (!karte) return false; + const setz = (feld, wert) => { + const e = karte.querySelector(`[data-feld="${feld}"]`); + if (!e) return; + if (e.type === "checkbox") e.checked = wert; else e.value = wert; + }; + setz("ziel", "https://www.paypal.com/paypalme/beispiel"); + setz("knopf", "Bei PayPal öffnen"); + setz("sichtbar", true); + [...karte.querySelectorAll("button")] + .find((b) => b.textContent === "Speichern")?.click(); + return true; + }); + ok(getippt, "die PayPal-Karte lässt sich ausfüllen"); + await seite.waitForTimeout(900); + + const jetztDa = await seite.evaluate(() => { + const k = [...document.querySelectorAll(".u-weg")] + .find((x) => x.dataset.schluessel === "paypal"); + return { aus: k?.dataset.aus || "", offen: !!k }; + }); + ok(jetztDa.offen && jetztDa.aus === "", "und steht danach nicht mehr ausgeblendet da"); + + /* UND JETZT DER PUNKT: Kommt sie beim Zuschauer an? */ + const beimGast = (await lies(kekse.gast)).daten.wege + .find((w) => w.schluessel === "paypal"); + ok(!!beimGast && beimGast.ziel === "https://www.paypal.com/paypalme/beispiel" + && beimGast.knopf === "Bei PayPal öffnen", + `der Zuschauer sieht den Weg jetzt (${beimGast?.knopf || "—"})`); + + /* ZURUECK IN DEN ANZEIGEMODUS -- das Bildschirmfoto soll zeigen, + was Filipe im Alltag sieht, nicht das Formular. */ + await seite.click("#aendern-an"); + await seite.waitForTimeout(500); + } + + if (rolle === "admin" && process.env.SCHIRM) { + await seite.screenshot({ path: "server/pruef-unterstuetzung.png" }); + melde(" -- Bildschirmfoto: server/pruef-unterstuetzung.png"); + } + ok(konsole.length === 0, konsole.length + ? `${rolle}: Fehler in der Konsole: ${konsole[0]}` + : `${rolle}: keine Fehler in der Konsole`); + await kontext.close(); + } +} finally { + await browser.close(); + try { vorbau.close(); } catch { /* egal */ } +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index b54d8ffb..bdb3690b 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -472,6 +472,23 @@ export const SEITEN = { "/workspace/support.html": ["gast", "modi", "creator", "scout", "manager", "linke", "hand", "spicy", "admin"], + /* UNTERSTÜTZEN (24.09.2026). + + Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie + verändern können oder vieles mehr sehen. alle anderen, rechte und + linke hand, modis und community sollen supporten können." + + Die Seite steht damit JEDER Rolle offen — was jemand darauf tun + darf, entscheidet workspace-unterstuetzung.js und nicht diese + Tafel. Sie beantwortet nur, wer die Tür aufbekommt. + + ALLE ROLLEN AUSGESCHRIEBEN, dieselbe Überlegung wie beim Support + eine Zeile darüber: Diese Tafel ist die Stelle, an der man + nachsieht, wer wohin darf. Eine Abkürzung hier hieße, dass die + Seite in „Wer sieht was" als Lücke erscheint. */ + "/workspace/unterstuetzen.html": ["gast", "modi", "creator", "scout", "manager", + "linke", "hand", "spicy", "admin"], + /* WER SIEHT WAS. DogFather und die rechte Hand (Filipe, 11.09.2026: "ich will die kategorie auch auf der team dogi seite fuer die dogfather und rechte hand rollen"). diff --git a/server/unterstuetzung-tabellen.js b/server/unterstuetzung-tabellen.js new file mode 100644 index 00000000..868cab3c --- /dev/null +++ b/server/unterstuetzung-tabellen.js @@ -0,0 +1,179 @@ +/* ===================================================================== + UNTERSTÜTZEN — Tabellen (24.09.2026) + + Filipe: „ich brauch auch noch eine kachel im community bereich. wo + mein paypal und meine amazon liste sein wird. wo die leute alle + supporten können auf andere art anstatt nur tiktok. … jeder soll + diese kachel sehen aber nur dogfather soll sie verändern können + oder vieles mehr sehen. alle anderen, rechte und linke hand, modis + und community sollen supporten können." + + --------------------------------------------------------------------- + WARUM DIE WEGE IN DER DATENBANK STEHEN UND NICHT IM QUELLTEXT + + Es wäre kürzer gewesen, PayPal und die Wunschliste als zwei feste + Einträge in eine Datei zu schreiben. Drei Gründe dagegen, und der + dritte ist der entscheidende: + + 1. EIN LINK ÄNDERT SICH. Eine Wunschliste wird neu angelegt, ein + Zahlungsweg kommt dazu. Stünde er im Quelltext, hinge jede + Änderung an einem Deploy — und damit an mir. + 2. FILIPE SOLL IHN SELBST ÄNDERN KÖNNEN. Genau das hat er + verlangt („nur dogfather soll sie verändern können"). Ein Recht, + das man nur über einen Entwickler ausüben kann, ist keines. + 3. UND DER PAYPAL-LINK STEHT NIRGENDS IM HAUS. Filipe schrieb + „mein paypal kennst du ja schon" — gesucht habe ich, gefunden + nicht. Ihn zu RATEN wäre der schlimmste Fehler dieser ganzen + Seite: Ein falscher Zahlungslink schickt Geld an einen + Fremden, und niemand merkt es. Deshalb steht dort am Anfang + nichts, der Weg ist unsichtbar, und die Seite sagt DogFather + (und nur ihm), dass dort noch etwas fehlt. + + --------------------------------------------------------------------- + DREI ARTEN, UND DIE DRITTE IST DIE WICHTIGSTE + + geld Direkt Geld — PayPal. + geschenk Etwas schenken — die Wunschliste. + frei Kostet nichts. + + „frei" ist nicht Beiwerk. Eine Seite, die nur zwei Wege zum Zahlen + zeigt, liest sich wie eine hohle Hand — und die meisten, die hier + lesen, sind Zuschauer ohne Geld übrig. Wer gar nichts geben kann, + soll trotzdem etwas finden, das er tun kann, statt die Seite mit + einem schlechten Gefühl zu schließen. Deshalb stehen die freien + Wege gleichberechtigt daneben und nicht als Trostpflaster darunter. + + --------------------------------------------------------------------- + DIE ZÄHLUNG IST ANONYM — UND ZWAR VON DER TABELLE HER + + „nur dogfather soll … vieles mehr sehen": Er sieht, wie oft ein Weg + geöffnet wurde. Was er NICHT sieht, ist WER — und das liegt nicht an + einer Abfrage, die man später ändern könnte, sondern daran, dass es + in dieser Tabelle keine Spalte dafür gibt. + + Das ist Absicht und keine Nachlässigkeit. „Wer hat auf PayPal + gedrückt und dann doch nichts geschickt" ist eine Frage, die + niemandem nützt und mehrere Leute beschämt. Sie lässt sich hier + nicht beantworten, auch nicht von mir, auch nicht mit Datenbank- + zugriff. + + Gezählt wird je Weg und je TAG. Der Tag reicht für „läuft das + überhaupt an?" und ergibt bei einer Community dieser Größe keine + Spur zu einzelnen Menschen. + ===================================================================== */ + +/** Wie lange die Tagesstriche stehen bleiben. + * + * Ein Jahr: Lang genug, um Weihnachten mit Weihnachten zu + * vergleichen, kurz genug, dass hier nichts für immer liegt. Aufgeräumt + * wird im Rundgang, nicht beim Lesen — sonst hinge das Löschen davon + * ab, dass jemand die Seite öffnet. */ +export const ZAEHLUNG_TAGE = 365; + +/** Die drei Arten. Steht hier und nicht in der Oberfläche: Der Server + * prüft dagegen, und eine zweite Liste im Browser wäre die, die beim + * nächsten Zuwachs vergessen wird. */ +export const UNTERSTUETZUNG_ARTEN = { + geld: "Direkt", + geschenk: "Schenken", + frei: "Kostet nichts", +}; + +/* Die Startbelegung. Sie wird EINMAL eingesetzt (INSERT OR IGNORE über + den Schlüssel) und danach nie wieder angefasst — wer einen Weg + löscht, soll ihn nicht beim nächsten Serverstart wiederfinden. + + DER PAYPAL-WEG KOMMT OHNE ADRESSE UND UNSICHTBAR. Siehe oben: Raten + ist hier die gefährlichste aller Abkürzungen. */ +const START_WEGE = [ + { + schluessel: "paypal", art: "geld", platz: 10, sichtbar: 0, + titel: "PayPal", + text: "Direkt und ohne Umweg. Auch ein kleiner Betrag hilft – " + + "und niemand muss etwas geben, um dazuzugehören.", + knopf: "Bei PayPal öffnen", + ziel: "", + }, + { + schluessel: "amazon", art: "geschenk", platz: 20, sichtbar: 1, + titel: "Wunschliste", + text: "Etwas Konkretes schenken statt Geld – die Liste sagt, " + + "was gerade gebraucht wird.", + knopf: "Wunschliste ansehen", + ziel: "https://www.amazon.de/hz/wishlist/ls/3BGGNNHYAO5QL?ref_=wl_share", + }, + { + schluessel: "dabei", art: "frei", platz: 30, sichtbar: 1, + titel: "Einfach da sein", + text: "Im Live dabei sein, mitreden, jemanden begrüßen, der neu ist. " + + "Das kostet nichts und ist am meisten wert.", + knopf: "", ziel: "", + }, + { + schluessel: "weitersagen", art: "frei", platz: 40, sichtbar: 1, + titel: "Weitersagen", + text: "Einen Clip teilen oder jemandem von hier erzählen. " + + "Neue Leute kommen über euch, nicht über Werbung.", + knopf: "", ziel: "", + }, + { + schluessel: "draussen", art: "frei", platz: 50, sichtbar: 1, + titel: "Wo es Dogi sonst gibt", + text: "Kanäle, Shop und dein Rabatt stehen auf einer eigenen Seite.", + knopf: "Zu „Draußen“", + ziel: "unsere-seiten.html", + }, +]; + +export function unterstuetzungTabellen(d) { + d.exec(` + CREATE TABLE IF NOT EXISTS unterstuetzung_wege ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + /* Der Schlüssel ist die Kennung für die Startbelegung und für + die Zählung. Er ändert sich nie — der TITEL darf sich ändern, + ohne dass die Striche der letzten Monate zu einem anderen Weg + gehören. */ + schluessel TEXT NOT NULL UNIQUE, + art TEXT NOT NULL CHECK (art IN ('geld','geschenk','frei')), + titel TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + /* Die Aufschrift des Knopfes. Leer heißt: Dieser Weg hat keinen + Knopf — es gibt Wege, die man nicht anklickt („einfach da + sein"). Ein Knopf ohne Ziel wäre ein Versprechen ins Leere. */ + knopf TEXT NOT NULL DEFAULT '', + /* Die Adresse. Darf leer sein. Geprüft wird sie beim SCHREIBEN + (nur https:// oder eine Seite dieses Hauses) — nicht erst beim + Anzeigen: Eine Adresse, die erst im Browser auffällt, steht + vorher schon in der Datenbank. */ + ziel TEXT NOT NULL DEFAULT '', + sichtbar INTEGER NOT NULL DEFAULT 1, + platz INTEGER NOT NULL DEFAULT 0, + geaendert TEXT, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + + /* KEINE SPALTE FÜR EINE PERSON. Siehe den Kopf dieser Datei — das + ist die eigentliche Zusage dieser Seite, und sie steht hier im + Schema und nicht in einer Abfrage, die jemand später ändert. */ + CREATE TABLE IF NOT EXISTS unterstuetzung_striche ( + weg_id INTEGER NOT NULL REFERENCES unterstuetzung_wege(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + anzahl INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (weg_id, tag) + ); + CREATE INDEX IF NOT EXISTS idx_unterstuetzung_tag + ON unterstuetzung_striche(tag); + `); + + /* INSERT OR IGNORE über den eindeutigen Schlüssel: Beim ersten Start + entstehen die fünf Wege, bei jedem weiteren passiert nichts. Wer + einen löscht, findet ihn nicht beim nächsten Neustart wieder — das + wäre eine Änderung, die sich von selbst zurücknimmt, und so etwas + sucht man stundenlang. */ + const setz = d.prepare(`INSERT OR IGNORE INTO unterstuetzung_wege + (schluessel, art, titel, text, knopf, ziel, sichtbar, platz) + VALUES (?,?,?,?,?,?,?,?)`); + for (const w of START_WEGE) { + setz.run(w.schluessel, w.art, w.titel, w.text, w.knopf, w.ziel, w.sichtbar, w.platz); + } +} diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index bfea3e5a..6406c01b 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -43,6 +43,10 @@ import express from "express"; zwei Wahrheiten, von denen eine veraltet. `hilfe-tabellen.js` importiert absichtlich nichts, es kann also kein Kreis entstehen. */ import { AUFBEWAHRUNG_TAGE as HILFE_TAGE, FALL_STAND } from "./hilfe-tabellen.js"; +import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js"; +import { supportAufraeumen } from "./workspace-support.js"; +import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; +import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js"; import { db, sitzungLesen, istDogFather, protokolliere, echteIp, einstellung, einstellungSetzen, @@ -262,6 +266,83 @@ export const FRISTEN = [ offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM aufgaben WHERE erledigt_am IS NOT NULL"), }, + /* ==== ZWEI EINTRÄGE, DIE HIER GEFEHLT HABEN (24.09.2026) ======== + + Beim Einbau des Unterstützen-Bereichs ist aufgefallen, dass + `supportAufraeumen()` in workspace-support.js zwar EXPORTIERT, + aber nirgends AUFGERUFEN wird — gesucht im ganzen Verzeichnis, + null Treffer. Die Meldungen samt Bildschirmfotos wären also für + immer liegen geblieben, obwohl im Kopf der Datei "90 Tage" steht. + + Das ist dieselbe Sorte Fehler wie eine Prüfung, die nie läuft: + Es sieht fertig aus, es ist dokumentiert, und es passiert nichts. + Diese Liste ist die einzige Stelle im Haus, die wirklich räumt — + wer eine Frist verspricht und hier keinen Eintrag hat, verspricht + nichts. */ + { + schluessel: "support_erledigt", + was: "Erledigte Support-Meldungen samt Bildschirmfoto", + art: "raeumen", + frist: `${SUPPORT_TAGE} Tage nach dem Erledigen`, + zweck: "Ein Fehler kommt oft wieder – dann hilft es, nachlesen zu können, " + + "was beim letzten Mal daran war.", + grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung", + wirkung: `Die Meldung verschwindet ganz, mitsamt dem Bild auf der Platte. ` + + `Offene Meldungen werden nie geräumt – nur erledigte, und erst ` + + `${SUPPORT_TAGE} Tage danach.`, + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM support_meldungen WHERE stand = 'erledigt'" + + " AND erledigt_am IS NOT NULL AND erledigt_am < ?", vorTagen(SUPPORT_TAGE)), + /* ÜBER supportAufraeumen() UND NICHT ÜBER EIN DELETE HIER: Zu + jeder Meldung gehört eine Bilddatei auf der Platte. Ein DELETE + an dieser Stelle würde die Zeile löschen und das Bild liegen + lassen — und ein Bild ohne Zeile findet danach niemand mehr. */ + raeumen: () => supportAufraeumen(), + }, + { + schluessel: "unterstuetzung_striche", + was: "Tagesstriche der Unterstützen-Seite", + art: "raeumen", + frist: `${ZAEHLUNG_TAGE} Tage`, + zweck: "Sehen, ob ein Weg überhaupt benutzt wird – und ob ein neuer anläuft.", + grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse", + wirkung: "Die Zeile verschwindet. Es steht ohnehin keine Person darin: " + + "Die Tabelle hat gar keine Spalte dafür (siehe unterstuetzung-tabellen.js) – " + + "gezählt wird je Weg und Tag, nicht je Mensch.", + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM unterstuetzung_striche WHERE tag < ?", + vorTagen(ZAEHLUNG_TAGE).slice(0, 10)), + raeumen: () => unterstuetzungAufraeumen(), + }, + { + /* AUFGABEN-ZUTEILUNG (nachgetragen 24.09.2026). + + Die Tabelle gibt es seit dem 21.09., im Löschkonzept stand sie + nicht -- gefunden von pruef-aufbewahrung, die jede Tabelle mit + einer "erledigt_am"-Spalte hier einfordert. Die Prüfung meldete + sie zusammen mit support_meldungen, und beide waren echte + Lücken: Für jede Tabelle muss JEMAND entschieden haben, ob sie + verschwindet oder bleibt. "Noch nicht bedacht" ist keine der + beiden Antworten. + + DIE ANTWORT HIER IST "BLEIBT", und sie ist begründet: Wer welche + Aufgabe übernommen, abgelehnt oder erledigt hat, ist der + Werdegang eines Menschen im Team -- dieselbe Überlegung wie bei + den Aufgaben selbst eine Zeile darüber. Sie verschwindet mit + ihrer Aufgabe (ON DELETE CASCADE) und mit der Person + (ON DELETE CASCADE), aber nicht nach einer Frist. */ + schluessel: "aufgaben_zuteilung", + was: "Wer eine Aufgabe übernommen hat", + art: "bleibt", + frist: "gehen mit der Aufgabe und mit der Person", + zweck: "Nachvollziehen, wer was übernommen hat – und was daraus wurde.", + grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse", + wirkung: "Bleiben. Wird die Aufgabe gelöscht, gehen sie mit (ON DELETE CASCADE); " + + "wird der Zugang gelöscht, ebenso. Eine eigene Frist gibt es nicht – " + + "eine Zuteilung ohne ihre Aufgabe wäre ohnehin nicht mehr lesbar.", + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM aufgaben_zuteilung WHERE erledigt_am IS NOT NULL"), + }, { schluessel: "personen_geloescht", was: "Daten einer gelöschten Person", diff --git a/server/workspace-unterstuetzung.js b/server/workspace-unterstuetzung.js new file mode 100644 index 00000000..ae10f72b --- /dev/null +++ b/server/workspace-unterstuetzung.js @@ -0,0 +1,366 @@ +/* ===================================================================== + UNTERSTÜTZEN — die Wege (24.09.2026) + + Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie + verändern können oder vieles mehr sehen. alle anderen, rechte und + linke hand, modis und community sollen supporten können." + + Warum die Wege in der Datenbank stehen und warum die Zählung keine + Personenspalte hat, steht im Kopf von unterstuetzung-tabellen.js. + Hier sind die Wege — und die vier Stellen, an denen etwas + schiefgehen kann: + + 1. EINE ADRESSE IST EIN VERSPRECHEN. Auf dieser Seite steht ein + Zahlungslink. Wer ihn ändern darf, kann Geld umleiten — deshalb + hängt das Schreiben an `istDogFather` und nicht an `istLeitung`. + Die rechte Hand führt dieses Team mit, und sie soll trotzdem + nicht an diesen Link können. Das ist kein Misstrauen, sondern + dieselbe Regel wie bei der Rechtetafel: Genau eine Tür bleibt + bei ihm. + + 2. UND SIE WIRD GEPRÜFT, BEVOR SIE IN DER DATENBANK STEHT. Erlaubt + sind `https://` und eine Seite dieses Hauses. `javascript:` wäre + ein Skript, das jeder Besucher ausführt, `http://` ein + Zahlungsweg ohne Verschlüsselung, `data:` eine ganze Seite im + Link. Geprüft wird beim SCHREIBEN: Was erst beim Anzeigen + auffällt, steht vorher schon in der Datenbank. + + 3. DER LESEWEG IST FÜR ALLE UND ZEIGT NICHT ALLES. Unsichtbare Wege + und die Zählung gehen nur an DogFather. Beides entscheidet EINE + Stelle (`darfAlles`), damit nicht zwei Antworten auf dieselbe + Frage entstehen. + + 4. EIN STRICH IST KEIN PROTOKOLL. Gezählt wird je Weg und Tag, ohne + Person. Dass derselbe Mensch nicht zehnmal zählt, merkt sich der + Server NUR im Arbeitsspeicher — nach einem Neustart ist es + vergessen, und auf der Platte steht es nie. + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, istDogFather, heuteLokal, +} from "./workspace.js"; +import { UNTERSTUETZUNG_ARTEN, ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; + +export const unterstuetzungRouter = express.Router(); + +const TITEL_MAX = 60; +const TEXT_MAX = 400; +const KNOPF_MAX = 40; +const ZIEL_MAX = 500; +const WEGE_MAX = 12; + +/** Wer ändern darf UND wer mehr sieht — dieselbe Frage, eine Antwort. + * + * Zwei getrennte Prüfungen wären zwei Gelegenheiten, eine davon zu + * lockern. Und die beiden gehören ohnehin zusammen: Wer einen Weg + * bearbeitet, muss ihn auch sehen, solange er noch unsichtbar ist. */ +const darfAlles = (person) => istDogFather(person); + +/* Gleiche Herkunft — dasselbe Muster wie überall im Haus. Ohne + Origin-Kopf (Server zu Server, Prüfungen) geht es durch; mit einem + fremden nicht. */ +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + try { + if (new URL(herkunft).host === req.get("host")) return next(); + } catch { /* unlesbar -> abweisen */ } + return res.status(403).json({ fehler: "fremde_herkunft" }); +} + +/** Nur DogFather. 404 und nicht 403: Wer nicht ändern darf, muss nicht + * erfahren, dass es die Möglichkeit gibt — dasselbe Vorgehen wie bei + * der Aufbewahrung und den Kanälen. */ +function nurDogFather(req, res, next) { + if (!darfAlles(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + next(); +} + +/* ===================================================================== + DIE ADRESSE + ===================================================================== + + Drei Formen sind erlaubt, alles andere nicht: + + "" kein Ziel (ein Weg ohne Knopf) + https://… nach draußen, verschlüsselt + seite.html[?frage] eine Seite dieses Hauses + + AUSDRÜCKLICH NICHT: `javascript:` (ein Skript, das jeder Besucher + ausführt), `data:` (eine ganze Seite im Link), `http://` (ein + Zahlungsweg im Klartext) und alles mit `//` am Anfang (das übernimmt + das Schema der Seite und sieht aus wie ein eigener Pfad). + + Geprüft wird mit `new URL()` und nicht mit einem Muster: Ein + selbstgeschriebener Ausdruck übersieht `HtTpS:` oder ein + eingeschobenes Leerzeichen, der Parser des Browsers nicht. */ +const HAUS_SEITE = /^[a-z0-9][a-z0-9-]{0,40}\.html(\?[A-Za-z0-9=&_%.-]{0,80})?$/; + +function zielPruefen(roh) { + const z = String(roh ?? "").trim(); + if (!z) return { ok: true, wert: "" }; + if (z.length > ZIEL_MAX) return { ok: false, grund: "Die Adresse ist zu lang." }; + if (HAUS_SEITE.test(z)) return { ok: true, wert: z }; + let u; + try { u = new URL(z); } catch { + return { ok: false, grund: "Das ist keine gültige Adresse. Sie muss mit https:// anfangen." }; + } + if (u.protocol !== "https:") { + return { ok: false, grund: "Nur https:// – ein Zahlungsweg ohne Verschlüsselung kommt hier nicht hin." }; + } + return { ok: true, wert: u.href }; +} + +const text = (roh, max) => String(roh ?? "").replace(/\s+/g, " ").trim().slice(0, max); + +/* ===================================================================== + LESEN — für alle + ===================================================================== */ + +unterstuetzungRouter.get("/workspace/api/unterstuetzung", (req, res) => { + try { + const alles = darfAlles(req.person); + const d = db(); + const wege = d.prepare( + `SELECT id, schluessel, art, titel, text, knopf, ziel, sichtbar, platz + FROM unterstuetzung_wege + ${alles ? "" : "WHERE sichtbar = 1"} + ORDER BY platz, id`).all(); + + /* WAS DOGFATHER MEHR SIEHT (und sonst niemand): + die unsichtbaren Wege — und wie oft geöffnet wurde. + + DREI ZEITRÄUME, weil eine einzelne Zahl nichts sagt: „48" kann + „läuft gut" oder „lief mal" heißen. Mit 7 und 30 Tagen daneben + sieht man den Unterschied. */ + let zahlen = null; + if (alles) { + const tagVor = (n) => { + const t = new Date(); t.setHours(12, 0, 0, 0); t.setDate(t.getDate() - n); + return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}` + + `-${String(t.getDate()).padStart(2, "0")}`; + }; + const roh = d.prepare( + `SELECT weg_id, + SUM(anzahl) AS gesamt, + SUM(CASE WHEN tag >= ? THEN anzahl ELSE 0 END) AS tage30, + SUM(CASE WHEN tag >= ? THEN anzahl ELSE 0 END) AS tage7 + FROM unterstuetzung_striche + GROUP BY weg_id`).all(tagVor(29), tagVor(6)); + zahlen = Object.fromEntries(roh.map((z) => [z.weg_id, { + gesamt: z.gesamt || 0, tage30: z.tage30 || 0, tage7: z.tage7 || 0, + }])); + } + + res.json({ + wege: wege.map((w) => ({ ...w, sichtbar: !!w.sichtbar })), + arten: UNTERSTUETZUNG_ARTEN, + darf_aendern: alles, + zahlen, + }); + } catch (fehler) { + console.error("[unterstuetzung] Lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + EINEN STRICH MACHEN — für alle + ===================================================================== + + Wer einen Weg öffnet, macht einen Strich. Kein Name, keine Nummer, + keine Kennung: nur Weg und Tag. + + DIE SPERRE GEGEN MEHRFACHZÄHLUNG LIEGT IM ARBEITSSPEICHER. Sie + müsste sonst eine Kennung je Mensch auf die Platte schreiben — und + damit wäre die Zusage dieser Seite gebrochen, für eine Zahl, die nur + ungefähr stimmen muss. Nach einem Neustart zählt jeder wieder einmal; + das ist der Preis, und er ist richtig herum bezahlt. */ +const heuteSchon = new Map(); // "sitzung|weg" -> Tag +unterstuetzungRouter.post("/workspace/api/unterstuetzung/:id(\\d+)/strich", + gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + const weg = db().prepare( + "SELECT id, sichtbar FROM unterstuetzung_wege WHERE id = ?").get(id); + /* Ein unsichtbarer Weg wird nicht gezählt. Sonst stünde eine Zahl + an einem Weg, den außer DogFather niemand sehen kann — und die + käme dann von ihm selbst. */ + if (!weg || (!weg.sichtbar && !darfAlles(req.person))) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const tag = heuteLokal(); + const schluessel = `${req.person?.id ?? "?"}|${id}`; + if (heuteSchon.get(schluessel) === tag) return res.json({ gezaehlt: false }); + heuteSchon.set(schluessel, tag); + /* Der Speicher wächst sonst mit jedem Menschen und jedem Weg. Bei + mehr als tausend Einträgen fängt er von vorn an — eine Zahl, + die nur ungefähr stimmen muss, verträgt das. */ + if (heuteSchon.size > 1000) heuteSchon.clear(); + + db().prepare( + `INSERT INTO unterstuetzung_striche (weg_id, tag, anzahl) VALUES (?,?,1) + ON CONFLICT(weg_id, tag) DO UPDATE SET anzahl = anzahl + 1`).run(id, tag); + res.json({ gezaehlt: true }); + } catch (fehler) { + console.error("[unterstuetzung] Strich:", fehler?.message); + /* Ein Zähler darf den Weg nach draußen nie blockieren: Der Browser + öffnet den Link ohnehin, egal was hier steht. */ + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ===================================================================== + ÄNDERN — nur DogFather + ===================================================================== */ + +unterstuetzungRouter.put("/workspace/api/unterstuetzung/:id(\\d+)", + gleicheHerkunft, nurDogFather, express.json({ limit: "8kb" }), (req, res) => { + try { + const id = Number(req.params.id); + const alt = db().prepare("SELECT * FROM unterstuetzung_wege WHERE id = ?").get(id); + if (!alt) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const titel = text(req.body?.titel ?? alt.titel, TITEL_MAX); + if (titel.length < 2) return res.status(400).json({ fehler: "Der Weg braucht einen Namen." }); + const art = String(req.body?.art ?? alt.art); + if (!UNTERSTUETZUNG_ARTEN[art]) return res.status(400).json({ fehler: "Diese Art gibt es nicht." }); + + const z = zielPruefen(req.body?.ziel ?? alt.ziel); + if (!z.ok) return res.status(400).json({ fehler: z.grund }); + + const knopf = text(req.body?.knopf ?? alt.knopf, KNOPF_MAX); + /* EIN KNOPF OHNE ZIEL IST EIN VERSPRECHEN INS LEERE. Er sieht aus + wie ein Weg und tut nichts — und weil nichts passiert, hält man + die Seite für kaputt, nicht die Eingabe für unvollständig. */ + if (knopf && !z.wert) { + return res.status(400).json({ + fehler: "Ein Knopf braucht eine Adresse – sonst passiert beim Drücken nichts.", + }); + } + /* UND EIN SICHTBARER WEG MIT ZIEL BRAUCHT EINEN KNOPF, sonst + steht die Adresse da und niemand kommt hin. */ + const sichtbar = req.body?.sichtbar === undefined + ? !!alt.sichtbar : !!req.body.sichtbar; + if (sichtbar && z.wert && !knopf) { + return res.status(400).json({ + fehler: "Zu einer Adresse gehört eine Aufschrift für den Knopf.", + }); + } + + db().prepare(`UPDATE unterstuetzung_wege + SET titel = ?, text = ?, knopf = ?, ziel = ?, art = ?, sichtbar = ?, + platz = ?, geaendert = ?, geaendert_von = ? + WHERE id = ?`) + .run(titel, text(req.body?.text ?? alt.text, TEXT_MAX), knopf, z.wert, art, + sichtbar ? 1 : 0, + Number.isInteger(Number(req.body?.platz)) ? Number(req.body.platz) : alt.platz, + new Date().toISOString(), req.person.id, id); + + /* PROTOKOLLIERT WIRD, DASS DIE ADRESSE SICH GEÄNDERT HAT — nicht, + wie sie lautet. Ein Zahlungslink gehört nicht in ein Protokoll, + das andere lesen können; dass er gewechselt hat, schon. */ + protokolliere("unterstuetzung_geaendert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${alt.schluessel}${alt.ziel !== z.wert ? " (Adresse gewechselt)" : ""}`, + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[unterstuetzung] Aendern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +unterstuetzungRouter.post("/workspace/api/unterstuetzung", + gleicheHerkunft, nurDogFather, express.json({ limit: "8kb" }), (req, res) => { + try { + const d = db(); + const { n } = d.prepare("SELECT COUNT(*) AS n FROM unterstuetzung_wege").get(); + /* EINE OBERGRENZE, und zwar eine niedrige. Zwanzig Wege sind + keine Auswahl mehr, sondern eine Wand — und die Seite soll + leicht sein. */ + if (n >= WEGE_MAX) { + return res.status(400).json({ + fehler: `Mehr als ${WEGE_MAX} Wege werden unübersichtlich. Räum erst einen weg.`, + }); + } + const titel = text(req.body?.titel, TITEL_MAX); + if (titel.length < 2) return res.status(400).json({ fehler: "Der Weg braucht einen Namen." }); + const art = String(req.body?.art ?? "frei"); + if (!UNTERSTUETZUNG_ARTEN[art]) return res.status(400).json({ fehler: "Diese Art gibt es nicht." }); + const z = zielPruefen(req.body?.ziel); + if (!z.ok) return res.status(400).json({ fehler: z.grund }); + const knopf = text(req.body?.knopf, KNOPF_MAX); + if (knopf && !z.wert) { + return res.status(400).json({ + fehler: "Ein Knopf braucht eine Adresse – sonst passiert beim Drücken nichts.", + }); + } + + /* Der Schlüssel wird ABGELEITET und nicht getippt. Er hält die + Striche der letzten Monate am Weg fest, auch wenn der Titel + sich ändert — und ein „eigen-" davor kann nie mit einem der + fünf Startschlüssel zusammenfallen. */ + const rein = titel.toLowerCase() + .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30) || "weg"; + let schluessel = "eigen-" + rein; + if (d.prepare("SELECT 1 FROM unterstuetzung_wege WHERE schluessel = ?").get(schluessel)) { + schluessel += "-" + Date.now().toString(36).slice(-4); + } + const { platz } = d.prepare( + "SELECT COALESCE(MAX(platz), 0) + 10 AS platz FROM unterstuetzung_wege").get(); + + d.prepare(`INSERT INTO unterstuetzung_wege + (schluessel, art, titel, text, knopf, ziel, sichtbar, platz, geaendert, geaendert_von) + VALUES (?,?,?,?,?,?,1,?,?,?)`) + .run(schluessel, art, titel, text(req.body?.text, TEXT_MAX), knopf, z.wert, + platz, new Date().toISOString(), req.person.id); + + protokolliere("unterstuetzung_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: schluessel, + }); + res.json({ id: d.prepare("SELECT last_insert_rowid() AS id").get().id }); + } catch (fehler) { + console.error("[unterstuetzung] Anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +unterstuetzungRouter.delete("/workspace/api/unterstuetzung/:id(\\d+)", + gleicheHerkunft, nurDogFather, (req, res) => { + try { + const id = Number(req.params.id); + const weg = db().prepare( + "SELECT schluessel FROM unterstuetzung_wege WHERE id = ?").get(id); + if (!weg) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Die Striche gehen mit (ON DELETE CASCADE). Sie ohne ihren Weg + stehen zu lassen hieße, eine Zahl aufzubewahren, zu der niemand + mehr sagen kann, wozu sie gehört. */ + db().prepare("DELETE FROM unterstuetzung_wege WHERE id = ?").run(id); + protokolliere("unterstuetzung_entfernt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: weg.schluessel, + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[unterstuetzung] Entfernen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Alte Tagesstriche wegräumen — aus dem Rundgang, nicht beim Lesen. + * + * Hinge es am Öffnen der Seite, bliebe alles liegen, solange niemand + * hinsieht. */ +export function unterstuetzungAufraeumen() { + try { + const grenze = new Date(Date.now() - ZAEHLUNG_TAGE * 86400000) + .toISOString().slice(0, 10); + const r = db().prepare("DELETE FROM unterstuetzung_striche WHERE tag < ?").run(grenze); + return r.changes || 0; + } catch { return 0; } +} diff --git a/server/workspace.js b/server/workspace.js index 16f4c144..072de5e8 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -34,6 +34,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js"; import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js"; import { hilfeTabellen } from "./hilfe-tabellen.js"; import { supportTabellen } from "./support-tabellen.js"; +import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js"; import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN, TEAM_DOGI_ROLLEN, CREW_ADRESSE } from "./crew-adresse.js"; /* Weitergereicht, damit die Fachmodule sie wie alles andere aus @@ -1410,6 +1411,31 @@ const TREFF_BEREICHE = [ zeichen: "hinaus", ton: 39, 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" }, + /* UNTERSTÜTZEN (24.09.2026). + + Filipe: "ich brauch auch noch eine kachel im community bereich. wo + mein paypal und meine amazon liste sein wird. wo die leute alle + supporten können auf andere art anstatt nur tiktok." + + SIE STEHT VOR "REGELN & HILFE" UND NACH "MITMACHEN", und das ist + die Reihenfolge des Weges: erst dazugehoeren, dann etwas + beitragen. Ganz vorne waere sie eine hohle Hand am Eingang. + + WARUM SIE KEIN BRETT IST: Die Bretter (bereich.html?b=...) sind + Listen, auf die jemand schreibt. Hier schreibt niemand — hier + stehen Wege, und genau einer darf sie aendern. Deshalb eine eigene + Seite, wie bei "Draussen" und "Regeln & Hilfe". + + TON 45 ist GERECHNET, nicht gegriffen: tools/kachel-farbe- + einzeln.mjs gegen alle 44 vorhandenen Toene. Die Zahl steht + darunter im Kommentar der CSS-Zeile. + + RASTERRECHNUNG (drei Spalten, "Willkommen" belegt zwei): Mit + dieser Kachel sind es fuer die Community 11 + 1 = 12 Plaetze, + also genau vier volle Reihen. Vorher waren es elf und damit eine + Luecke am Ende. */ + { ...TREFF_GRUPPE, name: "Unterst\u00fctzen", unter: "Dogi etwas zur\u00fcckgeben \u2013 auch ohne Geld", + zeichen: "unterstuetzen", ton: 45, ziel: "unterstuetzen.html", szene: "lounge" }, /* UNSERE SEITEN (19.09.2026) -- die einzige Kachel, die HINAUSFUEHRT. Filipe: "ich brauch im community bereich noch eine kachel, die wenn @@ -5309,6 +5335,10 @@ export function db() { kommt deshalb hierher, nicht in den Bauplan darueber. */ hilfeTabellen(d); supportTabellen(d); + /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen + (geaendert_von) und kommen deshalb hierher, nicht in den Bauplan + darüber. */ + unterstuetzungTabellen(d); umstellungen(d); _db = d; return d; diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 247e950f..ab44479d 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`. --> - - + + - +