From d2f02ba84e0e56c23c466ad582e24d10604390aa Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 28 Sep 2026 10:14:26 +0200 Subject: [PATCH] =?UTF-8?q?Spenden:=20Stufen=20gestaltbar,=20Bild=20hochla?= =?UTF-8?q?den,=20Gr=C3=B6=C3=9Fe=20je=20Stufe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filipes Wunsch: „mach paar fertige und so dass ich auch hochladen kann. auch so dass ich das anders gestalten kann oder die größe verändern kann. ... auch spezielle sachen bei speziellen spenden." WAS ES SCHON GAB, WAS FEHLTE Drei Stufen ab Werk, fünf gezeichnete Zeichen, Farbe und Dauer je Stufe — und sogar schon ein Feld für ein eigenes Bild. Es fehlte der Weg, das alles zu ÄNDERN: Um eine Stufe umzubenennen, hätte jemand in die Datenbank greifen müssen. DIE GRÖSSE IST DAS „SPEZIELLE BEI SPEZIELLEN SPENDEN" Je Stufe, nicht einmal für alle: Eine Rudel-Legende darf größer dastehen als ein Danke. Eine einzige Größe für alle wäre wieder eine Preisliste. Umgesetzt als EINE Schriftgröße, alles darin in `em` — nicht `transform: scale()`, denn die Karte kommt schon mit `translateX()` herein, und zwei `transform` an derselben Stelle schließen einander aus; außerdem wird Text beim Skalieren matschig. Nur nach oben (1 bis 2,5), und das ist eine ehrliche Grenze: Das kleinste Wort auf der Karte steht bei 11,52 px, die Hausgrenze ist 11,5. Ein Faktor von 0,8 machte daraus 9,2 px. Kleiner geht an der richtigen Stelle — die OBS-Tafel hat ihren eigenen Regler in der Adresse, dort ist es eine Videoeinblendung und kein Text zum Lesen. AUSPROBIEREN, OHNE EINE SPENDE ANZULEGEN Der naheliegende Weg wäre gewesen: eine Spende eintragen und danach löschen. Das ist verboten — in ein laufendes System kommen keine Testdaten, und „gelöscht" heißt bei Geld nicht „war nie da". Die Probe schreibt deshalb NICHTS und geht nur an den, der drückt; eine Probe im ganzen Saal wäre eine Spende, die es nicht gab. DREI FEHLER, DIE DIE PRÜFUNG GEFUNDEN HAT 1. `protokolliere()` wurde an 17 Stellen falsch herum gerufen — `(personId, aktion, detail, ip)` statt `(aktion, {…})`. JavaScript beschwert sich nicht: Das zweite Argument war ein Text, und einen Text zu zerlegen ergibt lauter `undefined`. Auf dem echten Server nachgemessen: 39 Protokollzeilen mit Aktionen wie „16.0", alle ohne Person, ohne Detail, ohne IP. Betroffen waren Material, Hilfe, Bühne, Reaction und Spenden — also jede Änderung an Dogi-Media und jede Maßnahme im Live-Chat, ausgerechnet das, wofür es ein Protokoll gibt. Alle 17 berichtigt, und pruef-struktur wacht jetzt darüber (mit Gegenprobe). 2. Beim Speichern der Leiter bekam jede Stufe eine NEUE Kennung (DELETE + INSERT). Ein Bild-Hochladen gegen die eben noch gültige Kennung antwortete mit 404 — im Alltag trifft das jeden, der einen zweiten Bildschirm offen hat. Jetzt werden vorhandene Zeilen geändert statt ersetzt; das Bild bleibt von selbst daran hängen. 3. `ab_cent` ist eindeutig. Zwei Stufen ihre Beträge tauschen zu lassen scheiterte mit „UNIQUE constraint failed", obwohl das Ergebnis in Ordnung gewesen wäre: Beim Umschreiben stößt die Leiter auf sich selbst. Jetzt in drei Schritten — löschen, geparkte Zwischenwerte, endgültige Werte —, und das ist nach außen nie sichtbar. UND DREI, DIE IN MEINER MESSUNG STECKTEN Die Messung hat eine noch laufende Karte aus dem vorigen Abschnitt erwischt und daraus drei Fehler gemeldet, die keine waren — darunter „die Probe läuft im ganzen Saal". Sie zählte außerdem die versteckten Dateifelder als zu kleine Tippziele. Jetzt räumt sie vorher auf, wartet auf die Karte MIT DER ERWARTETEN GRÖSSE (die Karten laufen in einer Schlange — einen Knoten zu entfernen beendet sie nicht) und lässt die Einblendung zur Ruhe kommen, bevor sie misst. Ein Bildschirmfoto aus der Einblendphase sah aus, als stünde die Karte links heraus; nachgemessen: links 18 px, ganz im Bild. GEMESSEN, NICHT ANGENOMMEN Karte bei Größe 1: Schrift 16 px, Betrag 25,92 px. Bei Größe 2: 32 px und 51,84 px — Faktor exakt 2,00. Hätte eine einzige Regel noch in `rem` gestanden, wäre die Karte ungleichmäßig gewachsen, und auf einem Bild sieht beides nur „größer" aus. AUCH DAS BILD IST GEPRÜFT Es liegt am Bühnen-Router und nicht am Spenden-Router: Die Spendentafel in OBS hat keine Anmeldung, und ein 401 als JSON in einem `` ergibt ein kaputtes Bild ohne jeden Hinweis. Ohne Schlüssel, aber mit 128 Bit zufälligem Dateinamen — dieselbe Größenordnung wie der Bühnenschlüssel, und es ist ein Zierbild, das ohnehin im Stream steht. Kein Ausbruch aus dem Ordner (vier Wege geprüft, gemessen wird die Wirkung und nicht der Statuscode). NACHGETRAGEN AUS BLOCK 4 `reaktion_meldungen` fehlte im Löschkonzept — eine bestehende Prüfung hat es gefunden. 30 Tage nach dem Erledigen; meistens sind sie ohnehin früher weg, weil der Live-Chat beim Beenden gelöscht wird und die Meldungen daran hängen. Wer meldet, muss sich darauf verlassen können, dass daraus keine dauerhafte Liste wird. GEPRUEFT pruef-spenden: 95 Prüfungen (vorher 46), 0 Fehler. pruef-reaktion 260, pruef-buehne 36, pruef-aufbewahrung 45, pruef-struktur, pruef-meldungen, pruef-css-klassen, pruef-tippziele, pruef-deutsche-texte, pruef-auskunft alle grün. mess-reaktion und mess-buehne: Rückgabewert 0, kein ACHTUNG. Co-Authored-By: Claude Opus 5 --- server/mess-reaktion.mjs | 223 +++++++++++++++++ server/pruef-spenden.mjs | 269 ++++++++++++++++++++- server/pruef-struktur.mjs | 62 +++++ server/reaktion-tabellen.js | 15 ++ server/spenden-tabellen.js | 71 +++++- server/workspace-aufbewahrung.js | 22 +- server/workspace-buehne.js | 57 ++++- server/workspace-hilfe.js | 4 +- server/workspace-material.js | 25 +- server/workspace-reaktion.js | 50 +++- server/workspace-spenden.js | 332 +++++++++++++++++++++++++- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/reaktion.css | 62 +++++ workspace/assets/css/spendenkarte.css | 49 ++-- workspace/assets/js/meldung.js | 25 ++ workspace/assets/js/reaktion.js | 220 +++++++++++++++++ workspace/assets/js/spendenkarte.js | 10 + 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/buehne.html | 2 +- workspace/calls.html | 34 +-- workspace/chat.html | 40 ++-- 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/notizen.html | 28 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 +-- workspace/reaktion.html | 60 +++-- 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/tafel.html | 6 +- 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 | 32 +-- workspace/werdegang.html | 32 +-- workspace/willkommen.html | 30 +-- workspace/wissen.html | 34 +-- 61 files changed, 2140 insertions(+), 714 deletions(-) diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 5fb6072e..367cc7e2 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -685,6 +685,229 @@ if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice Abzeichen UND ein zweiter Knopf dazugekommen. Genau so ist am 28.09. die Regie 197 px uebergelaufen: Die Rechnung ging auf, die Wirklichkeit nicht. */ +/* ---- Die Gestaltung der Spendenkarten --------------------------- + + Filipe: „mach paar fertige und so dass ich auch hochladen kann. + auch so dass ich das anders gestalten kann oder die größe + verändern kann." + + GEMESSEN WIRD DIE KARTE, NICHT DIE EINSTELLUNG. Dass in der + Datenbank 2.0 steht, sagt nichts darüber, ob die Karte doppelt so + groß erscheint -- dazwischen liegen eine Zahl im Stilblatt, eine + Umrechnung in `em` und ein Dutzend Regeln, die noch in `rem` + stehen könnten. Also: Karte laufen lassen und nachmessen. */ +console.log(""); +console.log(" --- Die Gestaltung der Spendenkarten ---"); +await host.seite.click('#reiter .reiter__knopf[data-tafel="gestaltung"]').catch(() => {}); +await host.seite.waitForTimeout(700); + +const leiter = await host.seite.evaluate(() => { + const zeilen = [...document.querySelectorAll("#stufen .stufe-zeile")]; + return { + zeilen: zeilen.length, + ersteName: zeilen[0]?.querySelector(".stufe-name")?.value || "", + felder: zeilen[0] ? [...zeilen[0].querySelectorAll("input, select")].length : 0, + /* Jedes Bedienelement muss mit dem Finger zu treffen sein -- + hier wird eingestellt, was vor Publikum im Bild steht. */ + /* VERSTECKTES ZAEHLT NICHT. Hinter jedem „Bild hochladen" liegt + ein `` -- es ist 0 px hoch, und das + ist richtig so: Angefasst wird das Schild davor. Die erste + Fassung hat es mitgezaehlt und drei Fehler gemeldet, die + keine waren. Eine Messung, die Unsichtbares misst, misst das + Falsche. */ + zuKlein: zeilen.flatMap((z) => [...z.querySelectorAll("input, select, .stufe-tun")]) + .filter((e) => !e.hidden && e.offsetParent !== null) + .filter((e) => e.getBoundingClientRect().height < 40).length, + ueber: Math.max(0, document.documentElement.scrollWidth + - document.documentElement.clientWidth), + }; +}); +console.log(` ${leiter.zeilen} Stufen, erste „${leiter.ersteName}", ` + + `${leiter.felder} Felder je Zeile, ${leiter.zuKlein} zu kleine Ziele, ` + + `seitlich ${leiter.ueber} px`); +if (leiter.zeilen < 3 || leiter.felder < 6 || leiter.zuKlein > 0 || leiter.ueber > 0) { + console.log(" ACHTUNG: Die Stufenleiter ist unvollstaendig oder laeuft ueber."); + ausgang = 1; +} +await bild(host.seite, "17-stufenleiter"); + +/* ---- Eine Stufe umgestalten und nachsehen, ob es ankommt ---- */ +const vorherKarte = await host.seite.evaluate(async () => { + /* Erst einmal bei Größe 1 messen -- ohne Bezugswert sagt die zweite + Messung nichts. */ + const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2]; + k2.querySelector(".stufe-groesse").value = "1"; + k2.querySelector(".stufe-groesse").dispatchEvent(new Event("input", { bubbles: true })); + document.getElementById("stufen-sichern").click(); + return true; +}); +await host.seite.waitForTimeout(1200); + +/* ==== ERST AUFRAEUMEN, DANN MESSEN ================================ + + Die erste Fassung hat einfach `document.querySelector(".spenden- + karte")` genommen -- und damit die Karte der echten 10-Euro-Spende + erwischt, die aus dem vorigen Messabschnitt noch im Bild stand. + Ergebnis: drei gemeldete Fehler, die keine waren, darunter „die + Probe laeuft im ganzen Saal". Eine Messung, die nicht weiss, WELCHE + Karte sie vor sich hat, misst irgendetwas. + + Also: beide Tafeln leeren, dann druecken, dann auf eine Karte + warten, die sich als Probe ausweist. */ +const tafelnLeeren = async () => { + for (const s2 of [host.seite, zuschauer.seite]) { + await s2.evaluate(() => { + for (const k2 of document.querySelectorAll(".spendenkarte")) k2.remove(); + }); + } +}; + +const messeKarte = async (erwartet) => { + await tafelnLeeren(); + await host.seite.evaluate(() => { + const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2]; + [...k2.querySelectorAll(".stufe-tun")].find((x) => x.textContent === "Ausprobieren")?.click(); + }); + /* ==== AUF DIE RICHTIGE KARTE WARTEN, NICHT AUF DIE UHR ========== + + Zwei Fallen auf einmal, und die zweite hat die erste Fassung + gekostet: + + (1) Die Karten laufen in einer SCHLANGE. Ein Knoten aus dem + Dokument zu nehmen beendet nicht, was die Seite gerade + zeigt -- die naechste Karte kommt erst, wenn die Dauer der + vorigen abgelaufen ist. Bei elf Sekunden Dauer und acht + Sekunden Wartezeit kam sie nie, und die Messung meldete + einen Fehler, den es nicht gab. + + (2) Gewartet wird auf die Karte MIT DER ERWARTETEN GROESSE. + Sonst koennte die vorige Probe die Antwort geben, und die + Messung bestaetigte eine Aenderung, die gar nicht + angekommen ist. */ + await host.seite.waitForFunction( + (soll) => [...document.querySelectorAll(".spendenkarte")].some((k2) => + k2.querySelector(".spendenkarte__wer")?.textContent === "Probe" + && Math.abs(parseFloat(getComputedStyle(k2).fontSize) - soll) < 0.5), + erwartet, { timeout: 16000 }, + ).catch(() => {}); + /* DIE KARTE KOMMT VON LINKS HEREIN (450 ms). Wer sofort misst, + misst einen Zwischenschritt: Waehrend des Hereinkommens steht + sie 28 px weiter links und ist halb durchsichtig. Ein + Bildschirmfoto aus dieser Phase sieht aus, als waere sie + abgeschnitten -- und genau das hat mich beim ersten Ansehen in + die Irre gefuehrt. */ + await host.seite.waitForTimeout(700); + return host.seite.evaluate(() => { + const k2 = [...document.querySelectorAll(".spendenkarte")] + .find((x) => x.querySelector(".spendenkarte__wer")?.textContent === "Probe"); + if (!k2) return null; + const r = k2.getBoundingClientRect(); + const c = getComputedStyle(k2); + const betrag = k2.querySelector(".spendenkarte__betrag"); + return { + schrift: Math.round(parseFloat(c.fontSize) * 100) / 100, + breit: Math.round(r.width), hoch: Math.round(r.height), + betragText: betrag?.textContent || "", + betragSchrift: Math.round(parseFloat(getComputedStyle(betrag).fontSize) * 100) / 100, + stufe: k2.querySelector(".spendenkarte__stufe")?.textContent || "", + wer: k2.querySelector(".spendenkarte__wer")?.textContent || "", + links: Math.round(r.left), oben: Math.round(r.top), + /* ALLE VIER SEITEN. Die erste Fassung hat nur rechts und unten + geprueft -- eine Karte, die links heraussteht, waere + durchgegangen. Im Stream ist links genauso abgeschnitten wie + rechts. */ + imBild: r.left >= -1 && r.top >= -1 + && r.right <= window.innerWidth + 1 && r.bottom <= window.innerHeight + 1, + }; + }); +}; + +const kKlein = await messeKarte(16); +console.log(` Größe 1: Karte ${kKlein?.breit}x${kKlein?.hoch} px, Schrift ${kKlein?.schrift} px, ` + + `Betrag „${kKlein?.betragText}" bei ${kKlein?.betragSchrift} px, Stufe „${kKlein?.stufe}"`); +if (!kKlein || kKlein.wer !== "Probe") { + console.log(" ACHTUNG: Die Probe zeigt keine Karte -- oder sie ist nicht als Probe erkennbar."); + ausgang = 1; +} +await bild(host.seite, "18-karte-normal"); + +/* DIE PROBE DARF NUR BEIM DRUECKENDEN ERSCHEINEN. Eine Probe, die im + ganzen Saal laeuft, ist eine Spende, die es nicht gab. */ +const beimZuschauer = await zuschauer.seite.evaluate(() => + document.querySelectorAll(".spendenkarte").length); +console.log(` Karten bei der Zuschauerin waehrend der Probe: ${beimZuschauer} (soll: 0)`); +if (beimZuschauer !== 0) { + console.log(" ACHTUNG: Die Probe laeuft im ganzen Saal."); + ausgang = 1; +} + +/* Jetzt auf doppelte Größe -- und nachmessen. */ +await host.seite.evaluate(() => { + const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2]; + k2.querySelector(".stufe-groesse").value = "2"; + k2.querySelector(".stufe-groesse").dispatchEvent(new Event("input", { bubbles: true })); + k2.querySelector(".stufe-farbe").value = "#55d6a0"; + k2.querySelector(".stufe-farbe").dispatchEvent(new Event("input", { bubbles: true })); + document.getElementById("stufen-sichern").click(); +}); +await host.seite.waitForTimeout(1400); +const kGross = await messeKarte(32); +console.log(` Größe 2: Karte ${kGross?.breit}x${kGross?.hoch} px, Schrift ${kGross?.schrift} px, ` + + `Betrag bei ${kGross?.betragSchrift} px, links ${kGross?.links} / oben ${kGross?.oben}, ` + + `ganz im Bild=${kGross?.imBild}`); +await bild(host.seite, "19-karte-doppelt"); + +if (!kGross || !kKlein) { + console.log(" ACHTUNG: Die Karte liess sich nicht zweimal messen."); + ausgang = 1; +} else { + /* ZWEIFACH HEISST ZWEIFACH. Wenn eine einzige Regel noch in `rem` + stünde statt in `em`, wüchse die Karte ungleichmäßig -- und das + sieht man auf einem Bildschirmfoto nicht, weil beides „größer" + aussieht. */ + const faktor = kGross.betragSchrift / kKlein.betragSchrift; + console.log(` Der Betrag wuchs um das ${faktor.toFixed(2)}-fache (soll: 2,00)`); + if (Math.abs(faktor - 2) > 0.05) { + console.log(" ACHTUNG: Die Größe wirkt nicht oder nicht gleichmaessig."); + ausgang = 1; + } + if (kGross.hoch <= kKlein.hoch) { + console.log(" ACHTUNG: Die Karte wird nicht hoeher."); + ausgang = 1; + } + /* UND SIE MUSS INS BILD PASSEN. Die Obergrenze 2,5 ist daran + ausgerichtet -- eine Karte, die rechts heraussteht, ist im + Stream abgeschnitten. */ + if (!kGross.imBild) { + console.log(" ACHTUNG: Die doppelt grosse Karte steht aus dem Bild heraus."); + ausgang = 1; + } +} + +/* ---- Und die Farbe? ---- */ +const farbe = await host.seite.evaluate(() => { + const k2 = [...document.querySelectorAll(".spendenkarte")] + .find((x) => x.querySelector(".spendenkarte__wer")?.textContent === "Probe"); + return k2 ? getComputedStyle(k2).getPropertyValue("--sp-farbe").trim() : ""; +}); +console.log(` Farbe der Karte: „${farbe}" (gesetzt war #55d6a0)`); +if (farbe.toLowerCase() !== "#55d6a0") { + console.log(" ACHTUNG: Die Farbe kommt nicht an."); + ausgang = 1; +} + +/* Wieder auf einen vernuenftigen Wert -- die naechste Messung soll + nicht mit einer Karte in doppelter Groesse anfangen. */ +await host.seite.evaluate(() => { + const k2 = [...document.querySelectorAll("#stufen .stufe-zeile")][2]; + k2.querySelector(".stufe-groesse").value = "1.45"; + k2.querySelector(".stufe-groesse").dispatchEvent(new Event("input", { bubbles: true })); + k2.querySelector(".stufe-farbe").value = "#e0a13a"; + k2.querySelector(".stufe-farbe").dispatchEvent(new Event("input", { bubbles: true })); + document.getElementById("stufen-sichern").click(); +}); +await host.seite.waitForTimeout(1000); + /* ---- Das Video: Tempo, Anordnung, Umschalter -------------------- Filipe: „auch bei den videos sachen wie pause geschwindigkeit ... diff --git a/server/pruef-spenden.mjs b/server/pruef-spenden.mjs index 27c57f0b..465cd0ba 100644 --- a/server/pruef-spenden.mjs +++ b/server/pruef-spenden.mjs @@ -16,7 +16,7 @@ Aufrufen mit: node server/pruef-spenden.mjs ===================================================================== */ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -83,16 +83,26 @@ for (const [i, [rolle, name]] of ROLLEN.entries()) { d.close(); /* ---------- Sprechen mit Hausadresse ------------------------------------ */ -function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { +/** `roher` schickt Bytes statt JSON. + * + * Gebraucht fuer das Hochladen eines Bildes: Die Route nimmt + * `express.raw()` entgegen, und ein als JSON verpacktes Bild waere + * etwas anderes als das, was ein Browser schickt -- die Pruefung + * wuerde dann einen Weg pruefen, den es gar nicht gibt. */ +function roh(host, pfad, { method = "GET", keks = "", rumpf = null, roher = null, origin } = {}) { return new Promise((fertig, schief) => { - const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const daten = roher !== null ? roher + : (rumpf === null ? null : Buffer.from(JSON.stringify(rumpf))); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method, headers: { Host: host, Origin: origin === undefined ? `https://${host}` : origin, ...(keks ? { Cookie: keks } : {}), - ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + ...(daten ? { + "Content-Type": roher !== null ? "application/octet-stream" : "application/json", + "Content-Length": daten.length, + } : {}), }, }, (antwort) => { const teile = []; @@ -355,6 +365,257 @@ melde("=== 7. Keine Spende zweimal ==="); d2.close(); } + +/* ======================================================================= + 8. DIE GESTALTUNG + + Filipe: „mach paar fertige und so dass ich auch hochladen kann. + auch so dass ich das anders gestalten kann oder die groesse + veraendern kann. ... auch spezielle sachen bei speziellen spenden." + ======================================================================= */ +melde(""); +melde("=== 8. Die Gestaltung der Karten ==="); +{ + /* Wie viele Spenden es gibt -- gebraucht fuer den Nachweis, dass + die Probe KEINE anlegt. Gezaehlt wird in der Datenbank und nicht + in einer Liste der Oberflaeche: Eine Liste zeigt nur, was sie + zeigen soll. */ + const { DatabaseSync: DBZ } = await import("node:sqlite"); + const dz = new DBZ(process.env.WORKSPACE_DB, { readOnly: true }); + const zaehle = () => dz.prepare("SELECT COUNT(*) AS n FROM spenden").get().n; + + const { VORLAGEN, STUFEN_MAX, GROESSE_MIN, GROESSE_MAX, DAUER_MS_MIN, DAUER_MS_MAX } + = await import("./spenden-tabellen.js"); + + /* ---- Die Vorlagen sind an BEIDEN Enden dieselben ---- + Ein Name, den nur eine Seite kennt, waere eine Vorlage, die sich + auswaehlen laesst und dann als Herz erscheint. */ + { + const karte = readFileSync( + join(HIER, "..", "workspace", "assets", "js", "spendenkarte.js"), "utf8"); + const block = karte.match(/const VORLAGEN = \{([\s\S]*?)\n \};/); + const inKarte = [...(block ? block[1] : "").matchAll(/^\s{4}([a-z]+):/gm)].map((m) => m[1]); + ok(inKarte.length >= 5, `die Karte kennt ${inKarte.length} Zeichnungen`); + const fehlt = VORLAGEN.filter((v) => !inKarte.includes(v)); + const zuviel = inKarte.filter((v) => !VORLAGEN.includes(v)); + ok(fehlt.length === 0 && zuviel.length === 0, + (fehlt.length || zuviel.length) + ? `UNGLEICH: fehlt ${fehlt.join(",")} / zuviel ${zuviel.join(",")}` + : `Server und Karte kennen dieselben ${VORLAGEN.length} Zeichnungen`); + ok(!VORLAGEN.includes("wolke"), "Gegenprobe: eine erfundene Zeichnung steht in keiner Liste"); + } + + /* ---- Wer gestalten darf ---- */ + const guteLeiter = { + stufen: [ + { ab_cent: 100, name: "Danke", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 6000, groesse: 1 }, + { ab_cent: 2000, name: "Legende", vorlage: "krone", farbe: "#e0a13a", dauer_ms: 11000, groesse: 1.45 }, + ], + }; + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, "/workspace/api/spenden/stufen", { + method: "PUT", keks: k[rolle], rumpf: guteLeiter, + }); + ok(a.code === 403, `${rolle}: gestaltet nicht (${a.code})`); + } + + /* ---- Was eine Leiter sein muss ---- */ + const schlecht = [ + [{ stufen: [] }, 400, "keine_stufe", "eine leere Leiter"], + [{ stufen: null }, 400, "stufen_fehlen", "gar keine Leiter"], + [{ stufen: Array.from({ length: STUFEN_MAX + 1 }, (x, i) => ({ + ab_cent: (i + 1) * 100, name: `S${i}`, vorlage: "herz", + farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 })) }, 400, "zu_viele_stufen", + `${STUFEN_MAX + 1} Stufen`], + [{ stufen: [{ ab_cent: 0, name: "X", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }] }, + 400, "stufe_betrag_unklar", "null Cent"], + [{ stufen: [{ ab_cent: 100, name: " ", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }] }, + 400, "stufe_name_fehlt", "ein leerer Name"], + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "wolke", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }] }, + 400, "vorlage_unbekannt", "eine erfundene Zeichnung"], + /* DIE FARBE IST DIE HEIKELSTE STELLE: Sie landet als CSS-Wert in + der Karte. Ein freier Text waere dort eine Einschleusung. */ + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", + farbe: "red; background:url(javascript:1)", dauer_ms: 5000, groesse: 1 }] }, + 400, "farbe_unklar", "eine eingeschleuste Farbe"], + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "blau", dauer_ms: 5000, groesse: 1 }] }, + 400, "farbe_unklar", "ein Farbwort"], + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff", + dauer_ms: DAUER_MS_MAX + 1000, groesse: 1 }] }, 400, "dauer_unklar", "zu lange"], + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff", + dauer_ms: DAUER_MS_MIN - 500, groesse: 1 }] }, 400, "dauer_unklar", "zu kurz"], + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff", + dauer_ms: 5000, groesse: GROESSE_MAX + 1 }] }, 400, "groesse_unklar", "zu gross"], + [{ stufen: [{ ab_cent: 100, name: "X", vorlage: "herz", farbe: "#8ec9ff", + dauer_ms: 5000, groesse: GROESSE_MIN - 0.5 }] }, 400, "groesse_unklar", "zu klein"], + [{ stufen: [ + { ab_cent: 500, name: "A", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 5000, groesse: 1 }, + { ab_cent: 500, name: "B", vorlage: "welle", farbe: "#a98bff", dauer_ms: 5000, groesse: 1 }, + ] }, 409, "stufe_doppelt", "zwei Stufen beim selben Betrag"], + ]; + for (const [rumpf, code, wort, was] of schlecht) { + const a = await roh(CREW, "/workspace/api/spenden/stufen", { + method: "PUT", keks: k.admin, rumpf, + }); + ok(a.code === code && a.j?.fehler === wort, + `${was} wird abgelehnt (${a.code}/${a.j?.fehler})`); + } + + /* NACH ALL DEN ABLEHNUNGEN MUSS DIE ALTE LEITER NOCH STEHEN. + Eine Pruefung, die nur den Fehlercode ansieht, uebersieht genau + das: Ein Abbruch mitten im Schreiben haette die Leiter halb + geleert zurueckgelassen -- und dann faellt in der naechsten + Sendung eine Spende in gar keine Stufe. */ + const heil = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); + ok((heil.j?.stufen || []).length >= 3, + `die alte Leiter steht unveraendert (${heil.j?.stufen?.length} Stufen)`); + + /* ---- Und was geht ---- */ + const gut = await roh(CREW, "/workspace/api/spenden/stufen", { + method: "PUT", keks: k.admin, rumpf: guteLeiter, + }); + ok(gut.code === 200 && gut.j?.stufen?.length === 2, + `zwei Stufen werden gespeichert (${gut.code})`); + ok(gut.j?.stufen?.[0]?.ab_cent === 100 && gut.j?.stufen?.[1]?.ab_cent === 2000, + "und stehen aufsteigend"); + ok(gut.j?.stufen?.[1]?.groesse === 1.45, + `die Groesse kommt an (${gut.j?.stufen?.[1]?.groesse})`); + + /* ---- UMSORTIEREN MUSS GEHEN ---- + `ab_cent` ist eindeutig. Wer zwei vorhandene Stufen ihre Betraege + TAUSCHEN laesst, stoesst beim Umschreiben auf sich selbst -- + obwohl das Ergebnis voellig in Ordnung waere. Gemessen ist genau + das passiert („UNIQUE constraint failed"), und der ganze Vorgang + brach mit 503 ab. */ + { + const jetzige = gut.j.stufen; + const getauscht = await roh(CREW, "/workspace/api/spenden/stufen", { + method: "PUT", keks: k.admin, + rumpf: { stufen: [ + { ...jetzige[0], ab_cent: 2000 }, + { ...jetzige[1], ab_cent: 100 }, + ] }, + }); + ok(getauscht.code === 200, `zwei Stufen tauschen ihre Betraege (${getauscht.code})`); + ok(getauscht.j?.stufen?.[0]?.name === jetzige[1].name, + `und die Leiter steht richtig herum (${getauscht.j?.stufen?.[0]?.name})`); + /* Und wieder zurueck -- der Rest des Abschnitts rechnet damit. */ + await roh(CREW, "/workspace/api/spenden/stufen", { + method: "PUT", keks: k.admin, rumpf: { stufen: jetzige }, + }); + } + + /* SPEZIELLE SACHEN BEI SPEZIELLEN SPENDEN: Die Stufe entscheidet + sich am Betrag, und zwar immer die hoechste passende. */ + const { stufeFuer } = await import("./workspace-spenden.js"); + ok(stufeFuer(100)?.name === "Danke", "1 EUR -> Danke"); + ok(stufeFuer(1999)?.name === "Danke", "19,99 EUR -> immer noch Danke"); + ok(stufeFuer(2000)?.name === "Legende", "20 EUR -> Legende"); + ok(stufeFuer(50000)?.name === "Legende", "500 EUR -> Legende"); + ok(stufeFuer(99) === null, "unter der ersten Stufe gibt es keine"); + + /* ---- Die Probe legt KEINE Spende an ---- + Der naheliegende Weg waere gewesen, eine Spende einzutragen und + danach zu loeschen. In ein laufendes System kommen keine + Testdaten -- und „geloescht" heisst bei Geld nicht „war nie da". */ + const vorher = zaehle(); + const probe = await roh(CREW, "/workspace/api/spenden/probe", { + method: "POST", keks: k.admin, rumpf: { betrag_cent: 2000 }, + }); + ok(probe.code === 200 && probe.j?.karte?.stufe?.name === "Legende", + `die Probe zeigt die richtige Stufe (${probe.j?.karte?.stufe?.name})`); + ok(probe.j?.karte?.stand === "probe", "und ist als Probe gekennzeichnet"); + ok(zaehle() === vorher, + `und es entsteht KEINE Spende daraus (${vorher} -> ${zaehle()})`); + const probeNein = await roh(CREW, "/workspace/api/spenden/probe", { + method: "POST", keks: k.modi, rumpf: { betrag_cent: 2000 }, + }); + ok(probeNein.code === 403, `ein Modi probiert nicht (${probeNein.code})`); + + /* ---- Das Bild ---- */ + const id = gut.j.stufen[1].id; + const keinBild = await roh(CREW, `/workspace/api/spenden/stufe/${id}/bild`, { + method: "POST", keks: k.admin, roher: Buffer.from("das ist kein Bild"), + }); + ok(keinBild.code === 400 && keinBild.j?.fehler === "kein_bild", + `Text statt Bild wird abgelehnt (${keinBild.code})`); + + /* EIN ECHTES PNG -- der kleinste gueltige, den es gibt. Ein + erfundener Kopf wuerde nur beweisen, dass die Pruefung dieselbe + Annahme macht wie der Code. */ + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64"); + const hoch = await roh(CREW, `/workspace/api/spenden/stufe/${id}/bild`, { + method: "POST", keks: k.admin, roher: png, + }); + ok(hoch.code === 200 && /^\/workspace\/api\/buehne\/spendenbild\/[a-f0-9]{32}\.png$/ + .test(hoch.j?.bild || ""), `ein PNG wird angenommen (${hoch.j?.bild})`); + + /* DAS BILD MUSS OHNE ANMELDUNG ZU HOLEN SEIN. Die Spendentafel in + OBS hat keine Sitzung -- ein 401 als JSON in einem ergibt + ein kaputtes Bild ohne jeden Hinweis. */ + const ohne = await roh(CREW, hoch.j.bild, {}); + ok(ohne.code === 200, `und ohne Anmeldung abrufbar (${ohne.code})`); + ok(String(ohne.kopf["content-type"]).startsWith("image/png"), + `als Bild und nicht als etwas anderes (${ohne.kopf["content-type"]})`); + ok(ohne.kopf["x-content-type-options"] === "nosniff", + "der Browser darf den Typ nicht selbst raten"); + + /* KEIN AUSBRUCH AUS DEM ORDNER. + GEPRUEFT WIRD, WAS HERAUSKOMMT -- nicht, welche Zahl dabeisteht. + „../../workspace.db" wird vom Server zu „/workspace/api/workspace.db" + zusammengefaltet, bevor diese Route ueberhaupt drankommt; die + Antwort ist dann 401 von einer ganz anderen Stelle. Auf 404 zu + bestehen hiesse, eine Nebensaechlichkeit zu pruefen und den + eigentlichen Punkt zu verfehlen: Es darf keine Datei + herauskommen. */ + for (const boes of ["../../workspace.db", "..%2f..%2fworkspace.db", + "%2e%2e%2f%2e%2e%2fworkspace.db", "nicht-vorhanden.png"]) { + const a = await roh(CREW, `/workspace/api/buehne/spendenbild/${boes}`, {}); + ok(a.code !== 200 && !a.text.includes("SQLite format"), + `„${boes}" liefert keine Datei (${a.code})`); + } + /* GEGENPROBE: Die richtige Adresse liefert sehr wohl etwas -- + sonst waere die Pruefung oben eine, die immer bestaetigt. */ + { + const a = await roh(CREW, hoch.j.bild, {}); + ok(a.code === 200 && a.text.length > 0, + "Gegenprobe: die richtige Adresse liefert das Bild"); + } + + /* DAS BILD UEBERLEBT DAS NAECHSTE SPEICHERN. Wer nur den Namen + aendert, soll sein Bild nicht verlieren. */ + const nochmal = await roh(CREW, "/workspace/api/spenden/stufen", { + method: "PUT", keks: k.admin, + rumpf: { stufen: gut.j.stufen.map((z) => ({ ...z, name: z.name + "!" })) }, + }); + const jetztBild = (nochmal.j?.stufen || []).find((z) => z.ab_cent === 2000)?.bild; + ok(jetztBild === hoch.j.bild, + `nach dem Umbenennen haengt das Bild noch dran (${jetztBild ? "ja" : "nein"})`); + + /* DIE KENNUNG MUSS DAS SPEICHERN UEBERLEBT HABEN. Vorher wurde + beim Speichern geloescht und neu eingefuegt -- jede Stufe bekam + dabei eine neue Kennung, und ein Bild-Hochladen gegen die eben + noch gueltige antwortete mit 404. Im Alltag trifft das jeden, + der einen zweiten Bildschirm offen hat. */ + const nachId = (nochmal.j?.stufen || []).find((z) => z.ab_cent === 2000)?.id; + ok(nachId === id, `die Kennung bleibt ueber das Speichern hinweg (${id} -> ${nachId})`); + + const weg = await roh(CREW, `/workspace/api/spenden/stufe/${id}/bild`, + { method: "DELETE", keks: k.admin }); + ok(weg.code === 200, `das Bild laesst sich wegnehmen (${weg.code})`); + const fort = await roh(CREW, hoch.j.bild, {}); + ok(fort.code === 404, `und die Datei ist danach wirklich fort (${fort.code})`); + + /* ---- Was die Oberflaeche braucht, kommt vom Server ---- */ + const lage2 = await roh(CREW, "/workspace/api/spenden", { keks: k.admin }); + const g2 = lage2.j?.gestaltung; + ok(!!g2 && Array.isArray(g2.vorlagen) && g2.stufen_max === STUFEN_MAX + && g2.groesse_min === GROESSE_MIN && g2.groesse_max === GROESSE_MAX, + "die Grenzen stehen im Stand und nicht nur in der Oberflaeche"); +} + melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs index cafb3836..8c60a203 100644 --- a/server/pruef-struktur.mjs +++ b/server/pruef-struktur.mjs @@ -830,6 +830,68 @@ console.log(`\n=== Rechnen die Pruefungen mit Ortszeit? ===`); ok(!harmlos, "Gegenprobe: ein voller Zeitstempel (ohne Schnitt) schlägt nicht an"); } +/* ======================================================================= + WIRD DAS PROTOKOLL RICHTIG HERUM GERUFEN? + + `protokolliere(aktion, { personId, rolle, detail, ip })` -- die + Aktion zuerst, alles Weitere benannt. + + Aufgerufen wurde es an 17 Stellen andersherum: + `protokolliere(req.person.id, "aktion", detail, ip)`. JavaScript + beschwert sich darueber NICHT. Das zweite Argument ist ein Text, + und einen Text zu zerlegen ergibt lauter `undefined`. Der Eintrag + entsteht also -- nur steht die Personen-Kennung im Feld `aktion`, + und Person, Detail und IP sind leer. + + AUF DEM ECHTEN SERVER NACHGEMESSEN: 39 Zeilen mit Aktionen wie + „16.0" und „4.0". Betroffen war unter anderem jede Aenderung an + Dogi-Media und jede Massnahme im Live-Chat -- also ausgerechnet + das, wofuer es ein Protokoll gibt. + + Ein Kommentar haette das nicht verhindert (11.09.2026: „Ein + Kommentar, der vor einem Fehler warnt, verhindert ihn nicht"). + Eine Pruefung schon. + ======================================================================= */ +console.log(`\n=== Wird das Protokoll richtig herum gerufen? ===`); +{ + /* Das erste Argument muss etwas sein, das eine Aktion SEIN KANN: + eine Zeichenkette oder ein Ausdruck, der eine liefert (`a ? "x" : + "y"`). Was es nicht sein darf, ist eine Kennung -- und die + erkennt man daran, dass sie auf `.id` endet oder schlicht + `personId` heisst. */ + const schlecht = []; + let gesehen = 0; + for (const datei of readdirSync(SERVER).filter((n) => n.endsWith(".js"))) { + const text = readFileSync(join(SERVER, datei), "utf8"); + for (const m of text.matchAll(/\bprotokolliere\(\s*([^,)]{1,60})/g)) { + const erstes = m[1].trim(); + if (erstes.startsWith("aktion")) continue; // die Erklaerung selbst + gesehen++; + if (/\.id\s*$/.test(erstes) || /^personId$/.test(erstes) + || /^\d/.test(erstes)) { + const zeile = text.slice(0, m.index).split("\n").length; + schlecht.push(`${datei}:${zeile} -> ${erstes}`); + } + } + } + ok(gesehen > 60, `${gesehen} Protokollaufrufe im Haus gefunden`); + ok(schlecht.length === 0, + schlecht.length + ? `FALSCH HERUM: ${schlecht.slice(0, 6).join(" | ")}` + : "alle rufen die Aktion zuerst"); + + /* GEGENPROBE: Kann die Pruefung „nein" sagen? Ohne sie waere sie + eine, die immer bestaetigt -- und die bestaetigt nichts. */ + const probe = 'protokolliere(req.person.id, "test", "x");'; + const trifft = [...probe.matchAll(/\bprotokolliere\(\s*([^,)]{1,60})/g)] + .some((m) => /\.id\s*$/.test(m[1].trim())); + ok(trifft, "Gegenprobe: ein falsch herum gebauter Aufruf wird erkannt"); + const gut = 'protokolliere("test", { personId: req.person.id });'; + const trifftNicht = [...gut.matchAll(/\bprotokolliere\(\s*([^,)]{1,60})/g)] + .some((m) => /\.id\s*$/.test(m[1].trim())); + ok(!trifftNicht, "Gegenprobe: ein richtiger Aufruf schlaegt nicht an"); +} + /* ---------- Gegenprobe --------------------------------------------------- */ console.log(`\n=== Gegenprobe (muss anschlagen) ===`); diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js index ac8c7223..8d3d7938 100644 --- a/server/reaktion-tabellen.js +++ b/server/reaktion-tabellen.js @@ -114,6 +114,21 @@ export const TEMPI = [0.5, 0.75, 1, 1.25, 1.5, 2]; * waere eine Sperre ohne all das. */ export const STUMM_MAX_MINUTEN = 60; +/** So lange bleibt eine erledigte Meldung stehen. + * + * DREISSIG TAGE, UND NICHT LAENGER. Eine Meldung enthaelt drei + * Dinge, die zusammen empfindlich sind: was jemand geschrieben hat, + * wer es war und wer ihn gemeldet hat. Solange dieselbe Sendung + * laeuft, ist das die Arbeitsgrundlage der Moderation; danach ist es + * eine Liste darueber, wer sich mal danebenbenommen hat -- und wer + * das gemeldet hat. + * + * Meistens sind sie ohnehin frueher weg: Der Live-Chat wird beim + * Beenden der Sendung geloescht, und die Meldungen haengen mit + * `ON DELETE CASCADE` daran. Diese Frist greift nur fuer den Fall, + * dass eine Sendung nie ordentlich beendet wurde. */ +export const MELDUNG_TAGE = 30; + /** Die zwei kurzen Massnahmen der Sendung. */ export const MASSNAHMEN = ["stumm", "raus"]; diff --git a/server/spenden-tabellen.js b/server/spenden-tabellen.js index ac945714..62b4f4ab 100644 --- a/server/spenden-tabellen.js +++ b/server/spenden-tabellen.js @@ -64,6 +64,55 @@ export const BETRAG_MAX_CENT = 500000; export const NAME_MAX = 40; export const GRUSS_MAX = 200; +/** Die mitgelieferten Zeichnungen. + * + * DIESE LISTE IST DIE WAHRHEIT -- `spendenkarte.js` hat dieselben + * Namen, und eine Pruefung haelt beide aneinander. Ein Name, den + * nur eine Seite kennt, waere eine Vorlage, die sich auswaehlen + * laesst und dann als Herz erscheint. */ +export const VORLAGEN = ["herz", "welle", "krone", "stern", "pfote"]; + +/** So viele Stufen hoechstens. + * + * Sechs. Bei sieben merkt sich niemand mehr, was welche bedeutet -- + * und eine Stufenleiter, die man nicht im Kopf hat, ist keine + * Belohnung, sondern eine Preisliste. */ +export const STUFEN_MAX = 6; + +/** Wie lange eine Karte stehen darf. */ +export const DAUER_MS_MIN = 2000; +export const DAUER_MS_MAX = 30000; + +/** Wie gross eine Karte werden darf (Faktor auf die Grundgroesse). + * + * Filipe: „auch so dass ich das anders gestalten kann oder die + * groesse veraendern kann." + * + * JE STUFE UND NICHT EINMAL FUER ALLE. Genau darin steckt „spezielle + * sachen bei speziellen spenden": Eine Rudel-Legende darf GROESSER + * dastehen als ein Danke. Eine einzige Groesse fuer alle waere + * wieder die Preisliste. + * + * WARUM ES NUR NACH OBEN GEHT. Die Karte ist bereits auf die + * Untergrenze des Hauses gebaut: Das kleinste Wort darauf steht bei + * 0,72rem, also 11,52 Pixel, und 11,5 ist die Grenze, unter die + * nichts in diesem Haus faellt (pruef-css-klassen misst das). Ein + * Faktor von 0,8 machte daraus 9,2 Pixel -- ein Dank, den man nicht + * mehr lesen kann, ist keiner. + * + * KLEINER GEHT TROTZDEM -- aber an der richtigen Stelle: Die + * OBS-Tafel hat ihren eigenen Regler in der Adresse (`&g=`, 0,5 bis + * 4). Dort ist es eine Videoeinblendung und kein Text zum Lesen, + * und dort entscheidet ohnehin die Groesse des Streams. + * + * Die Obergrenze ist gemessen: Ueber 2,5 passt die Karte auf 1080 + * Pixel Hoehe nicht mehr neben den Chat. */ +export const GROESSE_MIN = 1; +export const GROESSE_MAX = 2.5; + +/** Wie gross ein hochgeladenes Bild sein darf. */ +export const BILD_MAX_BYTES = 3 * 1024 * 1024; + /** Die Stufen, die es ab Werk gibt — „paar fertige" aus Filipes * Ansage. Sie stehen hier und nicht in der Datenbank, damit ein * frisches Haus sofort etwas anzuzeigen hat; geaendert werden sie @@ -74,9 +123,9 @@ export const GRUSS_MAX = 200; * richtig grosse Danke. Drei Stufen, nicht sieben — bei sieben * merkt sich niemand mehr, was welche bedeutet. */ export const STUFEN_START = [ - { ab_cent: 100, name: "Danke", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 6000 }, - { ab_cent: 500, name: "Starke Runde", vorlage: "welle", farbe: "#a98bff", dauer_ms: 8000 }, - { ab_cent: 2000, name: "Rudel-Legende", vorlage: "krone", farbe: "#e0a13a", dauer_ms: 11000 }, + { ab_cent: 100, name: "Danke", vorlage: "herz", farbe: "#8ec9ff", dauer_ms: 6000, groesse: 1 }, + { ab_cent: 500, name: "Starke Runde", vorlage: "welle", farbe: "#a98bff", dauer_ms: 8000, groesse: 1.15 }, + { ab_cent: 2000, name: "Rudel-Legende", vorlage: "krone", farbe: "#e0a13a", dauer_ms: 11000, groesse: 1.45 }, ]; /** Die Betraege, die als Knopf angeboten werden (in Cent). @@ -147,6 +196,16 @@ export function spendenTabellen(d) { ); `); + /* ==== NACHGEWACHSEN: DIE GROESSE ================================ + `ALTER TABLE ... ADD COLUMN` und nicht „Tabelle neu bauen": Am + 11.09.2026 hat genau dieses Umkopieren an anderer Stelle drei + Spalten mit Inhalt verschluckt, ohne Fehlermeldung. */ + const da = new Set(d.prepare("PRAGMA table_info(spenden_stufen)").all().map((z) => z.name)); + if (!da.has("groesse")) { + d.exec("ALTER TABLE spenden_stufen ADD COLUMN groesse REAL NOT NULL DEFAULT 1"); + console.log("[spenden] Spalte groesse angelegt."); + } + /* DIE DREI STUFEN ANLEGEN, WENN ES NOCH KEINE GIBT. `INSERT OR IGNORE` je Zeile und nicht „erst zaehlen, dann @@ -155,8 +214,8 @@ export function spendenTabellen(d) { const nun = new Date().toISOString(); for (const s of STUFEN_START) { d.prepare(`INSERT OR IGNORE INTO spenden_stufen - (ab_cent, name, vorlage, farbe, dauer_ms, geaendert) - VALUES (?,?,?,?,?,?)`) - .run(s.ab_cent, s.name, s.vorlage, s.farbe, s.dauer_ms, nun); + (ab_cent, name, vorlage, farbe, dauer_ms, groesse, geaendert) + VALUES (?,?,?,?,?,?,?)`) + .run(s.ab_cent, s.name, s.vorlage, s.farbe, s.dauer_ms, s.groesse, nun); } } diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index bda60ae7..2db9b836 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -48,7 +48,7 @@ import { supportAufraeumen } from "./workspace-support.js"; import { notizenAufraeumen } from "./workspace-notizen.js"; import { reaktionAufraeumen } from "./workspace-reaktion.js"; import { spendenAufraeumen } from "./workspace-spenden.js"; -import { VERLAUF_TAGE } from "./reaktion-tabellen.js"; +import { VERLAUF_TAGE, MELDUNG_TAGE } from "./reaktion-tabellen.js"; import { SPENDEN_TAGE } from "./spenden-tabellen.js"; import { PAPIERKORB_TAGE as NOTIZ_TAGE } from "./notiz-tabellen.js"; import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; @@ -344,6 +344,26 @@ export const FRISTEN = [ vorTagen(VERLAUF_TAGE)), raeumen: () => reaktionAufraeumen(), }, + { + schluessel: "reaktion_meldungen", + was: "Gemeldete Chat-Beiträge einer Sendung", + art: "raeumen", + frist: `${MELDUNG_TAGE} Tage nach dem Erledigen`, + zweck: "Die Moderation muss während einer Sendung sehen, was gemeldet " + + "wurde – und hinterher nachvollziehen können, was sie entschieden hat.", + grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse " + + "(ein Chat, in dem sich alle benehmen)", + wirkung: "Die Zeile verschwindet: der gemeldete Beitrag, wer gemeldet hat " + + "und wer es erledigt hat. Meistens ist sie ohnehin früher weg – der " + + "Live-Chat wird beim Beenden der Sendung gelöscht, und die Meldungen " + + "hängen daran. Wer gemeldet hat, muss sich darauf verlassen können, " + + "dass daraus keine dauerhafte Liste wird.", + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM reaktion_meldungen WHERE erledigt_am IS NOT NULL" + + " AND erledigt_am < ?", + vorTagen(MELDUNG_TAGE)), + raeumen: () => reaktionAufraeumen(), + }, { schluessel: "spenden", was: "Spenden und ihre Karten", diff --git a/server/workspace-buehne.js b/server/workspace-buehne.js index 395bc92c..47bdf325 100644 --- a/server/workspace-buehne.js +++ b/server/workspace-buehne.js @@ -54,7 +54,11 @@ ===================================================================== */ import express from "express"; import { randomBytes, timingSafeEqual } from "node:crypto"; -import { db, sitzungLesen, einstellung, einstellungSetzen, protokolliere, echteIp } from "./workspace.js"; +import { join } from "node:path"; +import { existsSync, createReadStream } from "node:fs"; +import { + db, sitzungLesen, einstellung, einstellungSetzen, protokolliere, echteIp, DATEN_ORDNER, +} from "./workspace.js"; export const buehneRouter = express.Router(); @@ -127,6 +131,51 @@ export function buehneQuellen() { DIE WEGE ====================================================================== */ +/* ==== DAS BILD EINER SPENDENSTUFE ================================== + + Es haengt HIER und nicht am Spenden-Router, und dafuer gibt es + genau einen Grund: Die Spendentafel in OBS hat KEINE Anmeldung. + Am Spenden-Router steht eine Schranke ueber allem, und ein 401 als + JSON in einem `` ergibt ein kaputtes Bild ohne jeden Hinweis. + Dieselbe Ueberlegung wie beim Profilbild, das deshalb auch vor dem + Aufgaben-Router haengt. + + OHNE SCHLUESSEL -- UND WARUM DAS VERTRETBAR IST. Der Dateiname + sind 32 zufaellige Zeichen aus `randomBytes(16)`, also 128 Bit. Das + ist dieselbe Groessenordnung wie der Buehnenschluessel selbst. Und + der Gegenstand ist ein Zierbild, das Filipe fuer seinen Stream + hochlaedt -- es steht ohnehin oeffentlich im Bild. Eine Schranke + davor waere eine, die nur den eigenen Saal aussperrt: Der kennt + den Buehnenschluessel naemlich nicht, den bekommt nur die Leitung. + + DER NAME MUSS GENAU DEM MUSTER ENTSPRECHEN. Damit laesst sich kein + Pfad verlassen, auch nicht ueber kodierte Zeichen. */ +buehneRouter.get("/workspace/api/buehne/spendenbild/:datei", (req, res) => { + try { + const name = String(req.params.datei); + if (!/^[a-f0-9]{32}\.(jpg|png|webp)$/.test(name)) return res.status(404).end(); + const pfad = join(DATEN_ORDNER, "spendenbilder", name); + if (!existsSync(pfad)) return res.status(404).end(); + res.setHeader("Content-Type", name.endsWith(".jpg") ? "image/jpeg" + : name.endsWith(".png") ? "image/png" : "image/webp"); + res.setHeader("X-Content-Type-Options", "nosniff"); + /* Alles verbietende Inhaltsregel: Selbst wenn eine Datei jemals + durchkaeme, die der Browser als etwas anderes liest, darf sie + nichts nachladen und nichts ausfuehren. */ + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + /* Der Name ist zufaellig und aendert sich bei jedem neuen Bild -- + er darf lange zwischengespeichert werden. Das ist hier mehr als + Sparsamkeit: Eine OBS-Quelle laeuft stundenlang, und ein Bild, + das bei jeder Karte neu geladen wird, erscheint beim ersten Mal + mit einer Verzoegerung mitten in der Einblendung. */ + res.setHeader("Cache-Control", "public, max-age=604800, immutable"); + createReadStream(pfad).pipe(res); + } catch (fehler) { + console.error("[buehne] Spendenbild:", fehler?.message); + res.status(404).end(); + } +}); + /** Was gerade laeuft. Ein einziger Aufruf beim Start der Quelle. */ buehneRouter.get("/workspace/api/buehne/lage", mitSchluessel, (req, res) => { try { @@ -215,7 +264,11 @@ buehneRouter.post("/workspace/api/buehne/schluessel", nurLeitung, (req, res) => try { const neu = randomBytes(32).toString("hex"); einstellungSetzen(SCHLUESSEL_NAME, neu, req.person); - protokolliere(req.person.id, "buehne_schluessel", "neu erzeugt", echteIp(req)); + protokolliere("buehne_schluessel", { + personId: req.person.id, + detail: "neu erzeugt", + ip: echteIp(req), + }); /* DIE ALTEN QUELLEN WERDEN GESCHLOSSEN. Sonst liefe eine Quelle mit dem alten Schluessel weiter, obwohl er zurueckgezogen ist -- und man haelt sich fuer sicher, ohne es zu sein. */ diff --git a/server/workspace-hilfe.js b/server/workspace-hilfe.js index 8ed2e909..bcc20629 100644 --- a/server/workspace-hilfe.js +++ b/server/workspace-hilfe.js @@ -221,7 +221,7 @@ hilfeRouter.post("/workspace/api/hilfe", express.json(), (req, res) => { /* IM PROTOKOLL STEHT NUR, DASS ES EINEN FALL GIBT -- nicht, worum es geht. Das Protokoll lesen mehr Leute als den Fall. */ - protokolliere(req.person.id, "hilfe_fall_neu", `Fall ${id}`); + protokolliere("hilfe_fall_neu", { personId: req.person.id, detail: `Fall ${id}` }); leitungBenachrichtigen(id, betreff).catch(() => { /* Push ist Zugabe */ }); res.status(201).json({ id }); @@ -297,7 +297,7 @@ hilfeRouter.post("/workspace/api/hilfe/:id(\\d+)/schliessen", express.json(), (r + " geschlossen_am = ?, geschlossen_von = ? WHERE id = ?") .run(FALL_STAND.zu, t, t, req.person.id, fall.id); - protokolliere(req.person.id, "hilfe_fall_zu", `Fall ${fall.id}`); + protokolliere("hilfe_fall_zu", { personId: req.person.id, detail: `Fall ${fall.id}` }); schlussBenachrichtigen(fall).catch(() => { /* Zugabe */ }); res.json({ ok: true }); } catch (fehler) { diff --git a/server/workspace-material.js b/server/workspace-material.js index 6416e8a5..de76dbe3 100644 --- a/server/workspace-material.js +++ b/server/workspace-material.js @@ -395,8 +395,11 @@ materialRouter.post( sonst keinen Beleg. Genau deshalb steht das Haus hier. */ req.person.haus || null); - protokolliere(req.person.id, "material_eingestellt", - `${art} "${name}" (${req.body.length} Bytes)`, echteIp(req)); + protokolliere("material_eingestellt", { + personId: req.person.id, + detail: `${art} "${name}" (${req.body.length} Bytes)`, + ip: echteIp(req), + }); res.status(201).json({ id: Number(lastInsertRowid) }); } catch (f) { console.error("[material] Einstellen:", f?.message); @@ -437,7 +440,11 @@ materialRouter.post("/workspace/api/material/:id/nehmen", (req, res) => { }); } - protokolliere(req.person.id, "material_genommen", `Dogi-Media ${id}`, echteIp(req)); + protokolliere("material_genommen", { + personId: req.person.id, + detail: `Dogi-Media ${id}`, + ip: echteIp(req), + }); res.json({ ok: true, weg: `/workspace/api/material/${id}/datei` }); } catch (f) { console.error("[material] Nehmen:", f?.message); @@ -566,7 +573,11 @@ materialRouter.patch("/workspace/api/material/:id", express.json({ limit: "32kb" db().prepare("UPDATE material SET text = ?, gilt_ab = ?, gilt_bis = ? WHERE id = ?") .run(text, ab, bis, id); - protokolliere(req.person.id, "material_geaendert", `Dogi-Media ${id}`, echteIp(req)); + protokolliere("material_geaendert", { + personId: req.person.id, + detail: `Dogi-Media ${id}`, + ip: echteIp(req), + }); res.json({ ok: true, id, text, gilt_ab: ab, gilt_bis: bis, zustand: zustandVon({ ...z, gilt_ab: ab, gilt_bis: bis }, tagLokal(0)) }); } catch (f) { @@ -589,7 +600,11 @@ materialRouter.delete("/workspace/api/material/:id", (req, res) => { } db().prepare("DELETE FROM material WHERE id = ?").run(id); try { unlinkSync(join(ORDNER, z.datei)); } catch { /* schon weg */ } - protokolliere(req.person.id, "material_entfernt", `Dogi-Media ${id}`, echteIp(req)); + protokolliere("material_entfernt", { + personId: req.person.id, + detail: `Dogi-Media ${id}`, + ip: echteIp(req), + }); res.json({ ok: true }); } catch (f) { console.error("[material] Entfernen:", f?.message); diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index 994db5d2..b5435569 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -48,6 +48,7 @@ import { einstellung } from "./workspace.js"; import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, TEMPI, + MELDUNG_TAGE, VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN, } from "./reaktion-tabellen.js"; /* DIE SPERREN DES TREFFS GELTEN AUCH HIER. Ein zweites System @@ -189,7 +190,7 @@ function weiterZu(zeile, personId) { + " sekunde = 0, geaendert = ? WHERE id = 1") .run(zeile.video, zeile.titel, jetzt()); db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(zeile.id); - protokolliere(personId, "reaktion_weiter", zeile.video, ""); + protokolliere("reaktion_weiter", { personId, detail: zeile.video }); const s = sendung(); /* ZWEI MELDUNGEN, UND BEIDE WERDEN GEBRAUCHT. @@ -552,7 +553,11 @@ reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ lim db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`) .run(...Object.values(felder)); - protokolliere(req.person.id, "reaktion_stand", `${s.stand} -> ${neu}`, echteIp(req)); + protokolliere("reaktion_stand", { + personId: req.person.id, + detail: `${s.stand} -> ${neu}`, + ip: echteIp(req), + }); const l = lage(req.person); melden("reaktion", { was: "stand", lage: oeffentlich(l) }); res.json(l); @@ -686,7 +691,11 @@ reaktionRouter.post("/workspace/api/reaktion/gast", nurHost, express.json({ limi db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)") .run(id, jetzt(), platz); - protokolliere(req.person.id, "reaktion_gast_dazu", `${p.name} (Platz ${platz})`, echteIp(req)); + protokolliere("reaktion_gast_dazu", { + personId: req.person.id, + detail: `${p.name} (Platz ${platz})`, + ip: echteIp(req), + }); const l = lage(req.person); melden("reaktion", { was: "gaeste", lage: oeffentlich(l) }); stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) }); @@ -1096,8 +1105,11 @@ reaktionRouter.post("/workspace/api/reaktion/massnahme", express.json({ limit: " db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(ziel.id); } - protokolliere(req.person.id, "reaktion_massnahme", - `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(11, 16)}` : ""}`, echteIp(req)); + protokolliere("reaktion_massnahme", { + personId: req.person.id, + detail: `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(11, 16)}` : ""}`, + ip: echteIp(req), + }); /* DIE PERSON ERFAEHRT ES SELBST -- und zwar sofort. Wer nicht mehr schreiben kann und nicht weiss warum, sucht den Fehler @@ -1236,7 +1248,11 @@ reaktionRouter.post("/workspace/api/reaktion/chatmodus", nurHost, express.json({ const modus = String(req.body?.modus ?? ""); if (!CHAT_MODI.includes(modus)) return res.status(400).json({ fehler: "chatmodus_unbekannt" }); db().prepare("UPDATE reaktion SET chat_modus = ? WHERE id = 1").run(modus); - protokolliere(req.person.id, "reaktion_chatmodus", modus, echteIp(req)); + protokolliere("reaktion_chatmodus", { + personId: req.person.id, + detail: modus, + ip: echteIp(req), + }); const l = lage(req.person); melden("reaktion", { was: "chatmodus", lage: oeffentlich(l) }); res.json(l); @@ -1320,7 +1336,11 @@ reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => { db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?") .run(jetzt(), req.person.id, id); if (!eigen) { - protokolliere(req.person.id, "reaktion_chat_weg", `Beitrag ${id} von ${b.name}`, echteIp(req)); + protokolliere("reaktion_chat_weg", { + personId: req.person.id, + detail: `Beitrag ${id} von ${b.name}`, + ip: echteIp(req), + }); } melden("reaktion_chat", { was: "weg", id }); res.json({ ok: true }); @@ -1424,8 +1444,20 @@ export function reaktionAufraeumen() { const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze); const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?") .run(new Date(Date.now() - 600_000).toISOString()); - if (a.changes || b.changes) { - console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende.`); + /* ERLEDIGTE MELDUNGEN. Meistens sind sie schon mit dem Chat + verschwunden (CASCADE); das hier greift, wenn eine Sendung nie + ordentlich beendet wurde. */ + const c = db().prepare("DELETE FROM reaktion_meldungen WHERE erledigt_am IS NOT NULL" + + " AND erledigt_am < ?") + .run(new Date(Date.now() - MELDUNG_TAGE * 86400_000).toISOString()); + /* Und abgelaufene Massnahmen -- sie enden ohnehin mit der + Sendung, aber eine Zeile, die niemand mehr wegraeumt, bleibt + sonst fuer immer stehen. */ + const e = db().prepare("DELETE FROM reaktion_massnahmen WHERE bis IS NOT NULL AND bis < ?") + .run(jetzt()); + if (a.changes || b.changes || c.changes || e.changes) { + console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende, ` + + `${c.changes} Meldungen, ${e.changes} Massnahmen.`); } } catch (fehler) { console.error("[reaktion] Aufraeumen:", fehler?.message); diff --git a/server/workspace-spenden.js b/server/workspace-spenden.js index 341127f5..75f882a4 100644 --- a/server/workspace-spenden.js +++ b/server/workspace-spenden.js @@ -34,10 +34,18 @@ import express from "express"; import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js"; import { stromAn } from "./workspace-chat.js"; import { buehneMelden } from "./workspace-buehne.js"; +import { join } from "node:path"; +import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { DATEN_ORDNER } from "./workspace.js"; import { BETRAG_MAX_CENT, NAME_MAX, GRUSS_MAX, BETRAG_KNOEPFE, SPENDEN_TAGE, + VORLAGEN, STUFEN_MAX, DAUER_MS_MIN, DAUER_MS_MAX, + GROESSE_MIN, GROESSE_MAX, BILD_MAX_BYTES, } from "./spenden-tabellen.js"; +const BILDER = join(DATEN_ORDNER, "spendenbilder"); + export const spendenRouter = express.Router(); /** Wer eine Spende bestaetigen, eintragen oder ablehnen darf. @@ -152,12 +160,22 @@ function alsKarte(z) { stand: z.stand, erstellt: z.erstellt, stufe: st ? { - name: st.name, vorlage: st.vorlage, bild: st.bild, + name: st.name, vorlage: st.vorlage, bild: bildAdresse(st.bild), farbe: st.farbe, dauer_ms: st.dauer_ms, + groesse: Number(st.groesse) || 1, } : null, }; } +/** Aus dem Dateinamen wird eine Adresse -- oder nichts. + * + * Die Adresse zeigt auf den Buehnen-Router und NICHT hierher: Die + * Spendentafel in OBS hat keine Anmeldung, und an diesem Router + * steht eine Schranke ueber allem. */ +function bildAdresse(datei) { + return datei ? `/workspace/api/buehne/spendenbild/${datei}` : ""; +} + /** Eine bestaetigte Spende ins Bild schicken. */ function zeigen(z) { db().prepare("UPDATE spenden SET gezeigt_am = ? WHERE id = ?").run(jetzt(), z.id); @@ -185,8 +203,20 @@ spendenRouter.get("/workspace/api/spenden", (req, res) => { res.json({ paypal: pp ? { ziel: pp.ziel, knopf: pp.knopf } : null, knoepfe, - stufen: db().prepare("SELECT ab_cent, name, vorlage, bild, farbe, dauer_ms" - + " FROM spenden_stufen ORDER BY ab_cent").all(), + stufen: db().prepare("SELECT id, ab_cent, name, vorlage, bild, farbe, dauer_ms," + + " groesse FROM spenden_stufen ORDER BY ab_cent").all() + .map((z) => ({ ...z, bild: bildAdresse(z.bild), groesse: Number(z.groesse) || 1 })), + /* WAS SICH EINSTELLEN LAESST, KOMMT VOM SERVER. Stuenden die + Vorlagennamen und die Grenzen auch in der Oberflaeche, waeren + es zwei Listen -- und die eine liefe irgendwann von der + anderen weg. Dann steht dort ein Knopf, den der Server + ablehnt. */ + gestaltung: { + vorlagen: VORLAGEN, stufen_max: STUFEN_MAX, + dauer_min: DAUER_MS_MIN, dauer_max: DAUER_MS_MAX, + groesse_min: GROESSE_MIN, groesse_max: GROESSE_MAX, + bild_max_mb: Math.round(BILD_MAX_BYTES / 1024 / 1024), + }, betrag_max_cent: BETRAG_MAX_CENT, ich: { darf }, /* Die offenen Meldungen sieht nur, wer sie bestaetigen kann. @@ -267,7 +297,11 @@ spendenRouter.post("/workspace/api/spenden", nurGeld, express.json({ limit: "4kb .run(cent, "EUR", name, gruss, jetzt(), jetzt(), req.person.id); const z = db().prepare("SELECT * FROM spenden WHERE id = ?").get(Number(lastInsertRowid)); - protokolliere(req.person.id, "spende_hand", `${cent} Cent von ${name}`, echteIp(req)); + protokolliere("spende_hand", { + personId: req.person.id, + detail: `${cent} Cent von ${name}`, + ip: echteIp(req), + }); zeigen(z); res.status(201).json(alsKarte(z)); } catch (fehler) { @@ -300,7 +334,11 @@ spendenRouter.post("/workspace/api/spenden/:id(\\d+)/bestaetigen", nurGeld, bestaetigt_am = ?, bestaetigt_von = ? WHERE id = ?`) .run(cent, jetzt(), req.person.id, z.id); const neu = db().prepare("SELECT * FROM spenden WHERE id = ?").get(z.id); - protokolliere(req.person.id, "spende_bestaetigt", `#${z.id}, ${cent} Cent`, echteIp(req)); + protokolliere("spende_bestaetigt", { + personId: req.person.id, + detail: `#${z.id}, ${cent} Cent`, + ip: echteIp(req), + }); zeigen(neu); res.json(alsKarte(neu)); } catch (fehler) { @@ -324,6 +362,290 @@ spendenRouter.post("/workspace/api/spenden/:id(\\d+)/ablehnen", nurGeld, (req, r /** Dieselbe Karte noch einmal zeigen — fuer den Fall, dass sie * jemand verpasst hat oder die Technik im falschen Moment hakte. */ +/* ====================================================================== + DIE GESTALTUNG + + Filipe: „mach paar fertige und so dass ich auch hochladen kann. + auch so dass ich das anders gestalten kann oder die groesse + veraendern kann. ... auch spezielle sachen bei speziellen spenden." + + DIE GANZE LEITER AUF EINMAL UND NICHT ZEILE FUER ZEILE. Eine + Stufenleiter ist keine Liste unabhaengiger Zeilen -- sie muss als + Ganzes stimmen: aufsteigende Grenzen, keine zwei mit demselben + Betrag, mindestens eine. Wer einzeln speichert, geht durch + Zwischenzustaende, in denen sie das nicht tut, und waehrend einer + Sendung faellt dann eine Spende in die falsche Stufe. + ====================================================================== */ +spendenRouter.put("/workspace/api/spenden/stufen", nurGeld, + express.json({ limit: "16kb" }), (req, res) => { + try { + const roh = Array.isArray(req.body?.stufen) ? req.body.stufen : null; + if (!roh) return res.status(400).json({ fehler: "stufen_fehlen" }); + if (!roh.length) return res.status(400).json({ fehler: "keine_stufe" }); + if (roh.length > STUFEN_MAX) return res.status(400).json({ fehler: "zu_viele_stufen" }); + + const sauber = []; + for (const z of roh) { + const ab = Math.round(Number(z?.ab_cent)); + if (!Number.isFinite(ab) || ab <= 0 || ab > BETRAG_MAX_CENT) { + return res.status(400).json({ fehler: "stufe_betrag_unklar" }); + } + const name = String(z?.name ?? "").trim().slice(0, NAME_MAX); + if (!name) return res.status(400).json({ fehler: "stufe_name_fehlt" }); + + const vorlage = String(z?.vorlage ?? "herz"); + if (!VORLAGEN.includes(vorlage)) { + return res.status(400).json({ fehler: "vorlage_unbekannt" }); + } + /* NUR ECHTE FARBWERTE. Ein freier Text landete als CSS-Wert + in der Karte -- das ist genau die Stelle, an der aus einer + Einstellung eine Einschleusung wird. */ + const farbe = String(z?.farbe ?? "").trim(); + if (!/^#[0-9a-fA-F]{6}$/.test(farbe)) { + return res.status(400).json({ fehler: "farbe_unklar" }); + } + const dauer = Math.round(Number(z?.dauer_ms)); + if (!Number.isFinite(dauer) || dauer < DAUER_MS_MIN || dauer > DAUER_MS_MAX) { + return res.status(400).json({ fehler: "dauer_unklar" }); + } + const groesse = Number(z?.groesse); + if (!Number.isFinite(groesse) || groesse < GROESSE_MIN || groesse > GROESSE_MAX) { + return res.status(400).json({ fehler: "groesse_unklar" }); + } + sauber.push({ ab, name, vorlage, farbe, dauer, groesse, id: Number(z?.id) || 0 }); + } + + sauber.sort((a, b2) => a.ab - b2.ab); + for (let i = 1; i < sauber.length; i++) { + if (sauber[i].ab === sauber[i - 1].ab) { + return res.status(409).json({ fehler: "stufe_doppelt" }); + } + } + + /* ==== DIE KENNUNGEN MUESSEN DAS SPEICHERN UEBERLEBEN ======= + + Der erste Bau hat hier `DELETE` und dann `INSERT` gemacht. + Das Ergebnis war richtig -- und trotzdem falsch: Jede Stufe + bekam dabei eine NEUE Kennung. Gemessen ist das in der + Pruefung aufgefallen, als ein Bild-Hochladen gegen die eben + noch gueltige Kennung mit 404 antwortete. + + Im Alltag heisst das: Wer die Leiter speichert und dann auf + „Bild hochladen" drueckt, ohne dass die Seite dazwischen neu + gezeichnet wurde, greift ins Leere. Und ein zweiter offener + Bildschirm greift IMMER ins Leere. + + Also: vorhandene Zeilen AENDERN, neue anlegen, entfernte + loeschen. Dann bleibt die Kennung, und das Bild bleibt von + selbst daran haengen -- ohne eine Landkarte, die man pflegen + muesste. */ + const vorhanden = db().prepare("SELECT id, bild FROM spenden_stufen").all(); + const kenneIch = new Set(vorhanden.map((z) => z.id)); + const behalten = new Set(); + + /* EIN SCHREIBVORGANG. `node:sqlite` kennt kein `.transaction()`, + also von Hand -- ein Abbruch mittendrin haette eine Leiter + zurueckgelassen, die halb die alte und halb die neue ist. */ + db().exec("BEGIN IMMEDIATE"); + try { + /* ==== DREI SCHRITTE, UND DIE REIHENFOLGE IST DER PUNKT ==== + + `ab_cent` ist EINDEUTIG. Wer die Zeilen einfach der Reihe + nach umschreibt, stoesst mitten im Vorgang auf sich + selbst: Die 500er-Stufe auf 100 zu setzen scheitert, + solange die alte 100er noch dasteht -- obwohl das ERGEBNIS + voellig in Ordnung waere. Gemessen ist genau das passiert: + „UNIQUE constraint failed: spenden_stufen.ab_cent", und + der ganze Vorgang brach ab. + + Also erst alles aus dem Weg raeumen: Was weg soll, wird + geloescht; was bleibt, wandert kurz auf einen Betrag, den + niemand sonst hat (eine Milliarde plus die eigene + Kennung). Danach kann jede Zeile ihren endgueltigen Wert + bekommen, ohne einer anderen in die Quere zu kommen. + + Das ist nur INNERHALB des Schreibvorgangs sichtbar -- nach + aussen gibt es diesen Zwischenzustand nie. */ + for (const z of vorhanden) { + const bleibt = sauber.some((n) => n.id === z.id); + if (!bleibt) db().prepare("DELETE FROM spenden_stufen WHERE id = ?").run(z.id); + else { + db().prepare("UPDATE spenden_stufen SET ab_cent = ? WHERE id = ?") + .run(1000000000 + z.id, z.id); + } + } + + const aendern = db().prepare(`UPDATE spenden_stufen SET + ab_cent = ?, name = ?, vorlage = ?, farbe = ?, dauer_ms = ?, + groesse = ?, geaendert = ? WHERE id = ?`); + const ein = db().prepare(`INSERT INTO spenden_stufen + (ab_cent, name, vorlage, bild, farbe, dauer_ms, groesse, geaendert) + VALUES (?,?,?,'',?,?,?,?)`); + for (const z of sauber) { + if (z.id && kenneIch.has(z.id)) { + aendern.run(z.ab, z.name, z.vorlage, z.farbe, z.dauer, z.groesse, jetzt(), z.id); + behalten.add(z.id); + } else { + const { lastInsertRowid } = ein.run( + z.ab, z.name, z.vorlage, z.farbe, z.dauer, z.groesse, jetzt()); + behalten.add(Number(lastInsertRowid)); + } + } + db().exec("COMMIT"); + } catch (f) { + db().exec("ROLLBACK"); + throw f; + } + + /* WAS AN BILDERN UEBRIG BLEIBT, WIRD WEGGERAEUMT. Sonst + sammelt sich bei jeder geloeschten Stufe eine Datei an, die + niemand mehr findet und niemand mehr loescht. */ + for (const z of vorhanden) { + if (z.bild && !behalten.has(z.id)) { + try { unlinkSync(join(BILDER, z.bild)); } catch { /* war schon weg */ } + } + } + + protokolliere("spenden_stufen", { + personId: req.person.id, + detail: `${sauber.length} Stufen gespeichert`, + ip: echteIp(req), + }); + res.json({ ok: true, stufen: stufenLesen() }); + } catch (fehler) { + console.error("[spenden] Stufen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +function stufenLesen() { + return db().prepare("SELECT id, ab_cent, name, vorlage, bild, farbe, dauer_ms," + + " groesse FROM spenden_stufen ORDER BY ab_cent").all() + .map((z) => ({ ...z, bild: bildAdresse(z.bild), groesse: Number(z.groesse) || 1 })); +} + +/** Woran man ein Bild erkennt -- an seinem Inhalt und nicht an seinem + * Namen. Eine Datei „katze.png", die etwas anderes enthaelt, waere + * sonst ein Bild, das der Browser als etwas anderes liest. */ +function bildart(puffer) { + if (!puffer || puffer.length < 12) return null; + if (puffer[0] === 0xff && puffer[1] === 0xd8 && puffer[2] === 0xff) return "jpg"; + if (puffer[0] === 0x89 && puffer[1] === 0x50 && puffer[2] === 0x4e && puffer[3] === 0x47) { + return "png"; + } + if (puffer.toString("ascii", 0, 4) === "RIFF" && puffer.toString("ascii", 8, 12) === "WEBP") { + return "webp"; + } + return null; +} + +/* Ein zu grosses Bild ist kein Serverfehler, sondern eine ganz + normale Eingabe -- und der Absender soll erfahren, WAS zu tun ist. + Ohne diese Stelle laeuft der Fehler von express.raw() bis in die + allgemeine Behandlung durch und kommt als „500 Serverfehler" an. */ +function zuGross(fehler, req, res, next) { + if (fehler?.type === "entity.too.large") { + return res.status(413).json({ fehler: "bild_zu_gross" }); + } + return next(fehler); +} + +spendenRouter.post("/workspace/api/spenden/stufe/:id(\\d+)/bild", nurGeld, + express.raw({ type: "*/*", limit: BILD_MAX_BYTES }), zuGross, (req, res) => { + try { + const id = Number(req.params.id); + const stufe = db().prepare("SELECT id, bild FROM spenden_stufen WHERE id = ?").get(id); + if (!stufe) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const endung = bildart(req.body); + if (!endung) return res.status(400).json({ fehler: "kein_bild" }); + + mkdirSync(BILDER, { recursive: true }); + const name = `${randomBytes(16).toString("hex")}.${endung}`; + writeFileSync(join(BILDER, name), req.body); + + db().prepare("UPDATE spenden_stufen SET bild = ?, geaendert = ? WHERE id = ?") + .run(name, jetzt(), id); + /* Das alte wegraeumen -- sonst bleibt bei jedem Wechsel eine + Datei liegen, die niemand mehr findet. */ + if (stufe.bild && stufe.bild !== name) { + try { unlinkSync(join(BILDER, stufe.bild)); } catch { /* war schon weg */ } + } + protokolliere("spenden_bild", { + personId: req.person.id, + detail: `Stufe ${id}, ${Math.round(req.body.length / 1024)} KB`, + ip: echteIp(req), + }); + res.json({ ok: true, bild: bildAdresse(name), stufen: stufenLesen() }); + } catch (fehler) { + console.error("[spenden] Bild:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +spendenRouter.delete("/workspace/api/spenden/stufe/:id(\\d+)/bild", nurGeld, (req, res) => { + try { + const id = Number(req.params.id); + const stufe = db().prepare("SELECT id, bild FROM spenden_stufen WHERE id = ?").get(id); + if (!stufe) return res.status(404).json({ fehler: "nicht_gefunden" }); + db().prepare("UPDATE spenden_stufen SET bild = '', geaendert = ? WHERE id = ?") + .run(jetzt(), id); + if (stufe.bild) { try { unlinkSync(join(BILDER, stufe.bild)); } catch { /* war weg */ } } + res.json({ ok: true, stufen: stufenLesen() }); + } catch (fehler) { + console.error("[spenden] Bild loeschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Einmal ansehen, wie es aussieht -- OHNE eine Spende anzulegen. + * + * ==== DAS IST DER GANZE PUNKT DIESER ROUTE ========================== + * + * Wer eine Stufe gestaltet, will sehen, was herauskommt. Der + * naheliegende Weg waere, eine Spende von Hand einzutragen und + * danach zu loeschen -- und genau das ist verboten: In ein laufendes + * System kommen keine Testdaten. „Storniert heisst nicht weg"; der + * Eintrag stuende trotzdem im Verlauf, in der Summe und in der + * Aufbewahrung, und bei Geld ist das nicht nur unordentlich. + * + * Diese Route schreibt deshalb NICHTS. Sie baut eine Karte aus den + * aktuellen Stufen und schickt sie los. Und sie geht NUR an den, + * der drueckt -- eine Probe, die im ganzen Saal erscheint, waere + * eine Spende, die es nicht gab. */ +spendenRouter.post("/workspace/api/spenden/probe", nurGeld, + express.json({ limit: "2kb" }), (req, res) => { + try { + const cent = Math.round(Number(req.body?.betrag_cent)); + if (!Number.isFinite(cent) || cent <= 0 || cent > BETRAG_MAX_CENT) { + return res.status(400).json({ fehler: "betrag_unklar" }); + } + const st = stufeFuer(cent); + const karte = { + id: 0, + betrag_cent: cent, + waehrung: "EUR", + /* KENNTLICH ALS PROBE. Stuende hier ein Name, waere auf dem + Bildschirmfoto nicht mehr zu unterscheiden, ob jemand + gespendet hat. */ + name: "Probe", + gruss: "So sieht es aus", + herkunft: "hand", + stand: "probe", + erstellt: jetzt(), + stufe: st ? { + name: st.name, vorlage: st.vorlage, bild: bildAdresse(st.bild), + farbe: st.farbe, dauer_ms: st.dauer_ms, groesse: Number(st.groesse) || 1, + } : null, + }; + stromAn([req.person.id], "spende", { was: "zeigen", karte }); + res.json({ ok: true, karte }); + } catch (fehler) { + console.error("[spenden] Probe:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + spendenRouter.post("/workspace/api/spenden/:id(\\d+)/nochmal", nurGeld, (req, res) => { try { const z = db().prepare("SELECT * FROM spenden WHERE id = ? AND stand = 'bestaetigt'") diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index dec45a4b..23f4c42f 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +