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