diff --git a/server/mess-eingang-optik.mjs b/server/mess-eingang-optik.mjs
index 684f7777..fec23e43 100644
--- a/server/mess-eingang-optik.mjs
+++ b/server/mess-eingang-optik.mjs
@@ -77,15 +77,21 @@ const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAGQAAADwCAYAAAB7ovsMAAAAWklEQVR42u3QMQEAAAjAoNu/"
+ "tBmeIAfpVoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAA"
+ "AQIECBAgQIAAAQIECBAgQIAAAe4W1H8B4aXvOgAAAABJRU5ErkJggg==", "base64");
-d.prepare(`INSERT INTO support_meldungen
- (person_id, text, seite, bild_datei, bild_typ, bild_breite, bild_hoehe,
- stand, erstellt, geaendert)
- VALUES (?,?,?,?,?,?,?,?,?,?)`)
- .run(idModi,
+/* DIE BILDER STEHEN IN `support_bilder` (ab 02.10.2026), nicht mehr
+ in Spalten der Meldung. Hier direkt dort eintragen und NICHT auf
+ die Uebernahme beim Start hoffen: Die ist schon gelaufen, der
+ Server steht oben in dieser Datei laengst. */
+const meldungId = d.prepare(`INSERT INTO support_meldungen
+ (person_id, text, seite, stand, erstellt, geaendert)
+ VALUES (?,?,?,?,?,?) RETURNING id`)
+ .get(idModi,
"Ich konnte da wieder nicht zurück gehen, musste die App schließen "
+ "damit ich wieder auf die Hauptseite kam",
- "/workspace/support.html", "probe.png", "image/png", 100, 240,
- "in_arbeit", jetzt, jetzt);
+ "/workspace/support.html", "in_arbeit", jetzt, jetzt).id;
+d.prepare(`INSERT INTO support_bilder
+ (meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
+ VALUES (?,NULL,?,?,?,?,?)`)
+ .run(meldungId, "probe.png", "image/png", 100, 240, jetzt);
d.prepare(`INSERT INTO support_meldungen
(person_id, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?)`)
@@ -95,7 +101,10 @@ d.prepare(`INSERT INTO support_meldungen
const { DATEN_ORDNER } = await import("./workspace.js");
try {
const { mkdirSync, writeFileSync } = await import("node:fs");
- const ziel = join(DATEN_ORDNER, "support");
+ /* DER ORDNER HEISST `support-bilder`, nicht `support` -- so steht
+ es in workspace-support.js. Hier stand der falsche Name, und das
+ Bild landete neben der Tuer statt dahinter. */
+ const ziel = join(DATEN_ORDNER, "support-bilder");
mkdirSync(ziel, { recursive: true });
writeFileSync(join(ziel, "probe.png"), png);
} catch (f) { console.log("Bild nicht abgelegt:", f?.message); }
diff --git a/server/pruef-nachfrage.mjs b/server/pruef-nachfrage.mjs
index d081409d..562a5f4b 100644
--- a/server/pruef-nachfrage.mjs
+++ b/server/pruef-nachfrage.mjs
@@ -756,12 +756,17 @@ if (process.env.OHNE_BROWSER === "ja") {
});
await seite.waitForTimeout(400);
const gewaehlt = await seite.evaluate(() => {
- const schau = document.querySelector('dialog.nachfrage [data-teil="bildvorschau"]');
+ /* EIN KASTEN MIT EINER LISTE statt eines einzelnen
+ (02.10.2026): Seit VanVans Meldung #11 koennen es bis zu
+ drei sein, und `bildMax` entscheidet, wie viele. */
+ const kasten = document.querySelector('dialog.nachfrage [data-teil="bildvorschauen"]');
+ const schau = kasten?.querySelector("img") || null;
return {
name: document.querySelector('dialog.nachfrage [data-teil="bildname"]')
?.textContent || "",
wegDa: document.querySelector('dialog.nachfrage [data-teil="bildweg"]')?.hidden === false,
- vorschau: !!schau && !schau.hidden && (schau.getAttribute("src") || "").length > 0,
+ vorschau: !!kasten && !kasten.hidden && !!schau
+ && (schau.getAttribute("src") || "").length > 0,
hoch: schau ? Math.round(schau.getBoundingClientRect().height) : 0,
};
});
@@ -796,7 +801,7 @@ if (process.env.OHNE_BROWSER === "ja") {
feld.files = dt.files;
feld.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 300));
- const schau = document.querySelector('dialog.nachfrage [data-teil="bildvorschau"]');
+ const schau = document.querySelector('dialog.nachfrage .nachfrage__vorschau img');
const dlg = document.querySelector("dialog.nachfrage");
const ja = document.querySelector('dialog.nachfrage [data-teil="ja"]');
const r = schau.getBoundingClientRect();
@@ -813,9 +818,14 @@ if (process.env.OHNE_BROWSER === "ja") {
dialogHoch: Math.round(dlg.getBoundingClientRect().height),
};
});
+ /* 120 STATT 160 SEIT DEM 02.10.2026. Der Deckel ist gesunken,
+ weil in dem Kasten jetzt bis zu drei Bilder nebeneinander
+ stehen koennen -- bei 160 waere der Senden-Knopf bei drei
+ hochkanten Bildern aus dem Fenster. Die Zahl steht in
+ module.css und wird hier nachgemessen, nicht geraten. */
pruef(` ein 300x900 grosses Bild wird auf ${hoch.gezeigt} px gedeckelt`
- + ` (echt ${hoch.echt}, höchstens 160)`,
- hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 160);
+ + ` (echt ${hoch.echt}, höchstens 120)`,
+ hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 120);
pruef(` und es bleibt in der Breite (${hoch.breit} px)`, hoch.breit <= 300);
pruef(` der Senden-Knopf steht weiter im Fenster (Dialog ${hoch.dialogHoch} px)`,
hoch.jaImFenster);
@@ -825,15 +835,23 @@ if (process.env.OHNE_BROWSER === "ja") {
await seite.waitForTimeout(500);
const erg = await seite.evaluate(async () => {
const a = await window.__probe;
+ /* `bilder` UND NICHT `bild` (02.10.2026): Der Dialog gibt
+ immer eine Liste zurueck, auch wenn nur eines erlaubt war.
+ Ein Sonderfall „genau eines" waere die zweite Auskunft auf
+ dieselbe Frage -- und die veraltet. */
+ const eins = a && Array.isArray(a.bilder) ? a.bilder[0] : null;
return {
grund: a && a.grund,
- bildName: a && a.bild ? a.bild.name : null,
- bildTyp: a && a.bild ? a.bild.type : null,
- istDatei: !!(a && a.bild && typeof a.bild.size === "number"),
+ anzahl: a && Array.isArray(a.bilder) ? a.bilder.length : -1,
+ bildName: eins ? eins.name : null,
+ bildTyp: eins ? eins.type : null,
+ istDatei: !!(eins && typeof eins.size === "number"),
};
});
pruef(`der Dialog gibt den Grund zurück („${erg.grund}")`,
/siehe Bild/.test(erg.grund || ""));
+ pruef(` er gibt eine LISTE zurueck, nicht ein Feld (${erg.anzahl})`,
+ erg.anzahl === 1);
pruef(` und die Datei dazu (${erg.bildName}, ${erg.bildTyp})`,
/* und nicht : Zwei Zeilen
weiter oben wurde das Bild ERSETZT. Meine erste Fassung
@@ -875,6 +893,58 @@ if (process.env.OHNE_BROWSER === "ja") {
/Noch keins/.test(frisch));
await seite.click('dialog.nachfrage [data-teil="nein"]');
await seite.waitForTimeout(300);
+
+ /* ==== UND MIT `bildMax` SIND ES MEHRERE (02.10.2026) =========
+
+ VanVan im Support, Meldung #11: „2-3 wären besser." Der
+ Dialog kann das seither; ohne Angabe bleibt es bei einem,
+ damit sich fuer jeden anderen Aufruf im Haus nichts aendert.
+
+ DER UNTERSCHIED ZUM FALL DARUEBER IST DER PUNKT: Dort hat die
+ zweite Wahl die erste ERSETZT (ein Platz, eine Korrektur),
+ hier muss sie DAZUKOMMEN. Beides in derselben Pruefung, sonst
+ beweist keines etwas. */
+ await seite.evaluate(() => {
+ window.__probe4 = window.frageNach({
+ titel: "Mit dreien", ja: "Senden",
+ grund: "Was geht noch nicht?", grundPflicht: true, grundMin: 3,
+ bild: "Bildschirmfotos dazu (freiwillig)", bildMax: 3,
+ });
+ });
+ await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 });
+ const zaehl = () => seite.evaluate(() =>
+ document.querySelectorAll("dialog.nachfrage .nachfrage__vorschau").length);
+ for (const name of ["a.png", "b.png", "c.png", "d.png"]) {
+ await seite.setInputFiles("dialog.nachfrage #nachfrage-bild", {
+ name, mimeType: "image/png", buffer: PNG_PROBE,
+ });
+ await seite.waitForTimeout(250);
+ }
+ const dreiDa = await zaehl();
+ pruef(`mit bildMax 3 legt jede Wahl DAZU (${dreiDa} nach vier Griffen)`,
+ dreiDa === 3);
+ const grenzWort = await seite.evaluate(() =>
+ document.querySelector('dialog.nachfrage [data-teil="fehler"]')?.textContent || "");
+ pruef(` und das vierte wird mit einem Satz abgelehnt („${grenzWort}")`,
+ /3 Bilder/.test(grenzWort));
+
+ /* EINZELN WEGNEHMEN, nicht alles. */
+ await seite.click("dialog.nachfrage .nachfrage__vorschau:nth-child(2) "
+ + ".nachfrage__vorschau-weg");
+ await seite.waitForTimeout(200);
+ pruef(` eines einzeln entfernen laesst die anderen stehen (${await zaehl()})`,
+ await zaehl() === 2);
+
+ await seite.fill("dialog.nachfrage #nachfrage-grund", "Hier zwei Bilder dazu.");
+ await seite.click('dialog.nachfrage [data-teil="ja"]');
+ await seite.waitForTimeout(400);
+ const viele = await seite.evaluate(async () => {
+ const a = await window.__probe4;
+ return (a && Array.isArray(a.bilder)) ? a.bilder.map((b) => b.name) : null;
+ });
+ pruef(` und der Dialog gibt beide zurueck (${(viele || []).join(", ")})`,
+ Array.isArray(viele) && viele.length === 2
+ && viele[0] === "a.png" && viele[1] === "c.png");
}
pruef("keine Abstürze im Browser", abstuerze.length === 0, abstuerze[0] || "");
diff --git a/server/pruef-support-bilder.mjs b/server/pruef-support-bilder.mjs
new file mode 100644
index 00000000..9bea1089
--- /dev/null
+++ b/server/pruef-support-bilder.mjs
@@ -0,0 +1,466 @@
+/* =====================================================================
+ MEHRERE BILDER IM SUPPORT — IM ECHTEN BROWSER (02.10.2026)
+ =====================================================================
+
+ VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
+ Supportbereich kann man immer nur ein Bild hinzufügen bei einer
+ Meldung. 2-3 wären besser." Und in der zweiten Runde der Satz, auf
+ den es ankommt:
+
+ „wenn man es nacheinander versucht hinzuzufügen wird das Bild
+ immer nur ersetzt."
+
+ WARUM `pruef-support` DAFÜR NICHT REICHT. Die dortigen Prüfungen
+ sprechen mit dem Server und bekommen drei Bilder zurück -- grün,
+ und trotzdem wäre VanVans Problem unverändert da. Denn „es wird
+ nur ersetzt" passiert NICHT am Server, sondern in `support.js`:
+ Eine neue Auswahl im Dateifeld wirft die vorige weg, und zwar auch
+ dann, wenn das Feld `multiple` trägt. Ein Server, der drei Bilder
+ annimmt, hilft niemandem, dem der Browser nur eines mitgibt.
+
+ Also wird hier das gemessen, was SIE tut: dreimal hintereinander
+ ein Bild wählen. Und einmal viermal, damit die Grenze nicht nur im
+ Quelltext steht.
+
+ DREI AUSGÄNGE. Steht Playwright nicht bereit, endet die Prüfung mit
+ 2 und sagt, was fehlt -- „konnte nicht nachsehen" ist etwas anderes
+ als „in Ordnung".
+
+ Aufrufen mit: node server/pruef-support-bilder.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { eigenerPort } from "./helfer-port.mjs";
+const PORT = await eigenerPort(import.meta);
+const ordner = mkdtempSync(join(tmpdir(), "ws-supbild-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) {
+ return ec.call(this, n, w, { ...(o || {}), secure: false });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(300_000, "pruef-support-bilder");
+await new Promise((r) => setTimeout(r, 900));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+let geprueft = 0, fehler = 0;
+function ok(bedingung, text) {
+ geprueft++;
+ if (bedingung) { console.log(" ok " + text); return true; }
+ fehler++; console.log(" FEHL " + text); return false;
+}
+const melde = (t) => console.log(t);
+
+/* ---- Zugänge ------------------------------------------------------ */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
+};
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+anlegen("Miss", "modi", "CODE-MODI-0001");
+d.close();
+
+async function keksFuer(rolle, code) {
+ const an = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }),
+ });
+ return (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
+}
+const keksModi = await keksFuer("modi", "CODE-MODI-0001");
+const keksHand = await keksFuer("admin", "CODE-DOGI-0001");
+
+/* ---- Playwright, oder der dritte Ausgang -------------------------- */
+let pw = null;
+try {
+ pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+} catch (f) {
+ console.log("KONNTE NICHT NACHSEHEN: Playwright fehlt (" + (f?.message || "") + ").");
+ console.log(" Diese Pruefung braucht einen echten Browser -- was VanVan gemeldet hat,");
+ console.log(" passiert im Dateifeld und ist ueber die Schnittstelle nicht zu sehen.");
+ console.log(" Nachinstallieren in C:/Users/qciga/Documents/Obelix/Analyse:");
+ console.log(" npm i -D playwright && npx playwright install chromium");
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(2);
+}
+const browser = await pw.chromium.launch();
+
+/** Drei verschiedene Bilder -- verschieden LANG und verschieden GROSS.
+ *
+ * Drei gleiche waeren nicht zu unterscheiden: Wuerde die Oberflaeche
+ * dreimal dasselbe anzeigen oder die Reihenfolge vertauschen, faellt
+ * das nur auf, wenn die Bilder sich unterscheiden. */
+const BILDER = [
+ { name: "eins.png", typ: "image/png", b64:
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" },
+ { name: "zwei.gif", typ: "image/gif", b64:
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" },
+ { name: "drei.webp", typ: "image/webp", b64:
+ "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==" },
+];
+
+/** Eine Datei in ein `` legen -- SO WIE EIN MENSCH.
+ *
+ * `setInputFiles` von Playwright ersetzt die ganze Auswahl und
+ * feuert `change` -- also genau das, was auch der Dateidialog des
+ * Browsers tut. Mehrmals aufgerufen ist es genau VanVans Weg:
+ * „nacheinander versucht hinzuzufuegen". */
+async function waehle(seite, wahl, bilder) {
+ await seite.setInputFiles(wahl, bilder.map((b) => ({
+ name: b.name, mimeType: b.typ, buffer: Buffer.from(b.b64, "base64"),
+ })));
+ await seite.waitForTimeout(120);
+}
+
+async function neueSeite(breite, keks) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: 900 }, hasTouch: breite <= 860,
+ });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ seite.on("pageerror", (f) => { console.log(" JS-FEHLER: " + f.message); fehler++; });
+ await seite.goto(BASIS + "/workspace/support.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(500);
+ return { kontext, seite };
+}
+
+/* =====================================================================
+ 1. NACHEINANDER WAEHLEN LEGT DAZU — VanVans Satz, gemessen
+ ===================================================================== */
+melde("\n=== Nacheinander waehlen (VanVan, Meldung #11, Runde 2) ===");
+{
+ const { kontext, seite } = await neueSeite(390, keksModi);
+ const zahl = () => seite.locator("#vorschau .s-vorschau__eins").count();
+
+ ok(await zahl() === 0, `vorher ist nichts gewaehlt (${await zahl()})`);
+
+ await waehle(seite, "#bild", [BILDER[0]]);
+ const n1 = await zahl();
+ ok(n1 === 1, `nach dem ersten Bild: eine Vorschau (${n1})`);
+
+ /* HIER WAR DER FEHLER. Vorher stand die Auswahl in einer einzigen
+ Variablen -- der zweite Griff warf den ersten weg, und genau das
+ hat VanVan beschrieben. */
+ await waehle(seite, "#bild", [BILDER[1]]);
+ const n2 = await zahl();
+ ok(n2 === 2, `nach dem ZWEITEN: zwei (${n2}) — vorher wurde hier ersetzt`);
+
+ await waehle(seite, "#bild", [BILDER[2]]);
+ const n3 = await zahl();
+ ok(n3 === 3, `nach dem dritten: drei (${n3})`);
+
+ /* DIE REIHENFOLGE BLEIBT, und es sind wirklich die drei. Abgelesen
+ am Alternativtext, in dem der Dateiname steht. */
+ const texte = await seite.locator("#vorschau img").evaluateAll(
+ (bilder) => bilder.map((b) => b.alt));
+ ok(texte.length === 3 && /eins\.png/.test(texte[0])
+ && /zwei\.gif/.test(texte[1]) && /drei\.webp/.test(texte[2]),
+ `und zwar in der Reihenfolge des Waehlens (${texte.join(", ")})`);
+
+ /* ---- DIE GRENZE ----------------------------------------------- */
+ await waehle(seite, "#bild", [{ ...BILDER[0], name: "vier.png" }]);
+ const n4 = await zahl();
+ ok(n4 === 3, `ein viertes kommt nicht dazu (${n4})`);
+ const hinweis = (await seite.locator("#melden-fehler").textContent() || "").trim();
+ ok(/3 Bilder|drei Bilder/i.test(hinweis) && hinweis.length > 10,
+ `und es steht dabei, warum („${hinweis}")`);
+
+ /* ---- DASSELBE BILD ZWEIMAL IST EINES --------------------------- */
+ const vorher = await zahl();
+ await waehle(seite, "#bild", [BILDER[0]]);
+ ok(await zahl() === vorher,
+ `dasselbe Bild noch einmal zaehlt nicht doppelt (${await zahl()})`);
+
+ /* ---- EINZELN WIEDER WEGNEHMEN ---------------------------------- */
+ await seite.locator("#vorschau .s-vorschau__eins").nth(1)
+ .locator(".s-vorschau__weg").click();
+ await seite.waitForTimeout(120);
+ const nachWeg = await seite.locator("#vorschau img").evaluateAll(
+ (bilder) => bilder.map((b) => b.alt));
+ ok(nachWeg.length === 2 && /eins\.png/.test(nachWeg[0]) && /drei\.webp/.test(nachWeg[1]),
+ `das mittlere einzeln entfernen laesst die anderen stehen (${nachWeg.join(", ")})`);
+
+ /* ---- UND „ALLE WEG" NIMMT ALLE ---------------------------------- */
+ await seite.locator("#bild-weg").click();
+ await seite.waitForTimeout(120);
+ ok(await zahl() === 0, `„Alle weg" raeumt auf (${await zahl()})`);
+ ok(await seite.locator("#vorschau").isHidden(),
+ "und der leere Kasten nimmt keinen Platz mehr ein");
+
+ await kontext.close();
+}
+
+/* =====================================================================
+ 2. DIE KREUZE SIND ZU TREFFEN — 44 px und nichts davor
+ ===================================================================== */
+melde("\n=== Die Kreuze auf 390 px ===");
+{
+ const { kontext, seite } = await neueSeite(390, keksModi);
+ for (const b of BILDER) await waehle(seite, "#bild", [b]);
+
+ const mass = await seite.evaluate(() => {
+ const raus = [];
+ for (const k of document.querySelectorAll("#vorschau .s-vorschau__weg")) {
+ const r = k.getBoundingClientRect();
+ const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
+ raus.push({
+ breite: Math.round(r.width), hoehe: Math.round(r.height),
+ frei: k.contains(oben) || k === oben,
+ imBild: r.right <= document.documentElement.clientWidth + 0.5 && r.left >= -0.5,
+ });
+ }
+ return raus;
+ });
+ ok(mass.length === 3, `drei Kreuze da (${mass.length})`);
+ const zuKlein = mass.filter((m) => m.breite < 44 || m.hoehe < 44);
+ ok(mass.length === 3 && zuKlein.length === 0,
+ `jedes misst mindestens 44x44 (${mass.map((m) => `${m.breite}x${m.hoehe}`).join(", ")})`);
+ const verdeckt = mass.filter((m) => !m.frei);
+ ok(mass.length === 3 && verdeckt.length === 0,
+ `und keines liegt unter etwas anderem (${verdeckt.length} verdeckt)`);
+ const draussen = mass.filter((m) => !m.imBild);
+ ok(mass.length === 3 && draussen.length === 0,
+ `alle drei stehen im sichtbaren Bereich (${draussen.length} daneben)`);
+
+ /* GEGENPROBE: Kann diese Messung ueberhaupt „verdeckt" sagen? Ein
+ Deckel ueber das erste Kreuz, und sie MUSS anschlagen -- sonst
+ waere der gruene Haken oben wertlos. */
+ await seite.evaluate(() => {
+ const k = document.querySelector("#vorschau .s-vorschau__weg");
+ const r = k.getBoundingClientRect();
+ const deckel = document.createElement("div");
+ deckel.id = "probe-deckel";
+ deckel.style.cssText = `position:fixed;left:${r.left}px;top:${r.top}px;`
+ + `width:${r.width}px;height:${r.height}px;z-index:9999;`;
+ document.body.append(deckel);
+ });
+ const mitDeckel = await seite.evaluate(() => {
+ const k = document.querySelector("#vorschau .s-vorschau__weg");
+ const r = k.getBoundingClientRect();
+ const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
+ return k.contains(oben) || k === oben;
+ });
+ ok(mitDeckel === false, "Gegenprobe: ein Deckel darueber wird erkannt");
+ await seite.evaluate(() => document.getElementById("probe-deckel")?.remove());
+
+ /* ---- UND NICHTS RAGT AUS DER SEITE -------------------------- */
+ const ueber = await seite.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ ok(ueber <= 0, `die Seite mit drei Vorschauen ist nicht zu breit (${ueber} px)`);
+
+ await kontext.close();
+}
+
+/* =====================================================================
+ 3. ABSCHICKEN UND WIEDERSEHEN
+ ===================================================================== */
+melde("\n=== Abschicken und wiedersehen ===");
+let meldungId = 0;
+{
+ const { kontext, seite } = await neueSeite(390, keksModi);
+ await seite.fill("#text", "Vorher, der Klick und nachher – drei Bilder dazu.");
+ for (const b of BILDER) await waehle(seite, "#bild", [b]);
+ await seite.click("#senden");
+ await seite.waitForTimeout(1200);
+
+ const bilder = await seite.locator("#meine-liste .s-karte__bild img").count();
+ ok(bilder === 3, `die eigene Karte zeigt alle drei (${bilder})`);
+
+ /* GELADEN HEISST GELADEN. Ein mit kaputter Adresse steht
+ genauso im Baum wie ein gutes -- `naturalWidth` ist der
+ Unterschied. Ohne diese Zeile waeren drei 404er gruen. */
+ await seite.waitForTimeout(600);
+ const echt = await seite.locator("#meine-liste .s-karte__bild img").evaluateAll(
+ (bs) => bs.filter((b) => b.complete && b.naturalWidth > 0).length);
+ ok(echt === 3, `und alle drei kommen wirklich vom Server (${echt} geladen)`);
+
+ const wege = await seite.locator("#meine-liste .s-karte__bild a").evaluateAll(
+ (as) => as.map((a) => a.getAttribute("href")));
+ ok(new Set(wege).size === 3,
+ `drei verschiedene Adressen, nicht dreimal dieselbe (${new Set(wege).size})`);
+
+ const ueber = await seite.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ ok(ueber <= 0, `die Karte mit drei Bildern ist nicht zu breit (${ueber} px)`);
+
+ /* Das Feld ist danach leer -- sonst haengt beim naechsten Melden
+ noch die alte Auswahl dran. */
+ ok(await seite.locator("#vorschau .s-vorschau__eins").count() === 0,
+ "nach dem Abschicken ist die Auswahl leer");
+
+ meldungId = await seite.evaluate(() => {
+ const k = document.querySelector("#meine-liste .s-karte");
+ return Number((k?.id || "").replace("meldung-", "")) || 0;
+ });
+ ok(meldungId > 0, `die Meldung hat eine Nummer (#${meldungId})`);
+ await kontext.close();
+}
+
+/* =====================================================================
+ 4. UND IM „NOCH NICHT"-FENSTER GENAUSO
+ ===================================================================== */
+melde("\n=== Bilder in der Rueckfrage ===");
+{
+ /* Die Leitung schickt die Meldung zum Melder zurueck -- erst dann
+ gibt es die zwei Knoepfe. */
+ await fetch(BASIS + `/workspace/api/support/${meldungId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", cookie: keksHand },
+ body: JSON.stringify({ stand: "erledigt", antwort: "Sollte jetzt gehen." }),
+ });
+
+ const { kontext, seite } = await neueSeite(390, keksModi);
+ await seite.locator("#meine-liste .s-tun--geht").waitFor({ timeout: 8000 });
+ await seite.locator("#meine-liste .s-karte__tun .schritt:not(.s-tun--geht)").click();
+ await seite.locator("dialog.nachfrage").waitFor({ state: "visible", timeout: 8000 });
+ ok(true, "das Fenster „Geht es noch nicht?“ geht auf");
+
+ const dz = () => seite.locator("dialog.nachfrage .nachfrage__vorschau").count();
+ ok(await dz() === 0, `vorher keine Vorschau (${await dz()})`);
+
+ await waehle(seite, "#nachfrage-bild", [BILDER[0]]);
+ ok(await dz() === 1, `ein Bild (${await dz()})`);
+ await waehle(seite, "#nachfrage-bild", [BILDER[1]]);
+ ok(await dz() === 2, `nacheinander gewaehlt macht zwei (${await dz()}) — auch hier`);
+ await waehle(seite, "#nachfrage-bild", [BILDER[2]]);
+ await waehle(seite, "#nachfrage-bild", [{ ...BILDER[1], name: "vier.gif" }]);
+ ok(await dz() === 3, `und bei drei ist Schluss (${await dz()})`);
+
+ await seite.fill("#nachfrage-grund", "Geht noch nicht, hier drei Bildschirmfotos.");
+ await seite.locator("dialog.nachfrage [data-teil=\"ja\"]").click();
+ await seite.waitForTimeout(1500);
+
+ const imVerlauf = await seite.locator("#meine-liste .s-runde .s-karte__bild img").count();
+ ok(imVerlauf === 3, `im Verlauf haengen alle drei an der Runde (${imVerlauf})`);
+ const echt = await seite.locator("#meine-liste .s-runde .s-karte__bild img").evaluateAll(
+ (bs) => bs.filter((b) => b.complete && b.naturalWidth > 0).length);
+ ok(echt === 3, `und alle drei laden (${echt})`);
+
+ /* DIE BILDER DER MELDUNG BLEIBEN OBEN, die der Antwort unten. Sonst
+ waere nach drei Runden nicht mehr zu sehen, was wozu gehoert. */
+ const oben = await seite.locator("#meine-liste .s-karte > .s-karte__bild img").count();
+ ok(oben === 3, `oben stehen weiterhin die drei vom Melden (${oben})`);
+
+ const ueber = await seite.evaluate(() =>
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ ok(ueber <= 0, `und die Seite bleibt schmal genug (${ueber} px)`);
+
+ await kontext.close();
+}
+
+/* =====================================================================
+ 5. AUF DEM GROSSEN BILDSCHIRM: KEINE LEERE SPALTE
+ =====================================================================
+
+ Ab 760 px steht das Bild der Meldung NEBEN dem Text. Seit die
+ Rundenbilder denselben Kasten benutzen, trifft diese Regel ohne den
+ Pfeil in `:has(> …)` auch auf eine Karte zu, deren einziges Bild
+ tief im Verlauf steckt -- die Karte bekaeme eine zweite Spalte, in
+ der nichts steht.
+ ===================================================================== */
+melde("\n=== Die zweite Spalte nur mit eigenem Bild ===");
+{
+ /* Eine Meldung OHNE Bild, aber mit einem Bild in der Antwortrunde. */
+ const neu = await fetch(BASIS + "/workspace/api/support", {
+ method: "POST",
+ headers: { cookie: keksModi, "Content-Type": "text/plain",
+ "x-text": encodeURIComponent("Diese Meldung hat selbst kein Bild.") },
+ });
+ const id2 = (await neu.json())?.meldung?.id;
+ await fetch(BASIS + `/workspace/api/support/${id2}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", cookie: keksHand },
+ body: JSON.stringify({ stand: "erledigt", antwort: "Schau mal." }),
+ });
+ await fetch(BASIS + `/workspace/api/support/${id2}/rueckmeldung`, {
+ method: "POST",
+ headers: { cookie: keksModi, "Content-Type": "application/octet-stream",
+ "x-geht": "nein", "x-text": encodeURIComponent("Geht noch nicht, siehe Bild.") },
+ body: Buffer.from(BILDER[0].b64, "base64"),
+ });
+
+ const { kontext, seite } = await neueSeite(1280, keksModi);
+ const mass = await seite.evaluate((nr) => {
+ const k = document.getElementById("meldung-" + nr);
+ if (!k) return null;
+ const st = getComputedStyle(k);
+ const text = k.querySelector(".s-karte__text");
+ return {
+ raster: st.display,
+ spalten: st.gridTemplateColumns,
+ textBreite: Math.round(text?.getBoundingClientRect().width || 0),
+ karteBreite: Math.round(k.getBoundingClientRect().width),
+ };
+ }, id2);
+ ok(!!mass, `die Karte ohne eigenes Bild ist da (#${id2})`);
+ /* GEMESSEN WIRD `display`, NICHT DIE TEXTBREITE. Erst stand hier
+ „der Text nimmt mehr als 90 % ein" -- und die Gegenprobe darunter
+ wurde rot, obwohl alles stimmte: Die Pruefbilder sind 1x1 Bildpunkt
+ gross, die Bildspalte damit wenige Pixel breit, und der Text nimmt
+ auch MIT zweiter Spalte 93 %. Die Zahl mass die Bildgroesse, nicht
+ die Regel. `display` sagt genau das, was die Regel schaltet. */
+ ok(mass?.raster !== "grid",
+ `und sie bekommt KEINE zweite Spalte (display: ${mass?.raster})`);
+
+ /* GEGENPROBE: Bei einer Karte MIT eigenem Bild soll die zweite
+ Spalte sehr wohl kommen -- sonst waere die Zeile darueber nur
+ deshalb gruen, weil die Regel gar nicht mehr wirkt. */
+ const mitBild = await seite.evaluate((nr) => {
+ const k = document.getElementById("meldung-" + nr);
+ if (!k) return null;
+ return {
+ display: getComputedStyle(k).display,
+ spalten: getComputedStyle(k).gridTemplateColumns,
+ eigene: k.querySelectorAll(":scope > .s-karte__bild img").length,
+ imVerlauf: k.querySelectorAll(".s-runde .s-karte__bild img").length,
+ };
+ }, meldungId);
+ ok(mitBild?.display === "grid" && mitBild.eigene === 3,
+ `Gegenprobe: MIT eigenem Bild gibt es die zweite Spalte `
+ + `(${mitBild?.display}, ${mitBild?.eigene} eigene Bilder)`);
+ /* UND BEIDE KARTEN HABEN BILDER IM VERLAUF -- die eine mit, die
+ andere ohne eigenes. Ohne diese Zeile koennte der Unterschied
+ oben auch daher ruehren, dass die zweite Karte ueberhaupt kein
+ Bild hat. */
+ ok((mitBild?.imVerlauf || 0) === 3,
+ ` und beide Karten haben Rundenbilder (${mitBild?.imVerlauf})`);
+
+ await kontext.close();
+}
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+console.log("");
+console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
+console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-support.mjs b/server/pruef-support.mjs
index f63e2b79..5758eca9 100644
--- a/server/pruef-support.mjs
+++ b/server/pruef-support.mjs
@@ -158,6 +158,20 @@ const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
+/** Noch zwei Bilder -- und zwar mit ABSICHTLICH VERSCHIEDENEN LAENGEN
+ * (gemessen: PNG 70, GIF 42, WebP 34 Bytes).
+ *
+ * Drei gleich lange Bilder wuerden die Zerlegung des Rumpfes nicht
+ * pruefen: Waere die Reihenfolge der Laengen vertauscht oder die
+ * Schnittstelle um ein Byte verschoben, kaeme trotzdem etwas
+ * Bildaehnliches heraus. Mit 70/42/34 faellt jeder Fehlschnitt auf,
+ * weil das Ergebnis dann weder erkannt wird noch die richtige Zahl
+ * Bytes hat. Drei verschiedene Formate pruefen nebenbei, dass der
+ * Typ je Bild aus SEINEN Kopfbytes kommt und nicht aus dem
+ * Content-Type der ganzen Anfrage. */
+const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
+const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64");
+
const kekse = {};
melde("\n=== Alle neun Rollen kommen herein ===");
for (const [rolle] of ROLLEN) {
@@ -279,6 +293,7 @@ melde("\n=== Die Seite ist fuer jede Rolle offen ===");
===================================================================== */
melde("\n=== Melden ===");
let meldungMitBild = 0;
+let bildWeg = "";
{
/* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */
const leer = await hol("creator", "/workspace/api/support", {
@@ -305,8 +320,16 @@ let meldungMitBild = 0;
if (mitBild.status === 201) {
const j = await mitBild.json();
meldungMitBild = j.meldung?.id || 0;
- ok(!!j.meldung?.bild, "und die Antwort nennt die Adresse des Bildes");
- } else { ok(false, "kein Bild in der Antwort"); }
+ bildWeg = j.meldung?.bilder?.[0]?.weg || "";
+ /* EINE LISTE, AUCH BEI EINEM BILD (02.10.2026). Vorher stand hier
+ `j.meldung.bild` -- ein Feld fuer genau eines. Seit VanVans
+ Meldung #11 sind es bis zu drei, und ein Sonderfall „genau
+ eines" waere die zweite Auskunft auf dieselbe Frage. */
+ ok(Array.isArray(j.meldung?.bilder) && j.meldung.bilder.length === 1,
+ `und die Antwort nennt genau ein Bild (${j.meldung?.bilder?.length})`);
+ ok(/^\/workspace\/api\/support\/\d+\/bild\/\d+$/.test(bildWeg),
+ ` mit seiner eigenen Nummer im Weg (${bildWeg})`);
+ } else { ok(false, "kein Bild in der Antwort"); ok(false, "kein Weg zum Bild"); }
/* DER STANDARDFEHLER BEI UPLOADS: eine HTML-Datei mit Bildnamen.
Sie liefe im Browser als Seite DIESER Domain, mit Zugriff auf die
@@ -360,11 +383,11 @@ melde("\n=== Wer sieht was ===");
}
/* DAS BILD EINER FREMDEN MELDUNG bleibt fremd. */
- const fremd = await hol("creator", `/workspace/api/support/${meldungMitBild}/bild`);
+ const fremd = await hol("creator", bildWeg);
ok(fremd.status === 404,
`das Bild einer fremden Meldung gibt es fuer andere nicht (${fremd.status})`);
- const eigen = await hol("modi", `/workspace/api/support/${meldungMitBild}/bild`);
+ const eigen = await hol("modi", bildWeg);
ok(eigen.status === 200, `der Melder sieht sein eigenes Bild (${eigen.status})`);
ok(eigen.headers.get("content-type") === "image/png",
`ausgeliefert wird der ERKANNTE Typ (${eigen.headers.get("content-type")})`);
@@ -373,8 +396,22 @@ melde("\n=== Wer sieht was ===");
ok(/sandbox/.test(eigen.headers.get("content-security-policy") || ""),
"und einer Richtlinie, die jede Ausfuehrung nimmt");
- const leitungSieht = await hol("hand", `/workspace/api/support/${meldungMitBild}/bild`);
+ const leitungSieht = await hol("hand", bildWeg);
ok(leitungSieht.status === 200, "die Leitung sieht es auch");
+
+ /* ==== DIE MELDUNGSNUMMER IM WEG IST DIE SICHTBARKEITSFRAGE =======
+
+ Der Weg lautet `/support//bild/`, und die
+ Bildnummer allein waere eindeutig. Trotzdem muss BEIDES
+ zusammenpassen -- sonst koennte man die eigene Meldungsnummer
+ vorn hinschreiben und hinten die Bildnummer einer fremden
+ durchprobieren. Gegenprobe: derselbe Bildweg, aber eine andere
+ Meldung davor. */
+ const bildNr = Number((bildWeg.match(/\/bild\/(\d+)$/) || [])[1] || 0);
+ const vertauscht = await hol("modi",
+ `/workspace/api/support/${meldungMitBild + 1}/bild/${bildNr}`);
+ ok(bildNr > 0 && vertauscht.status === 404,
+ `eine fremde Meldungsnummer vor der eigenen Bildnummer gibt 404 (${vertauscht.status})`);
}
/* =====================================================================
@@ -597,19 +634,25 @@ melde("\n=== Sich kuemmern ===");
const stand = await standVon("modi", neuId);
const r1 = (stand?.verlauf || [])[0];
- ok(!!r1?.urteil_bild,
- `im Verlauf haengt das Bild an Runde ${r1?.nr} (${r1?.urteil_bild})`);
+ const r1weg = r1?.bilder?.[0]?.weg || "";
+ ok((r1?.bilder || []).length === 1,
+ `im Verlauf haengt das Bild an Runde ${r1?.nr} (${(r1?.bilder || []).length})`);
/* AN DER RUNDE UND NICHT AN DER MELDUNG. Sonst waere nach drei
Runden nicht mehr zu sehen, welches Bild zu welchem Versuch
- gehoerte. */
- ok(/\/runde\/1\/bild$/.test(r1?.urteil_bild || ""),
- " und zwar an DIESER Runde, nicht oben an der Meldung");
+ gehoerte.
+ SEIT DEM 02.10. STEHT DAS NICHT MEHR IM WEG (die Adresse nennt
+ nur noch die Bildnummer), also wird es dort geprueft, wo es
+ jetzt steht: Die Meldung selbst hat KEIN Bild bekommen. Das
+ ist die staerkere Pruefung -- sie misst die Zuordnung in den
+ Daten statt eine Zeichenkette. */
+ ok((stand?.bilder || []).length === 0,
+ ` und NICHT oben an der Meldung (${(stand?.bilder || []).length} dort)`);
ok(/noch nicht|nicht/.test(r1?.urteil || ""), ` das Urteil steht dabei (${r1?.urteil})`);
ok(/springt sie nach oben/.test(r1?.urteil_text || ""),
` und der Text kam durch den Kopf heil an („${r1?.urteil_text}")`);
/* ---- Das Bild kommt auch heraus ------------------------------ */
- const holen = await hol("modi", r1.urteil_bild);
+ const holen = await hol("modi", r1weg);
const bytes = Buffer.from(await holen.arrayBuffer());
ok(holen.status === 200 && bytes.length === PNG.length,
`der Melder bekommt es wieder (HTTP ${holen.status}, ${bytes.length} von ${PNG.length} Bytes)`);
@@ -620,21 +663,25 @@ melde("\n=== Sich kuemmern ===");
ok(holen.headers.get("accept-ranges") === "bytes",
"und ueber den gemeinsamen Ausliefer-Weg (Accept-Ranges)");
- const auchLeitung = await hol("hand", r1.urteil_bild);
+ const auchLeitung = await hol("hand", r1weg);
ok(auchLeitung.status === 200,
`die Leitung sieht es auch (${auchLeitung.status}) — sie soll ja darauf schauen`);
/* ---- GEGENPROBEN ---------------------------------------------- */
- const fremder = await hol("gast", r1.urteil_bild);
+ const fremder = await hol("gast", r1weg);
ok(fremder.status === 404,
`ein Fremder bekommt 404 (${fremder.status}) — nicht 403, sonst waere die Nummer verraten`);
- const ohneAnmeldung = await fetch(BASIS + r1.urteil_bild);
+ const ohneAnmeldung = await fetch(BASIS + r1weg);
ok(ohneAnmeldung.status !== 200,
`ohne Anmeldung gar nichts (${ohneAnmeldung.status})`);
- const gibtsNicht = await hol("modi", `/workspace/api/support/${neuId}/runde/9/bild`);
- ok(gibtsNicht.status === 404, `eine Runde ohne Bild gibt 404 (${gibtsNicht.status})`);
+ /* EINE BILDNUMMER, DIE ES NICHT GIBT. Vorher stand hier
+ „eine Runde ohne Bild" -- die Route dazu gibt es seit dem
+ 02.10. nicht mehr, weil Bilder jetzt ihre eigene Nummer haben.
+ Gefragt wird dieselbe Sache an der neuen Tuer. */
+ const gibtsNicht = await hol("modi", `/workspace/api/support/${neuId}/bild/999999`);
+ ok(gibtsNicht.status === 404, `eine erfundene Bildnummer gibt 404 (${gibtsNicht.status})`);
/* KEIN BILD IST WEITERHIN ERLAUBT -- ein Bildschirmfoto ist Hilfe,
keine Huerde. Ohne diese Zeile koennte die Pflicht hereinrutschen,
@@ -665,6 +712,322 @@ melde("\n=== Sich kuemmern ===");
}
}
+/* =====================================================================
+ 5b. MEHRERE BILDER JE MELDUNG (02.10.2026)
+ =====================================================================
+
+ VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
+ Supportbereich kann man immer nur ein Bild hinzufuegen bei einer
+ Meldung. 2-3 waeren besser." Und in Runde 2 der Grund, warum der
+ erste Anlauf nicht reichte: „wenn man es nacheinander versucht
+ hinzuzufuegen wird das Bild immer nur ersetzt."
+
+ DREI VERSCHIEDENE FORMATE MIT DREI VERSCHIEDENEN LAENGEN (PNG 70,
+ GIF 42, WebP 34). Waeren alle drei gleich lang, wuerde ein
+ Fehlschnitt im Rumpf gar nicht auffallen -- siehe die Begruendung
+ bei den Konstanten oben.
+ ===================================================================== */
+melde("\n=== Mehrere Bilder ===");
+{
+ const drei = [PNG, GIF, WEBP];
+ const dreiAn = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Vorher, der Klick und nachher - drei Bilder."),
+ "Content-Type": "application/octet-stream",
+ "x-bilder": drei.map((b) => b.length).join(","),
+ },
+ body: Buffer.concat(drei),
+ });
+ ok(dreiAn.status === 201, `drei Bilder auf einmal (${dreiAn.status})`);
+ const dreiM = dreiAn.status === 201 ? (await dreiAn.json()).meldung : null;
+ ok((dreiM?.bilder || []).length === 3,
+ `und alle drei haengen dran (${(dreiM?.bilder || []).length})`);
+
+ /* JEDES BILD EINZELN NACHGEMESSEN -- Reihenfolge, Laenge, Typ.
+ Das ist der eigentliche Beweis, dass der Rumpf richtig zerlegt
+ wurde: Eine um ein Byte verschobene Grenze gaebe hier sofort
+ einen anderen Typ oder eine andere Laenge. */
+ const erwartet = [
+ { bytes: PNG, typ: "image/png" },
+ { bytes: GIF, typ: "image/gif" },
+ { bytes: WEBP, typ: "image/webp" },
+ ];
+ let stimmen = 0;
+ for (const [i, e] of erwartet.entries()) {
+ const weg = dreiM?.bilder?.[i]?.weg;
+ if (!weg) continue;
+ const a = await hol("modi", weg);
+ const roh = Buffer.from(await a.arrayBuffer());
+ if (a.status === 200 && roh.equals(e.bytes)
+ && a.headers.get("content-type") === e.typ) stimmen++;
+ }
+ /* DIE ZAHL STEHT IN DER BEDINGUNG, nicht nur im Text -- sonst waere
+ die Pruefung auch mit null geholten Bildern gruen. */
+ ok(stimmen === 3,
+ `jedes kommt in SEINER Reihenfolge und mit SEINEM Typ zurueck (${stimmen} von 3)`);
+
+ /* ---- Die Grenze gilt ------------------------------------------- */
+ const vier = [PNG, GIF, WEBP, PNG];
+ const zuViele = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Hier sind es vier Bilder, das ist eines zu viel."),
+ "Content-Type": "application/octet-stream",
+ "x-bilder": vier.map((b) => b.length).join(","),
+ },
+ body: Buffer.concat(vier),
+ });
+ ok(zuViele.status === 400, `vier Bilder sind eines zu viel (${zuViele.status})`);
+
+ /* ---- Eine falsche Laenge wird nicht einfach hingenommen -------- */
+ const schief = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Die Laengen im Kopf passen nicht zum Rumpf."),
+ "Content-Type": "application/octet-stream",
+ "x-bilder": `${PNG.length},${GIF.length - 5}`,
+ },
+ body: Buffer.concat([PNG, GIF]),
+ });
+ ok(schief.status === 400,
+ `eine Summe, die nicht aufgeht, wird abgelehnt (${schief.status})`);
+
+ /* ---- UND EIN FAULES UNTER DREI VERDIRBT ALLE DREI -------------- */
+ const faul = [PNG, Buffer.from(""), GIF];
+ const gemischt = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Zwei Bilder und eine getarnte HTML-Datei dazwischen."),
+ "Content-Type": "application/octet-stream",
+ "x-bilder": faul.map((b) => b.length).join(","),
+ },
+ body: Buffer.concat(faul),
+ });
+ ok(gemischt.status === 415,
+ `ein getarntes HTML zwischen zwei Bildern kippt die ganze Meldung (${gemischt.status})`);
+
+ /* GEGENPROBE ZUM AUFRAEUMEN: Die abgelehnte Meldung darf es nicht
+ geben -- weder als Zeile noch halb, mit dem einen Bild, das vor
+ dem faulen schon geschrieben war. */
+ const listeDanach = await (await hol("modi", "/workspace/api/support")).json();
+ const geister = (listeDanach.meldungen || [])
+ .filter((m) => /getarnte HTML-Datei/.test(m.text || "")).length;
+ ok(geister === 0, `und hinterlaesst keine halbe Meldung (${geister})`);
+
+ /* ---- OHNE DEN KOPF IST ES EIN BILD, wie vor dem Umbau ---------- */
+ const altWeg = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Eine Seite aus dem Zwischenspeicher schickt ohne x-bilder."),
+ "Content-Type": "image/png",
+ },
+ body: PNG,
+ });
+ const altM = altWeg.status === 201 ? (await altWeg.json()).meldung : null;
+ ok(altWeg.status === 201 && (altM?.bilder || []).length === 1,
+ `ohne x-bilder gilt der ganze Rumpf als EIN Bild (${altWeg.status}, `
+ + `${(altM?.bilder || []).length})`);
+
+ /* ---- NACHLEGEN IN DERSELBEN RUNDE ERSETZT, statt zu haeufen ---- */
+ const fuerRunde = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: { "Content-Type": "text/plain",
+ "x-text": encodeURIComponent("Fuer die Runde mit mehreren Bildern.") },
+ });
+ const rId = (await fuerRunde.json())?.meldung?.id;
+ await hol("hand", `/workspace/api/support/${rId}`, {
+ method: "PATCH", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "erledigt", antwort: "Schau mal." }),
+ });
+ const zweiAn = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream", "x-geht": "nein",
+ "x-text": encodeURIComponent("Geht noch nicht, hier zwei Bilder."),
+ "x-bilder": `${PNG.length},${GIF.length}` },
+ body: Buffer.concat([PNG, GIF]),
+ });
+ ok(zweiAn.status === 200, `zwei Bilder zur Antwort (${zweiAn.status})`);
+ const standR = await (await hol("modi", "/workspace/api/support")).json();
+ const mR = (standR.meldungen || []).find((m) => m.id === rId);
+ const runde1 = (mR?.verlauf || [])[0];
+ ok((runde1?.bilder || []).length === 2,
+ `an der Runde haengen beide (${(runde1?.bilder || []).length})`);
+
+ /* ---- ZWEIMAL IN DERSELBEN RUNDE GEHT GAR NICHT ----------------
+
+ Hier stand erst eine Pruefung „nachgelegt ersetzt die Bilder" --
+ und sie wurde rot. Nachgemessen war nicht der Server kaputt,
+ sondern meine Annahme: Eine zweite Antwort in DERSELBEN Runde
+ kann es nicht geben. Die erste verlaesst den Stand „wartet", und
+ ohne den nimmt die Route nichts mehr an. Die Sperre im Server
+ ist daraufhin weggefallen (sie waere Code gewesen, der nie
+ laeuft); hier steht jetzt der Beweis, dass sie nicht fehlt. */
+ const zweitesMal = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream", "x-geht": "nein",
+ "x-text": encodeURIComponent("Und noch einmal, in derselben Runde."),
+ "x-bilder": `${WEBP.length}` },
+ body: WEBP,
+ });
+ ok(zweitesMal.status === 409,
+ `in derselben Runde ein zweites Mal antworten geht nicht (${zweitesMal.status})`);
+ const standR2 = await (await hol("modi", "/workspace/api/support")).json();
+ const runde1b = ((standR2.meldungen || []).find((m) => m.id === rId)?.verlauf || [])[0];
+ ok((runde1b?.bilder || []).length === 2,
+ ` und die zwei Bilder von vorhin stehen unveraendert da (${(runde1b?.bilder || []).length})`);
+
+ /* ---- JEDE RUNDE HAELT IHRE EIGENEN BILDER ---------------------
+
+ DAS IST DIE EIGENTLICHE GEFAHR BEI EINER TABELLE FUER ALLES:
+ dass Runde 2 die Bilder von Runde 1 mitzeigt oder umgekehrt.
+ Nach drei Runden waere dann nicht mehr zu sehen, welches Bild zu
+ welchem Versuch gehoerte -- und genau diese Frage loest einen
+ wiederkehrenden Fehler. */
+ await hol("hand", `/workspace/api/support/${rId}`, {
+ method: "PATCH", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "erledigt", antwort: "Zweiter Versuch." }),
+ });
+ const r2an = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream", "x-geht": "nein",
+ "x-text": encodeURIComponent("Runde zwei, ein anderes Bild."),
+ "x-bilder": `${WEBP.length}` },
+ body: WEBP,
+ });
+ const standR3 = await (await hol("modi", "/workspace/api/support")).json();
+ const verlauf3 = (standR3.meldungen || []).find((m) => m.id === rId)?.verlauf || [];
+ ok(r2an.status === 200 && (verlauf3[1]?.bilder || []).length === 1,
+ `Runde 2 hat ihr eigenes Bild (${(verlauf3[1]?.bilder || []).length})`);
+ ok((verlauf3[0]?.bilder || []).length === 2,
+ ` und Runde 1 behaelt ihre zwei (${(verlauf3[0]?.bilder || []).length})`);
+ /* GEGENPROBE: Es sind wirklich ANDERE Bilder und nicht dieselben
+ Zeilen zweimal gezeigt. */
+ const nummern = new Set([
+ ...(verlauf3[0]?.bilder || []).map((b) => b.id),
+ ...(verlauf3[1]?.bilder || []).map((b) => b.id),
+ ]);
+ ok(nummern.size === 3, `und alle drei sind verschiedene Bilder (${nummern.size})`);
+
+ /* ---- UND EINE ANTWORT OHNE BILD IST WEITERHIN ERLAUBT --------- */
+ await hol("hand", `/workspace/api/support/${rId}`, {
+ method: "PATCH", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "erledigt", antwort: "Dritter Versuch." }),
+ });
+ const ohneAnhang = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
+ method: "POST",
+ headers: { "x-geht": "nein", "x-text": encodeURIComponent("Nur der Text, kein Bild.") },
+ });
+ const standR4 = await (await hol("modi", "/workspace/api/support")).json();
+ const verlauf4 = (standR4.meldungen || []).find((m) => m.id === rId)?.verlauf || [];
+ ok(ohneAnhang.status === 200 && (verlauf4[2]?.bilder || []).length === 0,
+ `Runde 3 ohne Bild ist in Ordnung (${ohneAnhang.status}, `
+ + `${(verlauf4[2]?.bilder || []).length} Bilder)`);
+ ok((verlauf4[0]?.bilder || []).length === 2 && (verlauf4[1]?.bilder || []).length === 1,
+ " und loescht an den Runden davor nichts");
+
+ /* ---- UND DIE OBERFLAECHE ERFAEHRT DIE GRENZE ------------------- */
+ const liste = await (await hol("modi", "/workspace/api/support")).json();
+ ok(liste.bilder_max === 3,
+ `der Server nennt die Grenze, damit sie im Browser nicht zweimal steht (${liste.bilder_max})`);
+}
+
+/* =====================================================================
+ 5c. DER UMZUG DER ALTEN BILDER (02.10.2026)
+ =====================================================================
+
+ DIE WICHTIGSTE PRUEFUNG DIESES UMBAUS. Live liegen acht Meldungen
+ mit Bild in den alten Spalten `support_meldungen.bild_datei` und
+ dazu die Bilder an den Antwortrunden. Ab heute liest der Server
+ NUR noch `support_bilder`. Ohne den Umzug waeren diese Bilder
+ stillschweigend verschwunden -- kein Fehler, keine rote Zeile, nur
+ acht leere Karten. Genau die Sorte Schaden, die am 11.09. drei
+ Spalten gekostet hat.
+
+ SIE LAEUFT AUF EINER EIGENEN WEGWERF-DATENBANK und ruft
+ `supportTabellen()` direkt auf. Ueber den laufenden Server waere
+ es nicht zu messen: Dort ist die Datenbank frisch, es gibt gar
+ keine alten Zeilen, und die Pruefung waere gruen, ohne etwas
+ gesehen zu haben.
+ ===================================================================== */
+melde("\n=== Der Umzug der alten Bilder ===");
+{
+ const { DatabaseSync } = await import("node:sqlite");
+ const { supportTabellen } = await import("./support-tabellen.js");
+ const altDatei = join(ordner, "alt-bestand.db");
+ const a = new DatabaseSync(altDatei);
+
+ /* DER STAND VON GESTERN, von Hand nachgebaut: die Tabellen OHNE
+ `support_bilder`, mit den Bildspalten gefuellt. */
+ a.exec(`
+ CREATE TABLE support_meldungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER,
+ von_name TEXT, von_rolle TEXT, text TEXT NOT NULL, seite TEXT, geraet TEXT,
+ bild_datei TEXT, bild_typ TEXT, bild_breite INTEGER, bild_hoehe INTEGER,
+ stand TEXT NOT NULL DEFAULT 'neu', antwort TEXT, runde INTEGER NOT NULL DEFAULT 1,
+ erstellt TEXT NOT NULL, geaendert TEXT NOT NULL,
+ erledigt_am TEXT, erledigt_von INTEGER);
+ CREATE TABLE support_runden (
+ meldung_id INTEGER NOT NULL, nr INTEGER NOT NULL, antwort TEXT,
+ von_id INTEGER, von_name TEXT, am TEXT NOT NULL,
+ urteil TEXT, urteil_text TEXT, urteil_am TEXT);
+ INSERT INTO support_meldungen (text, bild_datei, bild_typ, bild_breite, bild_hoehe,
+ erstellt, geaendert)
+ VALUES ('mit Bild', 'alt-eins.png', 'image/png', 100, 240, '2026-09-01T10:00:00Z',
+ '2026-09-01T10:00:00Z');
+ INSERT INTO support_meldungen (text, erstellt, geaendert)
+ VALUES ('ohne Bild', '2026-09-02T10:00:00Z', '2026-09-02T10:00:00Z');
+ INSERT INTO support_runden (meldung_id, nr, antwort, am, urteil, urteil_am)
+ VALUES (1, 1, 'Probier es nochmal.', '2026-09-03T10:00:00Z',
+ 'nicht', '2026-09-03T11:00:00Z');
+ `);
+ /* Das Bild der RUNDE steht absichtlich noch nicht drin: Die vier
+ Spalten dafuer gibt es in einer Datenbank von gestern gar nicht.
+ Genau das muss `supportTabellen` erst nachruesten -- und DANACH
+ umziehen. Stuende der Umzug vor der Nachruestung, scheiterte er
+ hier und nur hier, naemlich auf einer alten Datenbank. */
+
+ supportTabellen(a);
+
+ a.exec(`UPDATE support_runden SET urteil_bild_datei = 'alt-runde.gif',
+ urteil_bild_typ = 'image/gif', urteil_bild_breite = 50, urteil_bild_hoehe = 60
+ WHERE meldung_id = 1 AND nr = 1`);
+ /* ZWEITER DURCHLAUF -- so wie beim naechsten Neustart des Dienstes.
+ Er muss das Rundenbild nachholen UND das Meldungsbild NICHT
+ verdoppeln. */
+ supportTabellen(a);
+
+ const alle = a.prepare("SELECT * FROM support_bilder ORDER BY id").all();
+ ok(alle.length === 2, `beide alten Bilder sind umgezogen (${alle.length})`);
+ const zurMeldung = alle.filter((b) => b.runde_nr === null);
+ ok(zurMeldung.length === 1 && zurMeldung[0].datei === "alt-eins.png"
+ && zurMeldung[0].typ === "image/png" && zurMeldung[0].breite === 100
+ && zurMeldung[0].hoehe === 240,
+ ` das Bild der Meldung mit Datei, Typ und Groesse (${zurMeldung[0]?.datei}, `
+ + `${zurMeldung[0]?.breite}x${zurMeldung[0]?.hoehe})`);
+ const zurRunde = alle.filter((b) => b.runde_nr === 1);
+ ok(zurRunde.length === 1 && zurRunde[0].datei === "alt-runde.gif",
+ ` und das Bild der Antwort an SEINER Runde (${zurRunde[0]?.datei})`);
+ ok(zurMeldung[0]?.erstellt === "2026-09-01T10:00:00Z",
+ ` mit dem Datum von damals, nicht dem von heute (${zurMeldung[0]?.erstellt})`);
+
+ /* DRITTER DURCHLAUF: Er darf nichts mehr tun. Ein Umzug, der bei
+ jedem Neustart dieselben Zeilen noch einmal anlegt, fuellt die
+ Karte nach einer Woche mit sieben gleichen Bildern. */
+ supportTabellen(a);
+ const nochmal = a.prepare("SELECT COUNT(*) AS n FROM support_bilder").get().n;
+ ok(nochmal === 2, `ein weiterer Start verdoppelt nichts (${nochmal})`);
+
+ /* GEGENPROBE: Die Meldung OHNE Bild bekommt auch keines. Ohne
+ diese Zeile koennte der Umzug alles verdoppeln und waere noch
+ immer gruen. */
+ const beiOhne = a.prepare("SELECT COUNT(*) AS n FROM support_bilder WHERE meldung_id = 2")
+ .get().n;
+ ok(beiOhne === 0, `eine Meldung ohne Bild bekommt keines (${beiOhne})`);
+
+ a.close();
+}
+
/* =====================================================================
6. GEGENPROBEN — kann diese Pruefung ueberhaupt rot werden?
===================================================================== */
@@ -680,7 +1043,7 @@ melde("\n=== Gegenproben (jede MUSS anschlagen) ===");
`ohne Anmeldung keine Liste (${ohne.status})`);
/* Und ohne Anmeldung auch nicht an ein Bild. */
- const ohneBild = await fetch(BASIS + `/workspace/api/support/${meldungMitBild}/bild`);
+ const ohneBild = await fetch(BASIS + bildWeg);
ok(ohneBild.status !== 200, `ohne Anmeldung kein Bild (${ohneBild.status})`);
}
diff --git a/server/support-tabellen.js b/server/support-tabellen.js
index 872d1a82..10de245d 100644
--- a/server/support-tabellen.js
+++ b/server/support-tabellen.js
@@ -252,8 +252,55 @@ export function supportTabellen(d) {
ON support_meldungen(stand, id DESC);
CREATE INDEX IF NOT EXISTS idx_support_person
ON support_meldungen(person_id, id DESC);
+
+ /* ==== MEHRERE BILDER JE MELDUNG (02.10.2026) ==================
+
+ VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
+ Supportbereich kann man immer nur ein Bild hinzufügen bei einer
+ Meldung. 2-3 wären besser." Und in Runde 2: „wenn man es
+ nacheinander versucht hinzuzufügen wird das Bild immer nur
+ ersetzt."
+
+ KEINE GEGEN-APOSTROPHE IN DIESEM TEXT. Er steht in einem
+ Template-Literal, und einer davon beendet es -- der Server
+ startet dann gar nicht mehr. Genau das ist in der Nacht zum
+ 25.09. schon einmal passiert, zwanzig Zeilen weiter unten
+ steht die Warnung dazu, und beim Schreiben dieses Kommentars
+ ist es trotzdem wieder passiert. Ein Kommentar, der vor einem
+ Fehler warnt, verhindert ihn nicht.
+
+ EINE TABELLE FUER ALLE SUPPORTBILDER, nicht drei Spaltensaetze
+ nebeneinander. Die Alternative waere gewesen, bild2_datei,
+ bild3_datei danebenzustellen -- und beim vierten Bild wieder.
+ Eine Zeile je Bild kennt keine Obergrenze im Schema; die Grenze
+ steht an EINER Stelle im Code (BILDER_MAX) und laesst sich dort
+ aendern, ohne die Datenbank anzufassen.
+
+ runde_nr SAGT, WOZU ES GEHOERT: NULL heisst „zur Meldung
+ selbst", eine Zahl heisst „zur Antwort in dieser Runde". Damit
+ braucht es keine zweite Tabelle fuer die Runden -- es ist
+ dieselbe Frage („welches Bild gehoert hierhin?"), und zwei
+ Tabellen waeren zwei Antworten darauf.
+
+ DIE REIHENFOLGE IST DIE DES HOCHLADENS (die id). Eine eigene
+ Spalte nr waere eine Zahl, die jemand pflegen muss, sobald ein
+ Bild dazwischen weggeht. */
+ CREATE TABLE IF NOT EXISTS support_bilder (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ meldung_id INTEGER NOT NULL
+ REFERENCES support_meldungen(id) ON DELETE CASCADE,
+ runde_nr INTEGER,
+ datei TEXT NOT NULL,
+ typ TEXT,
+ breite INTEGER,
+ hoehe INTEGER,
+ erstellt TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_support_bilder
+ ON support_bilder (meldung_id, runde_nr, id);
`);
+
/* ==== NACHTRAGEN, WAS `IF NOT EXISTS` NICHT NACHTRAEGT ============
(02.10.2026)
@@ -290,4 +337,53 @@ export function supportTabellen(d) {
console.error(`[support] Spalte '${spalte}':`, fehler?.message);
}
}
+
+ /* ==== DIE VORHANDENEN BILDER WANDERN MIT (02.10.2026) ============
+
+ Ohne diesen Schritt haette die neue Tabelle ab heute recht und
+ die acht Bilder von vorher waeren unsichtbar -- ein Umbau, der
+ Daten verliert, ohne dass etwas rot wird. Genau die Sorte, die
+ am 11.09. drei Spalten gekostet hat.
+
+ ERST HIER, NICHT WEITER OBEN. Gelesen wird `urteil_bild_datei`
+ aus `support_runden` -- und diese Spalte gibt es in einer
+ bestehenden Datenbank erst, nachdem die Nachruestung direkt
+ darueber gelaufen ist. Haette der Umzug oben gestanden, waere er
+ genau dort gescheitert, wo es darauf ankommt: live. Lokal waere
+ nichts aufgefallen, weil jede Pruefung ihre Datenbank frisch
+ anlegt und `CREATE TABLE` die Spalte dann gleich mitbringt.
+
+ ZWEIMAL AUSFUEHRBAR: Eingetragen wird nur, wo es noch keine Zeile
+ gibt. Ein zweiter Start darf nichts verdoppeln.
+
+ DIE ALTEN SPALTEN BLEIBEN STEHEN und werden ab jetzt nicht mehr
+ GESCHRIEBEN. Sie wegzunehmen hiesse, die Tabelle neu zu bauen --
+ und genau dabei gingen am 11.09. die drei Spalten verloren.
+ Gelesen wird ab jetzt nur noch `support_bilder`; die alten Felder
+ sind Geschichte, kein zweiter Weg daneben. */
+ try {
+ const n = new Date().toISOString();
+ const ausMeldung = d.prepare(`
+ INSERT INTO support_bilder (meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
+ SELECT m.id, NULL, m.bild_datei, m.bild_typ, m.bild_breite, m.bild_hoehe,
+ COALESCE(m.erstellt, ?)
+ FROM support_meldungen m
+ WHERE m.bild_datei IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM support_bilder b
+ WHERE b.meldung_id = m.id AND b.runde_nr IS NULL
+ AND b.datei = m.bild_datei)`).run(n);
+ const ausRunde = d.prepare(`
+ INSERT INTO support_bilder (meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
+ SELECT r.meldung_id, r.nr, r.urteil_bild_datei, r.urteil_bild_typ,
+ r.urteil_bild_breite, r.urteil_bild_hoehe, COALESCE(r.urteil_am, ?)
+ FROM support_runden r
+ WHERE r.urteil_bild_datei IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM support_bilder b
+ WHERE b.meldung_id = r.meldung_id AND b.runde_nr = r.nr
+ AND b.datei = r.urteil_bild_datei)`).run(n);
+ const mit = (ausMeldung.changes || 0) + (ausRunde.changes || 0);
+ if (mit) console.log(`[support] ${mit} vorhandene Bilder nach support_bilder uebernommen.`);
+ } catch (fehler) {
+ console.error("[support] BILDER NICHT UEBERNOMMEN:", fehler?.message);
+ }
}
diff --git a/server/workspace-support.js b/server/workspace-support.js
index 9f3dec8f..51eb93bd 100644
--- a/server/workspace-support.js
+++ b/server/workspace-support.js
@@ -64,6 +64,19 @@ const ANTWORT_MAX = 2000;
* Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */
const BILD_MAX = 12 * 1024 * 1024;
+/** Wie viele Bilder an EINE Meldung oder EINE Antwort dürfen.
+ *
+ * VanVan im Support, Meldung #11 — viermal gemeldet: „Hier im
+ * Supportbereich kann man immer nur ein Bild hinzufügen bei einer
+ * Meldung. 2-3 wären besser." Also drei, ihre eigene Obergrenze.
+ *
+ * DIE ZAHL STEHT AN EINER STELLE und nirgends sonst. Im Schema gibt
+ * es sie nicht (eine Zeile je Bild kennt keine Grenze), in der
+ * Oberfläche wird sie vom Server geholt. Eine zweite Zahl im Browser
+ * wäre die, die beim Erhöhen vergessen wird — genau das Muster, das
+ * am 06.09. den Umbruch bei 380 px gekostet hat. */
+const BILDER_MAX = 3;
+
/** Wie viele offene Meldungen eine Person gleichzeitig haben darf.
*
* Zehn, und damit deutlich großzügiger als die drei im vertraulichen
@@ -100,6 +113,160 @@ function textSauber(s, max) {
.slice(0, max);
}
+/* =====================================================================
+ MEHRERE BILDER — EIN RUMPF, DIE LÄNGEN IM KOPF (02.10.2026)
+ =====================================================================
+
+ VanVan, Meldung #11: „2-3 wären besser." Und in der zweiten Runde
+ der Grund, warum der erste Versuch nicht reichte: „wenn man es
+ nacheinander versucht hinzuzufügen wird das Bild immer nur
+ ersetzt."
+
+ WIE DREI BILDER IN EINE ANFRAGE KOMMEN. Bisher war der Rumpf EIN
+ Bild — roh, ohne Verpackung, und `dateiErkennen` sah an den ersten
+ Bytes, was es ist. Für mehrere braucht es eine Grenze dazwischen.
+ Drei Wege standen zur Wahl:
+
+ 1. `multipart/form-data`. Der übliche Weg — und er braucht einen
+ Zerleger, den dieses Haus nicht hat. Ein Paket mehr, um eine
+ Zahl zu übertragen, die auch in einen Kopf passt.
+ 2. Drei Anfragen nacheinander. Das ist genau der Zustand, gegen
+ den die Kommentare bei `POST /support` schon argumentieren:
+ eine Meldung, die es gibt, während Bild zwei noch lädt — und
+ bei Abbruch für immer unvollständig.
+ 3. DIE LÄNGEN IM KOPF, die Bilder hintereinander im Rumpf.
+
+ Es ist 3 geworden: `x-bilder: 20481,15320` sagt, wo zu schneiden
+ ist, und der Rumpf ist die Aneinanderreihung. Dieselbe Linie wie
+ bisher („Text im Kopf, Bild im Rumpf"), nur eine Angabe weiter.
+
+ EINE FALSCHE LÄNGE KANN NICHTS KAPUTTMACHEN. Die Summe muss auf
+ das Byte stimmen, sonst 400 — und jedes Stück wird danach einzeln
+ von `dateiErkennen` an seinen ersten Bytes gemessen. Wer falsch
+ schneidet, bekommt kein verfälschtes Bild, sondern eine Absage.
+
+ OHNE DEN KOPF IST ES DER ALTE WEG — ein Bild, der ganze Rumpf.
+ Das ist keine zweite Tür, sondern derselbe Satz mit einer Länge:
+ `[rumpf.length]`. Dadurch funktioniert eine Seite, die noch im
+ Zwischenspeicher des Browsers liegt, unverändert weiter. */
+
+/** Den Rumpf in einzelne Bilder zerlegen.
+ *
+ * Gibt `{ teile }` oder `{ fehler: { code, text } }` zurück — nie
+ * beides und nie `null` für „ging schief". Der Aufrufer soll den
+ * Fehler weiterreichen können, ohne ihn selbst zu formulieren;
+ * sonst steht derselbe Satz an zwei Routen und altert einmal. */
+function rumpfZerlegen(req) {
+ const rumpf = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ if (!rumpf.length) return { teile: [] };
+
+ const kopf = String(req.get("x-bilder") || "").trim();
+ let laengen;
+ if (!kopf) {
+ laengen = [rumpf.length];
+ } else {
+ laengen = kopf.split(",").map((s) => Number(s.trim()));
+ if (laengen.some((n) => !Number.isInteger(n) || n <= 0)) {
+ return { fehler: { code: 400, text: "Die Bildlängen sind unbrauchbar." } };
+ }
+ }
+ if (laengen.length > BILDER_MAX) {
+ return {
+ fehler: {
+ code: 400,
+ text: `Mehr als ${BILDER_MAX} Bilder gehen nicht – nimm die wichtigsten.`,
+ },
+ };
+ }
+ if (laengen.some((n) => n > BILD_MAX)) {
+ return { fehler: { code: 413, text: "Ein Bild ist größer als 12 MB." } };
+ }
+ const summe = laengen.reduce((a, b) => a + b, 0);
+ /* AUF DAS BYTE. Ein „ungefähr" hieße, dass das letzte Bild
+ abgeschnitten ankommt und trotzdem gespeichert wird. */
+ if (summe !== rumpf.length) {
+ return { fehler: { code: 400, text: "Die Bilder sind unterwegs beschädigt worden." } };
+ }
+
+ const teile = [];
+ let ab = 0;
+ for (const n of laengen) { teile.push(rumpf.subarray(ab, ab + n)); ab += n; }
+ return { teile };
+}
+
+/** Die Stücke prüfen und auf die Platte legen.
+ *
+ * Gibt `{ bilder }` oder `{ fehler }`. Was schon geschrieben wurde,
+ * räumt sie bei einem späteren Fehlschlag SELBST weg — sonst
+ * sammelt der Ordner Dateien an, auf die keine Zeile zeigt und die
+ * deshalb niemand je wieder zuordnen kann. */
+function bilderAblegen(teile) {
+ const bilder = [];
+ try {
+ for (const stueck of teile) {
+ const erkannt = dateiErkennen(stueck);
+ /* NUR BILDER. `dateiErkennen` kennt auch PDF und Ton — hier geht
+ es um Bildschirmfotos, und alles andere wäre eine Tür, nach
+ der niemand gefragt hat. */
+ if (!erkannt || erkannt.art !== "bild") {
+ for (const b of bilder) {
+ try { unlinkSync(join(BILD_ORDNER, b.datei)); } catch { /* egal */ }
+ }
+ return {
+ fehler: {
+ code: 415,
+ text: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
+ },
+ };
+ }
+ mkdirSync(BILD_ORDNER, { recursive: true });
+ const datei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
+ writeFileSync(join(BILD_ORDNER, datei), stueck);
+ bilder.push({
+ datei, typ: erkannt.typ,
+ breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
+ });
+ }
+ } catch (fehler) {
+ for (const b of bilder) {
+ try { unlinkSync(join(BILD_ORDNER, b.datei)); } catch { /* egal */ }
+ }
+ throw fehler;
+ }
+ return { bilder };
+}
+
+/** Die Zeilen schreiben — zur Meldung (`rundeNr === null`) oder zu
+ * einer Antwort. EINE Tabelle für beides, siehe support-tabellen.js. */
+function bilderEintragen(meldungId, rundeNr, bilder, wann) {
+ const satz = db().prepare(`INSERT INTO support_bilder
+ (meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
+ VALUES (?,?,?,?,?,?,?)`);
+ for (const b of bilder) {
+ satz.run(meldungId, rundeNr, b.datei, b.typ, b.breite, b.hoehe, wann);
+ }
+}
+
+/** Die Bilder einer Meldung oder einer Runde, in der Reihenfolge des
+ * Hochladens.
+ *
+ * DER WEG, NICHT DER DATEINAME. Was auf der Platte liegt, geht
+ * niemanden etwas an, der nur ein Bild anzeigen will — dieselbe
+ * Entscheidung wie beim Chat-Anhang. */
+function bilderVon(meldungId, rundeNr) {
+ const zeilen = rundeNr === null
+ ? db().prepare(`SELECT id, breite, hoehe FROM support_bilder
+ WHERE meldung_id = ? AND runde_nr IS NULL ORDER BY id`).all(meldungId)
+ : db().prepare(`SELECT id, breite, hoehe FROM support_bilder
+ WHERE meldung_id = ? AND runde_nr = ? ORDER BY id`).all(meldungId, rundeNr);
+ return zeilen.map((z) => ({
+ id: z.id,
+ weg: `/workspace/api/support/${meldungId}/bild/${z.id}`,
+ breite: z.breite || 0,
+ hoehe: z.hoehe || 0,
+ }));
+}
+
/** Darf diese Person diese Meldung sehen?
*
* Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe
@@ -153,13 +320,15 @@ function nachDraussen(m, fuerLeitung, werFragtId) {
geaendert: m.geaendert,
erledigt_am: m.erledigt_am || null,
seite: m.seite || null,
- /* Das Bild als fertige ADRESSE, nicht als Dateiname — sonst setzt
- jede Stelle im Browser denselben Pfad zusammen, und eine davon
- setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim
- Chat-Anhang. */
- bild: m.bild_datei ? `/workspace/api/support/${m.id}/bild` : null,
- bild_breite: m.bild_breite || 0,
- bild_hoehe: m.bild_hoehe || 0,
+ /* ==== EINE LISTE, AUCH WENN NUR EINES DRAN HÄNGT (02.10.2026) ==
+
+ Hier standen `bild`, `bild_breite`, `bild_hoehe` — drei Felder
+ für genau ein Bild. Daneben eine Liste zu stellen hieße: zwei
+ Auskünfte auf dieselbe Frage, und die zweite wird irgendwann
+ nicht mehr mitgepflegt. Deshalb nur noch `bilder` — leer,
+ eines oder bis zu drei. Die Oberfläche schleift darüber und
+ muss den Sonderfall „genau eines" nicht kennen. */
+ bilder: bilderVon(m.id, null),
von: m.von_name || null,
von_rolle: m.von_rolle || null,
/* Nur die Leitung braucht zu wissen, mit welchem Gerät gemeldet
@@ -191,18 +360,16 @@ function nachDraussen(m, fuerLeitung, werFragtId) {
/** Der Verlauf einer Meldung -- eine Zeile je Runde. */
function verlaufVon(id) {
- return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am,
- urteil_bild_datei
+ return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am
FROM support_runden WHERE meldung_id = ? ORDER BY nr`).all(id)
.map((r) => ({
nr: r.nr, antwort: r.antwort, von: r.von_name || null, am: r.am,
urteil: r.urteil || null, urteil_text: r.urteil_text || null,
urteil_am: r.urteil_am || null,
- /* DER WEG, NICHT DER DATEINAME. Wie beim Bild der Meldung eine
- Zeile weiter oben: Was auf der Platte liegt, geht niemanden
- etwas an, der nur ein Bild anzeigen will. */
- urteil_bild: r.urteil_bild_datei
- ? `/workspace/api/support/${id}/runde/${r.nr}/bild` : null,
+ /* AUCH HIER EINE LISTE und dieselbe Form wie bei der Meldung —
+ die Oberfläche hat dadurch EINE Funktion für beide Stellen
+ statt zweier, die auseinanderlaufen. */
+ bilder: bilderVon(id, r.nr),
}));
}
@@ -241,6 +408,11 @@ supportRouter.get("/workspace/api/support", (req, res) => {
: db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n,
hoechstens_bytes: BILD_MAX,
+ /* WIE VIELE BILDER GEHEN — vom Server gesagt, nicht im Browser
+ gewusst. Dieselbe Überlegung wie bei `hoechstens_bytes` eine
+ Zeile darüber: Eine zweite Zahl in der Oberfläche wäre die,
+ die beim Ändern vergessen wird. */
+ bilder_max: BILDER_MAX,
text_max: TEXT_MAX,
});
} catch (fehler) {
@@ -249,20 +421,45 @@ supportRouter.get("/workspace/api/support", (req, res) => {
}
});
-/** Das Bild einer Meldung.
+/** EIN Bild — egal ob es zur Meldung oder zu einer Antwort gehört.
+ *
+ * ==== EINE ROUTE STATT DREI (02.10.2026) ==========================
+ *
+ * Hier standen zwei: `/:id/bild` für das Bild der Meldung und
+ * `/:id/runde/:nr/bild` für das der Antwort. Mit bis zu drei Bildern
+ * je Stelle hätte jede eine laufende Nummer dazubekommen — zwei
+ * Routen, die dasselbe tun, und eine dritte Zählung, die zu nichts
+ * gehört außer zur Reihenfolge in der Anzeige.
+ *
+ * Jetzt hat jedes Bild eine eigene Nummer (die Zeile in
+ * `support_bilder`), und die Adresse nennt sie. Wohin es gehört,
+ * steht in der Zeile — der Weg muss es nicht noch einmal sagen.
+ *
+ * DIE MELDUNGSNUMMER BLEIBT TROTZDEM IM PFAD, obwohl die Bildnummer
+ * allein eindeutig wäre. Sie ist die SICHTBARKEITSFRAGE: Wer die
+ * Meldung sehen darf, sieht ihre Bilder. Stünde sie nicht im Pfad,
+ * müsste die Route sie aus der Bildzeile nachschlagen — dasselbe
+ * Ergebnis, aber ein Blick in den Quelltext mehr, um es zu wissen.
+ * Beides muss zusammenpassen, sonst 404.
*
* Dieselben Kopfzeilen wie beim Chat-Anhang: der Typ aus der
* ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie,
- * die dem Dokument jede Ausführung nimmt. */
-supportRouter.get("/workspace/api/support/:id(\\d+)/bild", (req, res) => {
+ * die dem Dokument jede Ausführung nimmt.
+ *
+ * 404 UND NICHT 403, wie überall im Haus: Wer eine fremde Meldung
+ * nicht sehen darf, soll auch nicht erfahren, dass es sie gibt. */
+supportRouter.get("/workspace/api/support/:id(\\d+)/bild/:bid(\\d+)", (req, res) => {
try {
const m = meldungFuer(req.person, req.params.id);
- if (!m || !m.bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" });
- const pfad = join(BILD_ORDNER, m.bild_datei);
+ if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const b = db().prepare("SELECT datei, typ FROM support_bilder WHERE id = ? AND meldung_id = ?")
+ .get(Number(req.params.bid), m.id);
+ if (!b) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const pfad = join(BILD_ORDNER, b.datei);
try { statSync(pfad); } catch {
return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." });
}
- res.setHeader("Content-Type", m.bild_typ || "image/png");
+ res.setHeader("Content-Type", b.typ || "image/png");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Cache-Control", "private, max-age=86400");
@@ -308,9 +505,16 @@ function darfMelden(req, res, next) {
* Ein leerer Rumpf ist zulässig: Dann gibt es eben kein Bild. */
supportRouter.post("/workspace/api/support",
darfMelden,
- express.raw({ type: "*/*", limit: BILD_MAX }),
+ /* DREIMAL DIE EINZELGRENZE. Das Paket darf so groß sein wie drei
+ erlaubte Bilder — mehr nicht. Hier `BILD_MAX` stehen zu lassen
+ hieße: Wer zwei Bildschirmfotos schickt, bekommt eine Absage von
+ express, bevor irgendeine Zeile dieses Hauses davon erfährt, und
+ sie lautete „request entity too large" statt eines Satzes, mit
+ dem jemand etwas anfangen kann. Die Grenze JE BILD prüft
+ `rumpfZerlegen` weiterhin selbst. */
+ express.raw({ type: "*/*", limit: BILD_MAX * BILDER_MAX }),
(req, res) => {
- let geschrieben = null;
+ let geschrieben = [];
try {
let text = "";
try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; }
@@ -321,38 +525,33 @@ supportRouter.post("/workspace/api/support",
});
}
- let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0;
- if (Buffer.isBuffer(req.body) && req.body.length) {
- const erkannt = dateiErkennen(req.body);
- /* NUR BILDER. dateiErkennen kennt auch PDF und Ton — hier geht
- es um Bildschirmfotos, und alles andere wäre eine Tür, die
- niemand bestellt hat. */
- if (!erkannt || erkannt.art !== "bild") {
- return res.status(415).json({
- fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
- });
- }
- mkdirSync(BILD_ORDNER, { recursive: true });
- bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
- writeFileSync(join(BILD_ORDNER, bildDatei), req.body);
- geschrieben = join(BILD_ORDNER, bildDatei);
- bildTyp = erkannt.typ;
- bildBreite = erkannt.breite || 0;
- bildHoehe = erkannt.hoehe || 0;
+ const zerlegt = rumpfZerlegen(req);
+ if (zerlegt.fehler) {
+ return res.status(zerlegt.fehler.code).json({ fehler: zerlegt.fehler.text });
}
+ const abgelegt = bilderAblegen(zerlegt.teile);
+ if (abgelegt.fehler) {
+ return res.status(abgelegt.fehler.code).json({ fehler: abgelegt.fehler.text });
+ }
+ geschrieben = abgelegt.bilder.map((b) => join(BILD_ORDNER, b.datei));
const n = jetzt();
+ /* DIE ALTEN BILDSPALTEN WERDEN NICHT MEHR GEFÜLLT (02.10.2026).
+ Sie stehen noch in der Tabelle, weil sie wegzunehmen hieße,
+ sie neu zu bauen — und genau dabei gingen am 11.09. drei
+ Spalten verloren. Ihr Inhalt ist beim Start nach
+ `support_bilder` gewandert; von dort wird ab jetzt gelesen,
+ und nur von dort. */
const info = db().prepare(`INSERT INTO support_meldungen
(person_id, von_name, von_rolle, text, seite, geraet,
- bild_datei, bild_typ, bild_breite, bild_hoehe,
stand, erstellt, geaendert)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
+ VALUES (?,?,?,?,?,?,?,?,?)`)
.run(req.person.id, req.person.name || null, req.person.rolle || null,
text, sauber(req.get("x-seite"), SEITE_MAX) || null,
sauber(req.get("user-agent"), GERAET_MAX) || null,
- bildDatei, bildTyp, bildBreite, bildHoehe,
SUPPORT_STAND.neu, n, n);
const id = Number(info.lastInsertRowid);
+ bilderEintragen(id, null, abgelegt.bilder, n);
protokolliere("support_gemeldet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
@@ -380,10 +579,13 @@ supportRouter.post("/workspace/api/support",
meldung: nachDraussen(frisch, istLeitung(req.person), req.person.id),
});
} catch (fehler) {
- /* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst
- sammelt der Ordner Bilder an, zu denen es keine Meldung gibt —
- und niemand merkt es, weil nichts darauf zeigt. */
- if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } }
+ /* DIE DATEIEN GEHEN MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM.
+ Sonst sammelt der Ordner Bilder an, zu denen es keine Meldung
+ gibt — und niemand merkt es, weil nichts darauf zeigt.
+ ALLE DREI, nicht nur das letzte: Hier stand eine einzelne
+ Variable, und mit mehreren Bildern wäre genau das die stille
+ Lücke geworden. */
+ for (const p of geschrieben) { try { unlinkSync(p); } catch { /* egal */ } }
console.error("[support] Melden:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
@@ -449,8 +651,8 @@ supportRouter.post("/workspace/api/support",
EIN LEERER RUMPF IST ZULÄSSIG. Wer kein Bildschirmfoto hat, soll
trotzdem antworten können; ein Bild ist Hilfe, keine Hürde. */
supportRouter.post("/workspace/api/support/:id/rueckmeldung",
- express.raw({ type: "*/*", limit: BILD_MAX }), (req, res) => {
- let geschrieben = null;
+ express.raw({ type: "*/*", limit: BILD_MAX * BILDER_MAX }), (req, res) => {
+ let geschrieben = [];
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
@@ -482,39 +684,51 @@ supportRouter.post("/workspace/api/support/:id/rueckmeldung",
});
}
- /* DAS BILD -- dieselbe Erkennung und dieselbe Absage wie beim
- Melden. `dateiErkennen` kennt auch PDF und Ton; hier geht es
- um Bildschirmfotos, und alles andere wäre eine Tür, nach der
- niemand gefragt hat. */
- let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0;
- if (Buffer.isBuffer(req.body) && req.body.length) {
- const erkannt = dateiErkennen(req.body);
- if (!erkannt || erkannt.art !== "bild") {
- return res.status(415).json({
- fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
- });
- }
- mkdirSync(BILD_ORDNER, { recursive: true });
- bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
- writeFileSync(join(BILD_ORDNER, bildDatei), req.body);
- geschrieben = join(BILD_ORDNER, bildDatei);
- bildTyp = erkannt.typ;
- bildBreite = erkannt.breite || 0;
- bildHoehe = erkannt.hoehe || 0;
+ /* DIE BILDER -- derselbe Weg und dieselbe Absage wie beim
+ Melden, und zwar wörtlich dieselben zwei Funktionen. Eine
+ eigene Fassung hier wäre die, die beim nächsten Dateityp
+ vergessen wird. */
+ const zerlegt = rumpfZerlegen(req);
+ if (zerlegt.fehler) {
+ return res.status(zerlegt.fehler.code).json({ fehler: zerlegt.fehler.text });
}
+ const abgelegt = bilderAblegen(zerlegt.teile);
+ if (abgelegt.fehler) {
+ return res.status(abgelegt.fehler.code).json({ fehler: abgelegt.fehler.text });
+ }
+ geschrieben = abgelegt.bilder.map((b) => join(BILD_ORDNER, b.datei));
const n = jetzt();
const runde = m.runde || 1;
db().prepare(`UPDATE support_runden
- SET urteil = ?, urteil_text = ?, urteil_am = ?,
- urteil_bild_datei = COALESCE(?, urteil_bild_datei),
- urteil_bild_typ = COALESCE(?, urteil_bild_typ),
- urteil_bild_breite = COALESCE(?, urteil_bild_breite),
- urteil_bild_hoehe = COALESCE(?, urteil_bild_hoehe)
+ SET urteil = ?, urteil_text = ?, urteil_am = ?
WHERE meldung_id = ? AND nr = ?`)
- .run(geht ? "geht" : "nicht", wort || null, n,
- bildDatei, bildTyp, bildDatei ? bildBreite : null,
- bildDatei ? bildHoehe : null, m.id, runde);
+ .run(geht ? "geht" : "nicht", wort || null, n, m.id, runde);
+
+ /* ==== EINFACH EINTRAGEN -- OHNE „ERSETZEN" (02.10.2026) ======
+
+ Hier stand zuerst: alte Bilder dieser Runde löschen, dann die
+ neuen eintragen. Begründung war „wer nachlegt, ersetzt seine
+ Antwort, die Bilder müssen mitziehen". Das klang richtig und
+ WAR ES NICHT -- die Prüfung dazu ist rot geworden, und zwar
+ zu Recht: Der Fall gibt es nicht.
+
+ NACHGEMESSEN: Eine zweite Antwort IN DERSELBEN RUNDE ist
+ unmöglich. `darfBestaetigen` verlangt den Stand „wartet", und
+ diese Antwort hier verlässt ihn in jedem Fall -- bei „geht"
+ nach „erledigt", bei „noch nicht" nach „in Arbeit" UND eine
+ Runde weiter. Der Melder bekommt beim zweiten Versuch 409;
+ genau das prüft `pruef-support` an zwei Stellen.
+
+ Also wäre das Löschen Code gewesen, der nie läuft -- und
+ damit Code, den niemand je prüfen kann. Das ist dieselbe
+ Sorte wie die Prüfung, die immer bestätigt: Sie sieht nach
+ Sorgfalt aus und trägt nichts.
+
+ DIE GRENZE VON DREI STEHT DAMIT AN EINER STELLE: in
+ `rumpfZerlegen`, je Anfrage -- und eine Anfrage je Runde ist
+ alles, was es gibt. */
+ bilderEintragen(m.id, runde, abgelegt.bilder, n);
if (geht) {
db().prepare(`UPDATE support_meldungen
@@ -556,49 +770,23 @@ supportRouter.post("/workspace/api/support/:id/rueckmeldung",
const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id);
res.json({ meldung: nachDraussen(frisch, false, req.person.id) });
} catch (fehler) {
- /* DIE DATEI WIEDER WEG, wenn das Schreiben danach scheitert --
+ /* DIE DATEIEN WIEDER WEG, wenn das Schreiben danach scheitert --
derselbe Handgriff wie beim Melden. Ohne ihn sammelt der
Bildordner Dateien an, zu denen es keine Zeile gibt, und
niemand kann sie je zuordnen. */
- if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } }
+ for (const p of geschrieben) { try { unlinkSync(p); } catch { /* egal */ } }
console.error("[support] Rueckmeldung:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
-/* Das Bild zu EINER Runde.
+/* HIER STAND `/:id/runde/:nr/bild` (02.10.2026 entfernt).
*
- * EIGENE ROUTE UND NICHT `/:id/bild` MIT ZUSATZ: Das Bild der
- * Meldung und das einer Antwort sind zwei verschiedene Dinge mit
- * verschiedenen Nummern. Eine Route, die mal das eine und mal das
- * andere liefert, braucht beim Lesen jedes Mal einen Blick in den
- * Quelltext.
- *
- * SICHTBARKEIT: dieselbe Regel wie beim Verlauf -- wer die Meldung
- * sehen darf, sieht auch ihre Bilder. 404 und nicht 403, damit
- * niemand an der Antwort ablesen kann, dass es die Meldung gibt. */
-supportRouter.get("/workspace/api/support/:id(\\d+)/runde/:nr(\\d+)/bild", (req, res) => {
- try {
- const m = meldungFuer(req.person, req.params.id);
- if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
- const r = db().prepare(`SELECT urteil_bild_datei, urteil_bild_typ
- FROM support_runden WHERE meldung_id = ? AND nr = ?`)
- .get(m.id, Number(req.params.nr));
- if (!r || !r.urteil_bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" });
- const pfad = join(BILD_ORDNER, r.urteil_bild_datei);
- try { statSync(pfad); } catch {
- return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." });
- }
- res.setHeader("Content-Type", r.urteil_bild_typ || "image/png");
- res.setHeader("X-Content-Type-Options", "nosniff");
- res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
- res.setHeader("Cache-Control", "private, max-age=86400");
- liefereDatei(req, res, pfad);
- } catch (fehler) {
- console.error("[support] Rundenbild:", fehler?.message);
- res.status(503).json({ fehler: "nicht_verfuegbar" });
- }
-});
+ * Sie lieferte das EINE Bild einer Antwort und hätte für drei eine
+ * laufende Nummer dazubekommen müssen. Statt zwei fast gleicher
+ * Routen mit je einer Zählung gibt es jetzt eine einzige weiter
+ * oben: `/:id/bild/:bid`. Wohin ein Bild gehört, steht in seiner
+ * Zeile — der Weg muss es nicht wiederholen. */
supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => {
try {
@@ -743,13 +931,27 @@ supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, re
export function supportAufraeumen() {
try {
const grenze = new Date(Date.now() - AUFBEWAHRUNG_TAGE * 86400000).toISOString();
- const alt = db().prepare(`SELECT id, bild_datei FROM support_meldungen
+ const alt = db().prepare(`SELECT id FROM support_meldungen
WHERE stand = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am < ?`).all(grenze);
if (!alt.length) return 0;
+ const dazu = db().prepare("SELECT datei FROM support_bilder WHERE meldung_id = ?");
for (const m of alt) {
- if (m.bild_datei) {
- try { unlinkSync(join(BILD_ORDNER, m.bild_datei)); } catch { /* schon weg */ }
+ /* ALLE BILDER DER MELDUNG, auch die aus den Antwortrunden
+ (02.10.2026). Hier stand `m.bild_datei` — das EINE Bild der
+ Meldung. Die Bilder zu den Antworten blieben damit auf der
+ Platte liegen, ohne Zeile, die auf sie zeigt; aufgefallen ist
+ es beim Umbau auf mehrere Bilder, nicht im Betrieb. Dass die
+ Liste jetzt aus einer Abfrage kommt statt aus einer Spalte,
+ ist der Grund, warum sie beim nächsten Ort nicht wieder
+ unvollständig sein kann. */
+ for (const b of dazu.all(m.id)) {
+ try { unlinkSync(join(BILD_ORDNER, b.datei)); } catch { /* schon weg */ }
}
+ /* Die Zeilen in `support_bilder` gehen über ON DELETE CASCADE
+ mit — vorausgesetzt, die Fremdschlüssel sind eingeschaltet.
+ Deshalb hier zusätzlich von Hand: Eine Aufräumfunktion, die
+ von einer Einstellung abhängt, räumt irgendwann nicht auf. */
+ db().prepare("DELETE FROM support_bilder WHERE meldung_id = ?").run(m.id);
db().prepare("DELETE FROM support_meldungen WHERE id = ?").run(m.id);
}
console.log(`[support] ${alt.length} erledigte Meldungen aelter als `
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index b6c99030..2398b926 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`. -->
-
-
+
+
-
+