/* CHAT: ANHÄNGE UND WEGRÄUMEN (09.09.2026) Filipe: "man muss die chats auch gelöscht bekommen!!! am besten wäre es auch wenn man da auch im chat pdfs schicken könnte. pdfs und fotos." Zwei Dinge, eine Prüfung, weil sie sich an einer Stelle berühren: Wer ein Gespräch wegräumt, darf auch an dessen Anhänge nicht mehr herankommen. Getrennt geprüft würde genau diese Naht niemandem auffallen. --------------------------------------------------------------------- DIE WICHTIGSTEN PRÜFUNGEN STEHEN IN ABSCHNITT 3 Ein Upload ist die gefährlichste Stelle einer Website. Der Dateiname kommt vom Absender, der Content-Type kommt vom Absender -- wer sich darauf verlässt, liefert irgendwann eine HTML-Datei aus, die im Browser des Empfängers als Seite DIESER Domain läuft, mit Zugriff auf seine Sitzung. Deshalb wird hier ausdrücklich versucht, genau das zu tun: HTML als bild.png, SVG als foto.png, eine Datei ohne erkennbaren Inhalt. Alle drei MÜSSEN abgewiesen werden. Und danach: Was ausgeliefert wird, muss den Typ tragen, der beim Hochladen ERKANNT wurde -- nicht den, der mitgeschickt wurde. DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */ import { mkdtempSync, rmSync, appendFileSync, existsSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-chatanhang-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; import { bestaetige } from "./helfer-nachfrage.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Chat-Anhangpruefung"); /* ---- DIE BLENDE ABSCHALTEN, NICHT AUF SIE WARTEN (01.10.2026) ---- Die Begleiter und die Schriftzuege der Bildschau blenden sich mit `transition: opacity .32s` ein. Die Behauptungen weiter unten filtern auf „Deckung ueber 0,5" -- und meldeten unter Last „0 Figuren" an einer Seite, die in Ordnung ist. GEMESSEN, STATT GERATEN: lage „hoch", Figuren da, Bilder geladen, Deckung exakt „0". Nicht 0,3 oder 0,7 -- die Ueberblendung hatte nicht ANGEFANGEN. Mit vier parallelen Browsern wird `requestAnimationFrame` gedrosselt, und eine Blende, die auf Bildern laeuft, steht still. Davor standen feste Wartezeiten (600 und 700 ms, „reichlich ueber den 320"). Mein erster Versuch, „warten bis sich nichts mehr aendert", war genauso falsch: Stillstand heisst auch „noch nicht angefangen", und er kam sofort zurueck. Auf eine Bewegung zu warten, die gar nicht laeuft, geht nicht. Also wird sie abgeschaltet -- das Haus hat die Regel schon: @media (prefers-reduced-motion: reduce) { .bildschau__figur, .bildschau__wort { transition: none; } } Der Endwert steht damit sofort da, die Messung haengt nicht mehr am Rechner, und dieser Weg wird nebenbei zum ersten Mal wirklich begangen. Dieselbe Einstellung benutzt diese Datei weiter unten ohnehin. SIE BLEIBT DANACH AN: Nach diesem Abschnitt behauptet nichts mehr etwas ueber Bewegung -- nachgesehen, nicht angenommen. */ async function blendeSteht(seite) { await seite.emulateMedia({ reducedMotion: "reduce" }); await seite.waitForFunction( () => !!document.querySelector("dialog.bildschau[open]"), null, { timeout: 8000 }, ).catch(() => { /* die Behauptung darunter sagt dann, was da war */ }); /* Ein Bild Zeit, damit die neue Regel auch angewandt ist. `raf` ist unter Last gedrosselt, deshalb zusaetzlich eine kurze Frist -- hier geht es nur noch um das Anwenden einer Regel, nicht um eine Bewegung. */ await seite.evaluate(() => new Promise((r) => requestAnimationFrame(() => r()))); await seite.waitForTimeout(120); } process.env.PORT = `${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 }); }; await import("./index.js"); notbremse(600_000, "pruef-chat-anhaenge"); await new Promise((r) => setTimeout(r, 1000)); const BASIS = `http://127.0.0.1:${PORT}`; const ANHANG_ORDNER = join(ordner, "chat-anhaenge"); /* Der Datenordner dieses Laufs. Gebraucht wird er, um nachzusehen, WO eine Datei liegt: in der GIF-Kiste oder bei den Anhaengen. Genau dieser Unterschied ist der Kern des GIF-Abschnitts -- ein verschicktes GIF ist eine KOPIE, kein Verweis. */ const DATEN_ORDNER_P = ordner; const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-lauf.txt"); try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } }; let fehler = 0, geprueft = 0; /* DER DRITTE AUSGANG BRAUCHT EINEN ZAEHLER (03.10.2026). „Konnte nicht nachsehen" ist weder in Ordnung noch kaputt -- ohne eigene Zahl verschwaende es zwischen den gruenen Zeilen, und am Ende stuende „alles in Ordnung" ueber etwas, das niemand gemessen hat. */ let nichtNachsehbar = 0; const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* ---- Bestand ------------------------------------------------------------ */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetztT = new Date().toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetztT); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetztT); d.close(); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); /* EIN ECHTES MIKROFON, NUR ERFUNDEN (23.09.2026). Fuer Sprachnachrichten braucht diese Pruefung eine Tonquelle. Chromium bringt eine mit: `--use-fake-device-for-media-stream` liefert einen gleichmaessigen Ton, `--use-fake-ui-for-media-stream` beantwortet die Nachfrage nach der Erlaubnis mit Ja. WARUM NICHT EINE SELBST GEBAUTE BYTEFOLGE: Aus demselben Grund, der zwanzig Zeilen weiter unten beim PNG steht. Eine gebastelte WebM-Kopfzeile wuerde beweisen, dass meine Erkennung meine Bastelei versteht. Was hier hochgeht, hat dagegen MediaRecorder erzeugt -- dasselbe Stueck Programm, das auf Filipes Handy laeuft. */ const browser = await pw.chromium.launch({ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"], }); async function alsRolle(rolle, code, breite = 1280) { const ctx = await browser.newContext({ viewport: { width: breite, height: 900 }, hasTouch: breite <= 860 }); const seite = await ctx.newPage(); const konsole = []; const rauschen = []; /* WARUM ZWEI TÖPFE. Diese Prüfung provoziert absichtlich Absagen: fünf abgewiesene Uploads, ein 413, drei 404 auf gesperrte Anhänge. Jede davon schreibt der Browser als "Failed to load resource" in die Konsole -- und jede davon ist der BEWEIS, dass die Abwehr gegriffen hat. Erster Anlauf war, sie nach Statusnummer herauszufiltern (415, 413, 404). Das ist bequem und falsch: Ein fehlendes Bild in der Oberfläche wäre ab da dauerhaft unsichtbar, weil es dieselbe Nummer trägt. Gefiltert wird deshalb nach ZEITPUNKT -- was während eines absichtlichen Versuchs entsteht, kommt in den zweiten Topf. Alles ausserhalb bleibt ein Befund, egal welche Nummer es hat. */ const zustand = { stumm: false }; seite.on("pageerror", (f) => konsole.push("Absturz auf " + seite.url() + ": " + f.message + " || " + String(f.stack || "").split(/\r?\n/).slice(1, 3).join(" / "))); seite.on("console", (m) => { if (m.type() !== "error") return; (zustand.stumm ? rauschen : konsole).push("Konsole: " + m.text()); }); await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 25000 }); await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); return { ctx, seite, konsole, rauschen, zustand }; } /** Alles, was hier drin an Konsolenfehlern entsteht, ist gewollt. * Der kurze Nachlauf ist nötig, weil Konsolenmeldungen nicht im selben * Atemzug ankommen wie die Antwort. */ async function absichtlich(leute, tun) { for (const l of leute) l.zustand.stumm = true; try { return await tun(); } finally { await new Promise((r) => setTimeout(r, 400)); for (const l of leute) l.zustand.stumm = false; } } const luna = await alsRolle("creator", "CODE-CREA-0001"); const pat = await alsRolle("scout", "CODE-SCOU-0001"); const dogi = await alsRolle("admin", "CODE-DOGI-0001"); async function direkt(s, mit) { return s.evaluate(async (m) => { const a = await fetch("/workspace/api/chat/direkt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mit: m }) }); return (await a.json()).raum_id; }, mit); } const raumId = await direkt(luna.seite, idPat); /* Luna <-> Patrick */ const raumDogi = await direkt(luna.seite, idDogi); /* Luna <-> DogFather */ async function schicke(s, raum, text) { return s.evaluate(async ([r, t]) => { const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }) }); return a.ok; }, [raum, text]); } async function oeffne(s, id) { await s.goto(`${BASIS}/workspace/chat.html?raum=${id}`, { waitUntil: "networkidle" }); await s.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 }); await s.waitForTimeout(500); } /* Ein ECHTES PNG mit bekannten Maßen -- gezeichnet im Browser, nicht von Hand zusammengebaut. Eine selbst gebastelte Bytefolge würde beweisen, dass meine Erkennung meine Bastelei versteht; ein echtes PNG beweist, dass sie PNG versteht. */ async function hochladen(s, raum, { inhalt, typ, name, text = "" }) { return s.evaluate(async ([r, i, t, nm, tx]) => { let koerper; if (i.art === "png" || i.art === "jpeg") { const c = document.createElement("canvas"); c.width = i.breite; c.height = i.hoehe; const g = c.getContext("2d"); g.fillStyle = "#2b6cb0"; g.fillRect(0, 0, c.width, c.height); g.fillStyle = "#ffd166"; g.fillRect(4, 4, Math.max(2, c.width - 8), Math.max(2, c.height - 8)); koerper = await new Promise((res) => c.toBlob(res, i.art === "png" ? "image/png" : "image/jpeg", 0.9)); } else if (i.art === "roh") { koerper = new Blob([i.text], { type: t }); } else if (i.art === "gross") { koerper = new Blob([new Uint8Array(i.bytes)], { type: t }); } const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, { method: "POST", headers: { "Content-Type": t, "X-Dateiname": encodeURIComponent(nm), "X-Text": encodeURIComponent(tx) }, body: koerper, }); let leib = null; try { leib = await a.json(); } catch { /* kein JSON */ } return { status: a.status, leib }; }, [raum, inhalt, typ, name, text]); } /* Ein winziges, gültiges PDF -- fünf Zeichen Kopf reichen der Erkennung, der Rest macht daraus eine Datei, die ein Betrachter auch öffnen würde. */ const PDF_TEXT = "%PDF-1.4\n1 0 obj<>endobj\n" + "2 0 obj<>endobj\n" + "3 0 obj<>endobj\n" + "trailer<>\n%%EOF"; /* ======================================================================= 1. EIN FOTO SCHICKEN ======================================================================= */ melde("\n=== 1. Ein Foto schicken ==="); let bildNachrichtId = 0; { await oeffne(luna.seite, raumId); await oeffne(pat.seite, raumId); const a = await hochladen(luna.seite, raumId, { inhalt: { art: "png", breite: 240, hoehe: 150 }, typ: "image/png", name: "startcheck.png", text: "Schau mal, so sieht es aus", }); ok(a.status === 201, `angenommen (HTTP ${a.status})`); const n = a.leib?.nachricht; bildNachrichtId = n?.id || 0; ok(n?.anhang?.art === "bild" && n?.anhang?.typ === "image/png", `erkannt als ${n?.anhang?.art} / ${n?.anhang?.typ}`); ok(n?.anhang?.breite === 240 && n?.anhang?.hoehe === 150, `die Maße wurden aus der Datei gelesen (${n?.anhang?.breite}×${n?.anhang?.hoehe})`); ok(n?.anhang?.name === "startcheck.png", `der Name bleibt erhalten ("${n?.anhang?.name}")`); ok(n?.text === "Schau mal, so sieht es aus", "der Begleitsatz kommt mit"); /* UND ER KOMMT BEIM ANDEREN AN, ohne dass der etwas tut. */ const beiPat = await pat.seite.waitForFunction( () => document.querySelector(".chat-nachricht:last-of-type .chat-bild img"), { timeout: 8000 }).then(() => true).catch(() => false); ok(beiPat, beiPat ? "Patrick sieht das Foto von selbst — kein Neuladen" : "das Foto kam bei Patrick NICHT an"); /* Es wird auch wirklich GEZEIGT, nicht nur eingehaengt. ERST WARTEN, DANN MESSEN. Der erste Anlauf mass sofort, nachdem das im Baum stand -- und las 0x0. Das war kein Befund am Chat, sondern die Frage zu frueh gestellt: Zwischen "das Element ist da" und "das Bild ist geladen" liegt eine Anfrage ans Netz. Eine Pruefung, die diesen Unterschied nicht kennt, meldet Fehler, die es nicht gibt -- und das ist teurer als gar nicht zu pruefen. */ const geladen = await pat.seite.waitForFunction(() => { const i = document.querySelector(".chat-nachricht:last-of-type .chat-bild img"); return !!(i && i.complete && i.naturalWidth > 0); }, { timeout: 8000 }).then(() => true).catch(() => false); ok(geladen, geladen ? "und es laedt auch wirklich" : "das Bild kam nicht durch die Leitung"); const bild = await pat.seite.evaluate(() => { const i = document.querySelector(".chat-nachricht:last-of-type .chat-bild img"); if (!i) return null; const r = i.getBoundingClientRect(); return { geladen: i.complete && i.naturalWidth > 0, nw: i.naturalWidth, nh: i.naturalHeight, w: Math.round(r.width), h: Math.round(r.height), attrW: i.getAttribute("width"), attrH: i.getAttribute("height") }; }); ok(bild?.geladen && bild.nw === 240 && bild.nh === 150, `in Originalgroesse angekommen (${bild?.nw}×${bild?.nh} Punkte)`); ok(bild?.w > 40 && bild?.h > 20, `und ist sichtbar groß (${bild?.w}×${bild?.h} px)`); /* Die Maße stehen am Bild, damit der Verlauf beim Laden nicht springt -- das ist der Zweck, nicht Zierrat. */ ok(bild?.attrW === "240" && bild?.attrH === "150", `Breite und Höhe stehen am Bild (${bild?.attrW}×${bild?.attrH}) — der Verlauf springt nicht`); /* ---- ÜBER DEN ECHTEN KNOPF, NICHT ÜBER DIE SCHNITTSTELLE --------- ================================================================ WARUM DAS HIER STEHT: EIN STILLER FEHLER, EINE LÜCKE IN DIESER PRÜFUNG (23.09.2026) ================================================================ Alles oben schickt mit `fetch` an die Route -- der bequeme Weg, und er prüft den Server gründlich. Er prüft aber NICHT den Weg, den ein Mensch geht: Büroklammer, Datei wählen, warten. In genau diesem Weg stand seit dem 19.09. `const { nachricht } = e.daten;` -- ein `e`, das es dort nicht gibt. Die Zeile warf, der `catch` daneben fing, und der Absender las: „Keine Verbindung -- der Anhang wurde nicht geschickt." Das Foto war aber da. Es kam Sekunden später über den Ereignisstrom, mit einer roten Meldung darüber, die das Gegenteil behauptete, und der getippte Begleitsatz blieb im Feld stehen -- also schickt man es noch einmal. Diese Prüfung war die ganze Zeit grün. Sie hat den Server geprüft, nicht die Bedienung. Deshalb geht dieser Block jetzt durch das Dateifeld selbst und sieht auf das, was der Mensch danach sieht: steht dort eine Fehlermeldung, und ist das Feld leer? */ await luna.seite.fill("#text", "über den Knopf geschickt"); await luna.seite.setInputFiles("#anhang-feld", { name: "ueber-den-knopf.png", mimeType: "image/png", /* Das kleinste gültige PNG: 1×1, ein Pixel. Es geht hier nicht um das Bild, sondern um den Weg. */ buffer: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"), }); await luna.seite.waitForTimeout(2500); const danach = await luna.seite.evaluate(() => ({ fehlertext: document.getElementById("fehler")?.textContent.trim() || "", feld: document.getElementById("text")?.value || "", letzterName: document.querySelector(".chat-nachricht:last-of-type .chat-bild") ?.getAttribute("title") || "", })); ok(danach.letzterName.includes("ueber-den-knopf.png"), `über die Büroklammer geschickt: das Foto steht im Verlauf (${danach.letzterName.slice(0, 40)})`); /* DIE EIGENTLICHE AUSSAGE. Eine Fehlermeldung über einem Foto, das angekommen ist, ist schlimmer als gar keine Rückmeldung. */ ok(danach.fehlertext === "", ` und darüber steht KEINE Fehlermeldung ("${danach.fehlertext}")`); /* UND DAS FELD IST LEER. Bleibt der Begleitsatz stehen, schickt man dasselbe Foto ein zweites Mal -- der Satz stand vorher drin. */ ok(danach.feld === "", ` das Schreibfeld ist leer, der Begleitsatz ging mit ("${danach.feld}")`); } /* ======================================================================= 1b. DER VERLAUF STEHT TROTZDEM UNTEN Ein Bild wird SPÄTER fertig als der Rest der Seite. Wer beim Öffnen ans Ende springt und danach ein Foto nachlädt, steht plötzlich mittendrin -- und die letzten Nachrichten stehen unter dem Bildschirmrand, ohne dass jemand etwas angeklickt hätte. Genau das war auf dem ersten Bildschirmfoto zu sehen. Deshalb steht die Messung jetzt hier und nicht im Auge. ======================================================================= */ melde("\n=== 1b. Der Verlauf steht unten ==="); { /* Ein GROSSES Bild -- eines, das an den Deckel von 340 px stösst. Ein kleines würde die Frage gar nicht stellen. */ await hochladen(luna.seite, raumId, { inhalt: { art: "png", breite: 900, hoehe: 600 }, typ: "image/png", name: "gross.png", }); for (let i = 1; i <= 4; i++) await schicke(pat.seite, raumId, `Danach kommt noch Zeile ${i}.`); /* IN EINEM FRISCHEN BROWSER, und das ist der Kern dieser Prüfung. Der erste Anlauf mass in Lunas offenem Fenster -- dort lag das Bild längst im Zwischenspeicher, war also sofort da, und die Messung ergab tadellose 0 px. Der Fehler zeigte sich erst beim ERSTEN Laden. Eine Prüfung, die nur den warmen Fall kennt, prüft genau den Fall nicht, um den es geht. */ const frisch = await alsRolle("creator", "CODE-CREA-0001"); await frisch.seite.goto(`${BASIS}/workspace/chat.html?raum=${raumId}`, { waitUntil: "networkidle" }); await frisch.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 }); await frisch.seite.waitForFunction(() => { const b = [...document.querySelectorAll(".chat-bild img")]; return b.length > 0 && b.every((i) => i.complete && i.naturalWidth > 0); }, { timeout: 10000 }).then(() => true).catch(() => false); await frisch.seite.waitForTimeout(700); const lage = await frisch.seite.evaluate(() => { const v = document.getElementById("verlauf"); const gross = [...document.querySelectorAll(".chat-bild")] .map((k) => Math.round(k.getBoundingClientRect().height)); return { rest: Math.round(v.scrollHeight - v.scrollTop - v.clientHeight), knopf: !document.getElementById("zum-neuesten").hidden, gross }; }); ok(lage.rest < 40, `beim ERSTEN Laden steht der Verlauf am Ende (${lage.rest} px Rest, Bildhöhen ${lage.gross.join("/")})`); ok(!lage.knopf, "und der Knopf „Zum neuesten“ erscheint nicht ohne Anlass"); ok(lage.gross.every((h) => h <= 342), `kein Bild sprengt den Deckel von 340 px (${lage.gross.join("/")})`); await frisch.ctx.close(); } /* ======================================================================= 2. EIN PDF SCHICKEN ======================================================================= */ melde("\n=== 2. Ein PDF schicken ==="); let pdfNachrichtId = 0; { const a = await hochladen(luna.seite, raumId, { inhalt: { art: "roh", text: PDF_TEXT }, typ: "application/pdf", name: "Vertrag Größe.pdf", }); ok(a.status === 201, `angenommen (HTTP ${a.status})`); pdfNachrichtId = a.leib?.nachricht?.id || 0; ok(a.leib?.nachricht?.anhang?.art === "pdf", `erkannt als ${a.leib?.nachricht?.anhang?.art}`); ok(a.leib?.nachricht?.anhang?.name === "Vertrag Größe.pdf", `Umlaute überleben die Kopfzeile ("${a.leib?.nachricht?.anhang?.name}")`); await oeffne(luna.seite, raumId); const karte = await luna.seite.evaluate(() => { const k = document.querySelector(".chat-nachricht:last-of-type .chat-datei"); if (!k) return null; return { name: k.querySelector(".chat-datei__name")?.textContent, mass: k.querySelector(".chat-datei__mass")?.textContent, ziel: k.target, rel: k.rel, breit: Math.round(k.getBoundingClientRect().width) }; }); ok(karte?.name === "Vertrag Größe.pdf", `die Karte zeigt den Namen ("${karte?.name}")`); ok(/\d/.test(karte?.mass || ""), `und die Größe ("${karte?.mass}")`); ok(karte?.ziel === "_blank" && /noopener/.test(karte?.rel || ""), "sie öffnet in einem neuen Tab, mit noopener"); /* KEIN Bild-Kasten für ein PDF -- eine graue Fläche, die so tut, als könnte man etwas lesen, wäre schlechter als eine ehrliche Karte. */ const kaeme = await luna.seite.$$eval(".chat-nachricht:last-of-type .chat-bild", (b) => b.length); ok(kaeme === 0, `für ein PDF wird kein Bildkasten gebaut (${kaeme})`); } /* ======================================================================= 2c. DIE BILDSCHAU (25.09.2026) ======================================================================= Filipe: „wenn man bilder aufmacht, will ich dass die in der mitte sind. beim hochformat soll links der husky sein und rechts der hase und beim querformat soll oben dogfather und unten team dogi stehen da wo platz ist." VORHER OEFFNETE DER KLICK DIE DATEI in einem neuen Tab -- und was man sah, war die eingebaute Bildanzeige des Browsers: schwarzer Grund, Bild oben links. Das ist kein Fehler des Browsers; eine Datei hat keine Gestaltung. GEMESSEN WIRD, WAS ER GESAGT HAT, und jedes einzeln: 1. Der Klick oeffnet die Schau (und nicht mehr den Tab). 2. Das Bild steht in der MITTE -- gemessen als Abstand seines Mittelpunkts zur Fenstermitte, nicht als „es ist da". 3. Bei einem breiten Bild stehen die zwei Schriftzuege da. 4. Escape bringt einen zurueck. 5. GEGENPROBE: Mit Strg gedrueckt oeffnet sie NICHT -- der Weg in den neuen Tab bleibt, wie er war. Ohne diese Zeile haette „die Schau geht auf" auch dann gestimmt, wenn sie IMMER aufgeht und der alte Weg verloren ist. ======================================================================= */ { await oeffne(luna.seite, raumId); /* Das Foto aus Abschnitt 2 ist 240x150 -- also quer. */ const bildKasten = ".chat-nachricht .chat-bild"; await luna.seite.waitForSelector(bildKasten, { timeout: 8000 }); /* GEGENPROBE ZUERST: mit Strg bleibt der alte Weg. */ await luna.seite.click(bildKasten, { modifiers: ["Control"] }); await luna.seite.waitForTimeout(400); const mitStrg = await luna.seite.evaluate(() => !!document.querySelector("dialog.bildschau[open]")); ok(mitStrg === false, `Gegenprobe: Strg+Klick öffnet die Schau NICHT (${mitStrg}) — der Weg in den Tab bleibt`); await luna.seite.click(bildKasten); await blendeSteht(luna.seite); const schau = await luna.seite.evaluate(() => { const d = document.querySelector("dialog.bildschau[open]"); if (!d) return { offen: false }; const b = d.querySelector(".bildschau__bild"); const r = b.getBoundingClientRect(); const worte = [...d.querySelectorAll(".bildschau__wort")] .filter((e) => Number(getComputedStyle(e).opacity) > .5) .map((e) => e.textContent.trim()); return { offen: true, lage: d.dataset.lage, /* Wie weit ist die Bildmitte von der Fenstermitte weg? Bei „mittig" sind das ein paar Pixel, bei „oben links" Hunderte. */ abX: Math.round(Math.abs((r.left + r.width / 2) - window.innerWidth / 2)), abY: Math.round(Math.abs((r.top + r.height / 2) - window.innerHeight / 2)), worte, figuren: [...d.querySelectorAll(".bildschau__figur")] .filter((e) => Number(getComputedStyle(e).opacity) > .5).length, grund: getComputedStyle(d.querySelector(".bildschau__grund")).backgroundImage.length, }; }); ok(schau.offen === true, `ein Klick öffnet die Bildschau (${schau.offen})`); /* 40 px Spielraum: Die Buehne hat oben mehr Polsterung als unten (dort steht die Leiste), der Mittelpunkt liegt also nie exakt auf dem Pixel. Bei „oben links in der Ecke" waeren es Hunderte. */ ok(schau.abX <= 40 && schau.abY <= 60, `und das Bild steht in der Mitte (${schau.abX} px seitlich, ${schau.abY} px hoch daneben)`); ok(schau.lage === "quer", `ein breites Bild bekommt die Querformat-Fassung ("${schau.lage}")`); ok(schau.worte.length === 2 && /DogFather/i.test(schau.worte[0]) && /Team Dogi/i.test(schau.worte[1]), `mit „DogFather" oben und „Team Dogi" unten (${JSON.stringify(schau.worte)})`); ok(schau.figuren === 0, `und OHNE die zwei Figuren — die gehören ins Hochformat (${schau.figuren})`); ok(schau.grund > 80, `der Grund ist gestaltet und nicht schwarz (${schau.grund} Zeichen Verlauf)`); /* EIN BILD FUER DEN MENSCHEN. Andere Pruefungen in diesem Haus legen ebenso eines ab (pruef-handy.png, pruef-crew-wand-handy.png) -- eine Zahl sagt „mittig", ein Bild sagt, ob es gut aussieht. */ await luna.seite.screenshot({ path: "server/pruef-bildschau.png" }); await luna.seite.keyboard.press("Escape"); await luna.seite.waitForTimeout(400); const zu = await luna.seite.evaluate(() => !!document.querySelector("dialog.bildschau[open]")); ok(zu === false, `Escape bringt einen zurück (${zu})`); /* ---- UND DAS HOCHFORMAT ----------------------------------------- Der andere Fall aus Filipes Satz -- „beim hochformat soll links der husky sein und rechts der hase". Ohne ein zweites, HOHES Bild waere dieser halbe Auftrag nie gemessen worden; das quere Bild oben kann ihn nicht beantworten. */ const hoch = await hochladen(luna.seite, raumId, { inhalt: { art: "png", breite: 300, hoehe: 800 }, typ: "image/png", name: "hochformat.png", text: "Und hochkant", }); ok(hoch.status === 201, `ein hohes Foto kommt an (HTTP ${hoch.status})`); await oeffne(luna.seite, raumId); await luna.seite.waitForSelector(".chat-nachricht:last-of-type .chat-bild", { timeout: 8000 }); await luna.seite.click(".chat-nachricht:last-of-type .chat-bild"); await blendeSteht(luna.seite); const hochSchau = await luna.seite.evaluate(() => { const d = document.querySelector("dialog.bildschau[open]"); if (!d) return { offen: false }; const b = d.querySelector(".bildschau__bild"); const r = b.getBoundingClientRect(); const sicht = (s) => [...d.querySelectorAll(s)] .filter((e) => Number(getComputedStyle(e).opacity) > .5); const figuren = sicht(".bildschau__figur"); return { offen: true, lage: d.dataset.lage, abX: Math.round(Math.abs((r.left + r.width / 2) - window.innerWidth / 2)), figuren: figuren.length, /* LIEGT EINE DAVON UEBER DEM BILD? Das waere der Schaden, den man erst sieht, wenn ein Gesicht halb verdeckt ist. */ drueber: figuren.filter((e) => { const f = e.getBoundingClientRect(); return Math.min(f.right, r.right) - Math.max(f.left, r.left) > 4; }).length, worte: sicht(".bildschau__wort").length, }; }); ok(hochSchau.lage === "hoch", `ein hohes Bild bekommt die Hochformat-Fassung ("${hochSchau.lage}")`); ok(hochSchau.figuren === 2, `Husky und Hase stehen daneben (${hochSchau.figuren})`); ok(hochSchau.drueber === 0, `und KEINE davon liegt über dem Foto (${hochSchau.drueber})`); ok(hochSchau.worte === 0, `die Schriftzüge bleiben weg — die gehören ins Querformat (${hochSchau.worte})`); ok(hochSchau.abX <= 40, `auch hier steht das Bild mittig (${hochSchau.abX} px daneben)`); await luna.seite.screenshot({ path: "server/pruef-bildschau-hoch.png" }); await luna.seite.keyboard.press("Escape"); await luna.seite.waitForTimeout(300); } /* ======================================================================= 3. WAS NICHT HEREINDARF — die eigentliche Prüfung Alles hier MUSS scheitern. Ein "ok" in diesem Abschnitt heisst: der Angriff wurde abgewiesen. ======================================================================= */ melde("\n=== 3. Was nicht hereindarf ==="); { const versuche = [ ["HTML, das sich png nennt", { art: "roh", text: '' }, "image/png", "harmlos.png"], ["SVG, das sich png nennt", { art: "roh", text: '' }, "image/png", "logo.png"], ["SVG als svg", { art: "roh", text: '' }, "image/svg+xml", "logo.svg"], ["eine Textdatei", { art: "roh", text: "nur Text, kein Bild, kein PDF" }, "text/plain", "notiz.txt"], ["ein PNG-Kopf ohne Inhalt", { art: "roh", text: "\x89PNG\r\n\x1a\n" + "x".repeat(40) }, "image/png", "fast.png"], ]; await absichtlich([luna], async () => { for (const [was, inhalt, typ, name] of versuche) { const a = await hochladen(luna.seite, raumId, { inhalt, typ, name }); ok(a.status === 415, `${was}: abgewiesen (HTTP ${a.status}${a.status === 415 ? "" : " — KAM DURCH"})`); } }); /* GEGENPROBE ZUR GEGENPROBE: Ein echtes JPEG geht durch. Ohne das bewiese der Block oben nur, dass gar nichts angenommen wird. */ const echt = await hochladen(luna.seite, raumId, { inhalt: { art: "jpeg", breite: 120, hoehe: 90 }, typ: "image/jpeg", name: "foto.jpg", }); ok(echt.status === 201 && echt.leib?.nachricht?.anhang?.typ === "image/jpeg", `ein echtes JPEG geht durch (HTTP ${echt.status}, ${echt.leib?.nachricht?.anhang?.typ})`); ok(echt.leib?.nachricht?.anhang?.breite === 120 && echt.leib?.nachricht?.anhang?.hoehe === 90, `auch beim JPEG stimmen die Maße (${echt.leib?.nachricht?.anhang?.breite}×${echt.leib?.nachricht?.anhang?.hoehe})`); /* Zu gross: Das muss eine LESBARE Absage sein, kein 500er. */ const zuGross = await absichtlich([luna], () => hochladen(luna.seite, raumId, { inhalt: { art: "gross", bytes: 13 * 1024 * 1024 }, typ: "image/png", name: "riesig.png", })); ok(zuGross.status === 413, `zu groß wird mit 413 abgelehnt, nicht mit 500 (${zuGross.status})`); ok(/MB/.test(zuGross.leib?.fehler || ""), `und die Meldung sagt, was gilt: "${zuGross.leib?.fehler}"`); } /* ======================================================================= 4. WIE DER ANHANG AUSGELIEFERT WIRD ======================================================================= */ melde("\n=== 4. Ausliefern ==="); { const kopf = await luna.seite.evaluate(async (id) => { const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" }); return { status: a.status, typ: a.headers.get("content-type"), lage: a.headers.get("content-disposition"), sniff: a.headers.get("x-content-type-options"), csp: a.headers.get("content-security-policy") }; }, bildNachrichtId); ok(kopf.status === 200, `das Foto kommt (HTTP ${kopf.status})`); ok(kopf.typ === "image/png", `mit dem ERKANNTEN Typ (${kopf.typ})`); ok(/^inline/.test(kopf.lage || ""), `zum Ansehen, nicht als Download (${kopf.lage})`); ok(kopf.sniff === "nosniff", "der Browser darf sich keinen anderen Typ ausdenken"); ok(/sandbox/.test(kopf.csp || ""), `und die Richtlinie nimmt jede Ausführung (${kopf.csp})`); const alsDownload = await luna.seite.evaluate(async (id) => { const a = await fetch(`/workspace/api/chat/anhang/${id}?laden=1`, { cache: "no-store" }); return a.headers.get("content-disposition"); }, bildNachrichtId); ok(/^attachment/.test(alsDownload || ""), `mit ?laden=1 wird es ein Download (${alsDownload})`); /* =================================================================== TEILANFRAGEN (02.10.2026) WARUM DAS HIER STEHT: Miss, eine Modi, konnte keine Sprachnachrichten hoeren. An ihrer Rolle lag es nicht -- der Weg hier kennt gar keine. Sie ist die Einzige im Haus mit Safari (iPhone, iOS 18.7), und Safari verlangt fuer