Spenden: Stufen gestaltbar, Bild hochladen, Größe je Stufe

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 `<img>` 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 <[email protected]>
This commit is contained in:
2026-09-28 10:14:26 +02:00
co-authored by Claude Opus 5
parent 74c74a08ab
commit d2f02ba84e
61 changed files with 2140 additions and 714 deletions
+223
View File
@@ -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 `<input type="file" hidden>` -- 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 ...
+265 -4
View File
@@ -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 <img> 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");
+62
View File
@@ -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) ===`);
+15
View File
@@ -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"];
+65 -6
View File
@@ -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);
}
}
+21 -1
View File
@@ -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",
+55 -2
View File
@@ -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 `<img>` 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. */
+2 -2
View File
@@ -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) {
+20 -5
View File
@@ -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);
+41 -9
View File
@@ -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);
+327 -5
View File
@@ -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'")