/* 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 { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4323, "die Chat-Anhangpruefung");
process.env.PORT = "4323";
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:4323";
const ANHANG_ORDNER = join(ordner, "chat-anhaenge");
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;
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");
const browser = await pw.chromium.launch();
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
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`);
}
/* =======================================================================
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})`);
}
/* =======================================================================
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: '