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