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