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