diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs
new file mode 100644
index 00000000..666efbf0
--- /dev/null
+++ b/server/pruef-chat-anhaenge.mjs
@@ -0,0 +1,631 @@
+/* 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";
+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: '
' },
+ "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})`);
+
+ /* WER NICHT IM RAUM IST, BEKOMMT NICHTS. DogFather sieht sonst
+ überall alles -- hier ausdrücklich nicht: Ein Gespräch zwischen
+ zwei anderen ist kein Bereich, den man verwaltet. */
+ const fremd = await absichtlich([dogi], () => dogi.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
+ return a.status;
+ }, bildNachrichtId));
+ ok(fremd === 404, `wer nicht im Gespräch ist, bekommt 404 (${fremd})`);
+
+ /* Und die Datei liegt AUSSERHALB des Web-Verzeichnisses. */
+ ok(existsSync(ANHANG_ORDNER) && readdirSync(ANHANG_ORDNER).length >= 3,
+ `die Dateien liegen im Datenordner (${existsSync(ANHANG_ORDNER) ? readdirSync(ANHANG_ORDNER).length : 0} Stück)`);
+ const namen = readdirSync(ANHANG_ORDNER);
+ ok(!namen.includes("startcheck.png") && namen.every((x) => /^[a-z0-9]+-[a-f0-9]{16}\.[a-z]+$/.test(x)),
+ `auf der Platte steht ein erzeugter Name, nie der eingeschickte (${namen[0]})`);
+}
+
+/* =======================================================================
+ 5. ZURÜCKNEHMEN NIMMT AUCH DIE DATEI
+ ======================================================================= */
+melde("\n=== 5. Zurücknehmen ===");
+{
+ const vorher = readdirSync(ANHANG_ORDNER).length;
+ const weg = await luna.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
+ return a.status;
+ }, pdfNachrichtId);
+ ok(weg === 200, `die Nachricht wird zurückgenommen (${weg})`);
+
+ const nachher = readdirSync(ANHANG_ORDNER).length;
+ ok(nachher === vorher - 1, `die Datei ist von der Platte weg (${vorher} → ${nachher})`);
+
+ const nochDa = await absichtlich([luna], () => luna.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
+ return a.status;
+ }, pdfNachrichtId));
+ ok(nochDa === 404,
+ `und über die Adresse kommt niemand mehr heran (${nochDa}) — sonst wäre es nur der Anschein einer Rücknahme`);
+
+ /* GEGENPROBE: Das Foto von vorhin ist NICHT mitgelöscht worden. */
+ const anderes = await luna.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
+ return a.status;
+ }, bildNachrichtId);
+ ok(anderes === 200, `das andere Foto liegt unberührt da (${anderes})`);
+}
+
+/* =======================================================================
+ 6. EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR
+ ======================================================================= */
+melde("\n=== 6. Wegräumen ===");
+{
+ await schicke(pat.seite, raumId, "Das ist ein Satz, den es nur hier gibt: Zaunkönig.");
+ await oeffne(luna.seite, raumId);
+ await oeffne(pat.seite, raumId);
+
+ const lunaVorher = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
+ const patVorher = await pat.seite.$$eval(".chat-nachricht", (n) => n.length);
+ ok(lunaVorher > 3 && patVorher > 3, `beide sehen den Verlauf (${lunaVorher} / ${patVorher})`);
+
+ luna.seite.once("dialog", (dlg) => dlg.accept());
+ await luna.seite.click("#gespraech-weg");
+ await luna.seite.waitForTimeout(900);
+
+ const inListe = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
+ ok(!inListe.some((t) => /Patrick/.test(t)),
+ `es ist aus Lunas Liste verschwunden (${inListe.length} übrig: ${inListe.map((x) => x.trim().split("\n")[0]).join(", ")})`);
+
+ /* DAS ENTSCHEIDENDE: Bei Patrick ist alles noch da. */
+ await oeffne(pat.seite, raumId);
+ const patNachher = await pat.seite.$$eval(".chat-nachricht", (n) => n.length);
+ ok(patNachher === patVorher,
+ `Patrick hat weiterhin alle ${patNachher} Nachrichten — niemand kann einem anderen den Verlauf nehmen`);
+
+ /* Auch beim direkten Aufruf sieht Luna nichts mehr davon. */
+ const beiLuna = await luna.seite.evaluate(async (r) => {
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
+ return (await a.json()).nachrichten.length;
+ }, raumId);
+ ok(beiLuna === 0, `auch direkt abgefragt ist der Verlauf für Luna leer (${beiLuna})`);
+
+ /* UND DIE SUCHE. Das ist die Stelle, an der so etwas typischerweise
+ durchrutscht: aus der Liste weg, über die Suche noch da. */
+ const findetLuna = await luna.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
+ return (await a.json()).treffer.length;
+ });
+ const findetPat = await pat.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
+ return (await a.json()).treffer.length;
+ });
+ ok(findetLuna === 0, `die Suche findet es bei Luna nicht mehr (${findetLuna})`);
+ ok(findetPat === 1, `bei Patrick schon (${findetPat}) — sonst wäre es doch für beide gelöscht`);
+
+ /* Und an den Anhang kommt Luna auch nicht mehr. */
+ const anhangLuna = await absichtlich([luna], () => luna.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
+ return a.status;
+ }, bildNachrichtId));
+ const anhangPat = await pat.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
+ return a.status;
+ }, bildNachrichtId);
+ ok(anhangLuna === 404 && anhangPat === 200,
+ `der Anhang ist für Luna zu (${anhangLuna}) und für Patrick offen (${anhangPat})`);
+
+ /* Die Zahl in der Kopfleiste darf davon nicht steigen. */
+ const offen1 = await luna.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/chat/ungelesen");
+ return (await a.json()).ungelesen;
+ });
+ ok(offen1 === 0, `nichts zählt mehr als ungelesen (${offen1})`);
+}
+
+/* =======================================================================
+ 7. UND ES KOMMT ZURÜCK, WENN JEMAND SCHREIBT
+ ======================================================================= */
+melde("\n=== 7. Es kommt zurück ===");
+{
+ await schicke(pat.seite, raumId, "Hallo? Bist du noch da?");
+ await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
+ await luna.seite.waitForTimeout(900);
+
+ const liste = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
+ ok(liste.some((t) => /Patrick/.test(t)), "das Gespräch ist wieder in der Liste");
+
+ await oeffne(luna.seite, raumId);
+ const texte = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
+ ok(texte.length === 1 && /Bist du noch da/.test(texte[0]),
+ `aber NUR mit dem Neuen (${texte.length} Nachricht: "${texte[0]}")`);
+ ok(!texte.some((t) => /Zaunkönig/.test(t)), "das Alte bleibt weg");
+
+ /* Auch die Suche bringt das Alte nicht zurück. */
+ const wieder = await luna.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
+ return (await a.json()).treffer.length;
+ });
+ ok(wieder === 0, `und die Suche auch nicht (${wieder})`);
+}
+
+/* =======================================================================
+ 8. EIN GESPRÄCH OHNE JEDE NACHRICHT
+
+ Der Randfall, für den es die zweite Spalte gibt: Hier ist die
+ höchste Nachrichtennummer 0 -- und 0 heisst sonst "nie gelöscht".
+ ======================================================================= */
+melde("\n=== 8. Das leere Gespräch ===");
+{
+ await oeffne(luna.seite, raumDogi);
+ const leer = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
+ ok(leer === 0, `das Gespräch mit DogFather ist leer (${leer})`);
+
+ luna.seite.once("dialog", (dlg) => dlg.accept());
+ await luna.seite.click("#gespraech-weg");
+ await luna.seite.waitForTimeout(900);
+
+ const liste = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
+ ok(!liste.some((t) => /Filipe/.test(t)),
+ `auch ein leeres Gespräch lässt sich wegräumen (${liste.length} übrig)`);
+
+ /* GEGENPROBE: Schreibt DogFather, ist es wieder da. */
+ await schicke(dogi.seite, raumDogi, "Kurze Frage noch.");
+ await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
+ await luna.seite.waitForTimeout(900);
+ const liste2 = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
+ ok(liste2.some((t) => /Filipe/.test(t)), "und es kommt zurück, sobald er schreibt");
+}
+
+/* =======================================================================
+ 9. KEINE STILLEN FEHLER
+ ======================================================================= */
+melde("\n=== 9. Stille Fehler ===");
+{
+ /* ABSICHTLICH PROVOZIERTE ABSAGEN ZÄHLEN NICHT.
+
+ Abschnitt 3 lädt fünfmal Unfug hoch und einmal etwas zu Grosses.
+ Jede dieser Absagen schreibt der Browser als "Failed to load
+ resource … 415" in die Konsole -- das ist der BEWEIS, dass die
+ Abwehr gegriffen hat, nicht ein Mangel.
+
+ Der Filter ist eng gefasst: genau diese Zeile, genau diese beiden
+ Nummern. Ein weiter gefasster ("alles mit 'Failed'") wäre bequem
+ und würde ab morgen echte Ausfälle mit verschlucken. */
+ const echte = [...luna.konsole, ...pat.konsole, ...dogi.konsole];
+ const gewollt = [...luna.rauschen, ...pat.rauschen, ...dogi.rauschen];
+ ok(echte.length === 0, echte.length ? "Fehler in der Konsole: " + echte.slice(0, 4).join(" | ")
+ : `keine unerwarteten Fehler in drei Browsern (${gewollt.length} provozierte Absagen sauber getrennt)`);
+
+ /* GEGENPROBE ZUR TRENNUNG: Der zweite Topf darf nicht leer sein.
+ Wäre er es, hätte die Trennung nichts getan -- und der Haken
+ darüber wäre grün, ohne je etwas auseinandergehalten zu haben.
+ Neun absichtliche Absagen: fünf Uploads, ein 413, drei 404. */
+ ok(gewollt.length >= 6,
+ `die absichtlichen Absagen landen wirklich im zweiten Topf (${gewollt.length})`);
+ await luna.seite.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
+ await luna.seite.waitForTimeout(500);
+ ok(luna.konsole.some((z) => /Absicht/.test(z)),
+ `ein absichtlicher Fehler wird bemerkt (${luna.konsole.length})`);
+}
+
+await luna.ctx.close();
+await pat.ctx.close();
+await dogi.ctx.close();
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+melde(`\n${geprueft} Prüfungen`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index 56465ee6..1f174a30 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -38,15 +38,32 @@
===================================================================== */
import express from "express";
+import { randomBytes } from "node:crypto";
+import { join, extname, basename } from "node:path";
+import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
- schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG,
+ schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
} from "./workspace.js";
export const chatRouter = express.Router();
const TEXT_MAX = 4000;
const NAME_MAX = 80;
+
+/* ---------- Anhänge ------------------------------------------------------
+ Derselbe Weg wie in der Dateiablage, und aus denselben Gründen:
+ Der Browser schickt die Datei roh im Rumpf, der Name steht im Kopf.
+ express.raw() nimmt das entgegen -- multipart/form-data von Hand zu
+ zerlegen wäre an dieser Stelle die gefährlichere Übung.
+
+ 12 MB und nicht 25 wie in der Ablage: Ein Chat sammelt viele kleine
+ Dateien, kein Archiv grosser. Ein Handyfoto liegt bei 2 bis 5 MB,
+ ein PDF mit Bildern selten über 10. */
+const ANHANG_MAX = 12 * 1024 * 1024;
+const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
+const ANHANG_NAME_MAX = 200;
+
const jetzt = () => new Date().toISOString();
/* ---------- Schranke ---------------------------------------------------- */
@@ -141,6 +158,129 @@ function direktRaum(personA, idB, { anlegen = true } = {}) {
return raumId;
}
+/* =====================================================================
+ WAS IST DAS WIRKLICH? — die Erkennung an den ersten Bytes
+
+ DIE GANZE SICHERHEIT DER ANHÄNGE HÄNGT AN DIESER FUNKTION.
+
+ Der Dateiname kommt vom Absender. Der Content-Type kommt vom
+ Absender. Beide kann man frei erfinden. Wer sich auf sie verlässt,
+ liefert irgendwann eine HTML- oder SVG-Datei mit der Endung .png
+ aus -- und die läuft dann im Browser als Seite DIESER Domain, mit
+ Zugriff auf die Sitzung des Empfängers. Das ist der Standardfehler
+ bei selbst gebauten Uploads, und er ist von aussen nicht zu sehen.
+
+ Deshalb: Der Typ wird aus dem Inhalt bestimmt. Was hier nicht
+ erkannt wird, kommt nicht herein -- und ausgeliefert wird später
+ genau der Typ, der HIER festgestellt wurde, nie der eingeschickte.
+
+ SVG STEHT ABSICHTLICH NICHT IN DER LISTE. Eine SVG-Datei ist ein
+ XML-Dokument und darf Skripte enthalten; sie ist als "Bild"
+ getarnter Programmtext. Fotos sind PNG, JPEG, WebP oder GIF.
+
+ Breite und Höhe kommen im selben Durchgang mit: Der Verlauf
+ reserviert damit den Platz, bevor das Bild da ist, und springt beim
+ Laden nicht. Lassen sie sich nicht ablesen, ist das kein Grund zur
+ Ablehnung -- dann steht eben 0, und der Platz wird geschätzt.
+ ===================================================================== */
+function dateiErkennen(b) {
+ if (!Buffer.isBuffer(b) || b.length < 16) return null;
+ const kopf = (von, bis) => b.toString("latin1", von, bis);
+
+ /* --- PNG: acht feste Bytes, danach muss IHDR kommen. -------------- */
+ if (b[0] === 0x89 && kopf(1, 4) === "PNG" && b[4] === 0x0d && b[5] === 0x0a
+ && b[6] === 0x1a && b[7] === 0x0a) {
+ if (b.length >= 24 && kopf(12, 16) === "IHDR") {
+ return { art: "bild", typ: "image/png", endung: ".png",
+ breite: b.readUInt32BE(16), hoehe: b.readUInt32BE(20) };
+ }
+ return null; /* PNG-Kopf ohne IHDR ist keins */
+ }
+
+ /* --- GIF ---------------------------------------------------------- */
+ if (kopf(0, 6) === "GIF87a" || kopf(0, 6) === "GIF89a") {
+ return { art: "bild", typ: "image/gif", endung: ".gif",
+ breite: b.readUInt16LE(6), hoehe: b.readUInt16LE(8) };
+ }
+
+ /* --- WebP: RIFF-Behälter mit der Kennung WEBP ---------------------- */
+ if (kopf(0, 4) === "RIFF" && kopf(8, 12) === "WEBP") {
+ let breite = 0, hoehe = 0;
+ const sorte = kopf(12, 16);
+ try {
+ if (sorte === "VP8 " && b.length > 30 && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
+ breite = b.readUInt16LE(26) & 0x3fff;
+ hoehe = b.readUInt16LE(28) & 0x3fff;
+ } else if (sorte === "VP8L" && b.length > 25 && b[20] === 0x2f) {
+ /* Vier Bytes, aus denen zweimal 14 Bit gelesen werden. */
+ const w = b.readUInt32LE(21);
+ breite = (w & 0x3fff) + 1;
+ hoehe = ((w >> 14) & 0x3fff) + 1;
+ } else if (sorte === "VP8X" && b.length > 30) {
+ breite = (b[24] | (b[25] << 8) | (b[26] << 16)) + 1;
+ hoehe = (b[27] | (b[28] << 8) | (b[29] << 16)) + 1;
+ }
+ } catch { breite = 0; hoehe = 0; }
+ return { art: "bild", typ: "image/webp", endung: ".webp", breite, hoehe };
+ }
+
+ /* --- JPEG: SOI, danach muss ein weiterer Marker folgen. ------------ */
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
+ let breite = 0, hoehe = 0;
+ let i = 2;
+ /* Durch die Abschnitte springen, bis ein Bildkopf (SOF) kommt.
+ 0xc4/0xc8/0xcc sind KEINE Bildköpfe (Huffman-Tabellen u. ä.) --
+ wer die mitnimmt, liest dort Unsinn als Maße. */
+ while (i + 9 < b.length) {
+ if (b[i] !== 0xff) { i++; continue; }
+ const m = b[i + 1];
+ if (m === 0xff) { i++; continue; } /* Füllbyte */
+ if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { i += 2; continue; }
+ if (m === 0xda) break; /* ab hier Bilddaten */
+ const laenge = b.readUInt16BE(i + 2);
+ if (laenge < 2) break;
+ if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
+ hoehe = b.readUInt16BE(i + 5);
+ breite = b.readUInt16BE(i + 7);
+ break;
+ }
+ i += 2 + laenge;
+ }
+ return { art: "bild", typ: "image/jpeg", endung: ".jpg", breite, hoehe };
+ }
+
+ /* --- PDF ---------------------------------------------------------- */
+ if (kopf(0, 5) === "%PDF-") {
+ return { art: "pdf", typ: "application/pdf", endung: ".pdf", breite: 0, hoehe: 0 };
+ }
+
+ return null;
+}
+
+/** Der Anzeigename. Er steht nur in der Datenbank -- auf der Platte
+ * bekommt jede Datei einen erzeugten Zufallsnamen, damit ein Name mit
+ * "../" darin nirgendwo hinkommt. */
+function anhangName(roh, endung) {
+ let name;
+ try { name = decodeURIComponent(roh || ""); } catch { name = String(roh || ""); }
+ name = basename(name).replace(/[\x00-\x1f<>:"/\\|?*]/g, "_").trim();
+ name = name.slice(0, ANHANG_NAME_MAX);
+ if (!name) name = "anhang" + endung;
+ return name;
+}
+
+/** Bis zu welcher Nachricht hat DIESE Person das Gespräch weggeräumt?
+ * 0 heisst: gar nicht. Steht an einer Stelle, damit keine Abfrage sie
+ * vergisst -- eine zweite Fassung wäre ein Gespräch, das in der Liste
+ * weg ist und in der Suche noch auftaucht. */
+function geloeschtBis(raumId, personId) {
+ try {
+ return db().prepare(
+ "SELECT geloescht_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
+ .get(raumId, personId)?.geloescht_bis ?? 0;
+ } catch { return 0; }
+}
+
/** Wie heißt dieser Raum für DIESE Person?
*
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
@@ -179,27 +319,44 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => {
/** Meine Gespräche, das jüngste zuerst. */
chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
try {
+ /* WEGGERÄUMTE GESPRÄCHE STEHEN NICHT IN DER LISTE.
+
+ Die Bedingung sitzt hier in der Abfrage und nicht in einem
+ Filter danach: Was nicht herauskommt, kann auch niemand
+ versehentlich wieder hineinreichen.
+
+ Zwei Fälle, und beide müssen stimmen:
+ * Es kam etwas Neues -> das Gespräch ist wieder da, aber nur
+ mit dem Neuen (id > geloescht_bis).
+ * Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine
+ Nachricht gab; dafür steht geloescht_am. */
const reihen = db().prepare(`
- SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung
+ SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung, t.geloescht_bis
FROM chat_raeume r
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
WHERE t.raus_am IS NULL
+ AND (t.geloescht_am IS NULL
+ OR EXISTS (SELECT 1 FROM chat_nachrichten n
+ WHERE n.raum_id = r.id AND n.id > t.geloescht_bis))
ORDER BY CASE WHEN r.letzte_am IS NULL THEN 1 ELSE 0 END, r.letzte_am DESC, r.id DESC`)
.all(req.person.id);
const raeume = reihen.map((r) => {
const leute = teilnehmerVon(r.id);
/* Die letzte Nachricht als Vorschau -- ohne sie ist eine
- Gesprächsliste nur eine Namensliste. */
+ Gesprächsliste nur eine Namensliste. Sie muss dieselbe Grenze
+ achten wie der Verlauf: Sonst stünde in der Vorschau ein Satz,
+ den man beim Öffnen nirgends findet. */
const letzte = db().prepare(`
- SELECT n.text, n.erstellt, n.weg_am, p.name AS von
+ SELECT n.text, n.erstellt, n.weg_am, n.anhang_art, p.name AS von
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
- WHERE n.raum_id = ? ORDER BY n.id DESC LIMIT 1`).get(r.id);
+ WHERE n.raum_id = ? AND n.id > ? ORDER BY n.id DESC LIMIT 1`)
+ .get(r.id, r.geloescht_bis);
const ungelesen = db().prepare(`
SELECT COUNT(*) AS n FROM chat_nachrichten
- WHERE raum_id = ? AND id > ? AND person_id <> ?`)
- .get(r.id, r.gelesen_bis, req.person.id).n;
+ WHERE raum_id = ? AND id > ? AND id > ? AND person_id <> ?`)
+ .get(r.id, r.gelesen_bis, r.geloescht_bis, req.person.id).n;
return {
id: r.id,
@@ -208,7 +365,13 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
leitung: !!r.leitung,
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
letzte: letzte ? {
- text: letzte.weg_am ? null : letzte.text.slice(0, 140),
+ /* Ein Anhang ohne Begleittext hätte hier eine leere Zeile
+ hinterlassen. "Foto"/"PDF" ist die Vorschau, die man in
+ jeder Gesprächsliste erwartet. */
+ text: letzte.weg_am ? null
+ : (letzte.text ? letzte.text.slice(0, 140)
+ : (letzte.anhang_art === "bild" ? "Foto"
+ : letzte.anhang_art === "pdf" ? "PDF" : "")),
zurueckgenommen: !!letzte.weg_am,
von: letzte.von, erstellt: letzte.erstellt,
} : null,
@@ -308,17 +471,24 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
`LEFT` und nicht `INNER`: Die meisten Nachrichten sind keine
Antworten, und die duerfen nicht verschwinden. */
+ /* WAS ICH WEGGERÄUMT HABE, KOMMT NICHT ZURÜCK. Die Grenze steht im
+ WHERE und nicht in einem Filter danach -- sonst wäre sie eine
+ Zeile davon entfernt, beim nächsten Umbau vergessen zu werden. */
+ const weg = geloeschtBis(raumId, req.person.id);
const reihen = db().prepare(`
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle,
p.bild AS von_bild,
n.antwort_auf,
- a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von
+ n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
+ n.anhang_breite, n.anhang_hoehe,
+ a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
+ a.anhang_art AS zitat_anhang
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
LEFT JOIN chat_nachrichten a ON a.id = n.antwort_auf AND a.raum_id = n.raum_id
LEFT JOIN personen ap ON ap.id = a.person_id
- WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""}
- ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId]));
+ WHERE n.raum_id = ? AND n.id > ? ${vor ? "AND n.id < ?" : ""}
+ ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, weg, vor] : [raumId, weg]));
/* DER GELESEN-STAND, BEVOR ER GESETZT WIRD.
@@ -357,9 +527,20 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
antwort: n.antwort_auf ? {
id: n.antwort_auf,
von: n.zitat_von || "Gelöscht",
- text: n.zitat_weg ? null : String(n.zitat_text || "").slice(0, 140),
+ text: n.zitat_weg ? null
+ : (String(n.zitat_text || "").slice(0, 140)
+ || (n.zitat_anhang === "bild" ? "Foto" : n.zitat_anhang === "pdf" ? "PDF" : "")),
zurueckgenommen: !!n.zitat_weg,
} : null,
+ /* Der Anhang wird als fertige ADRESSE geliefert, nicht als
+ Dateiname. Sonst setzt jede Stelle im Browser denselben Pfad
+ zusammen -- und eine davon setzt ihn irgendwann anders. */
+ anhang: (!n.weg_am && n.anhang_art) ? {
+ art: n.anhang_art, typ: n.anhang_typ,
+ name: n.anhang_name, groesse: n.anhang_groesse,
+ breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0,
+ weg: `/workspace/api/chat/anhang/${n.id}`,
+ } : null,
})),
});
} catch (fehler) {
@@ -419,6 +600,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
text: zitat.weg_am ? null : String(zitat.text || "").slice(0, 140),
zurueckgenommen: !!zitat.weg_am,
} : null,
+ anhang: null,
};
/* Sofort an alle, die gerade zusehen -- und per Benachrichtigung
@@ -434,6 +616,200 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
}
});
+/* =====================================================================
+ ANHÄNGE: FOTOS UND PDF (09.09.2026)
+
+ Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
+ schicken könnte. pdfs und fotos."
+
+ Wer im Gespräch schreiben darf, darf auch etwas schicken -- auch
+ Creator. Das ist ausdrücklich ANDERS als in der Dateiablage, wo
+ Creator nicht hochladen dürfen: Dort landet etwas in einem Bereich,
+ den mehrere sehen; hier bekommt es genau der, mit dem man ohnehin
+ gerade spricht. Ein Creator, der seinem Scout einen Screenshot
+ schicken will, soll das können.
+
+ DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT. Die
+ Mitgliedschaft wird VOR express.raw geprüft -- sonst wanderten
+ zwölf Megabyte durch die Leitung, nur um danach verworfen zu
+ werden, und jeder Angemeldete könnte damit an jeder Raumnummer
+ Speicher verbrauchen.
+ ===================================================================== */
+
+/** Vor express.raw: Bin ich in diesem Raum? */
+function drinFuerAnhang(req, res, next) {
+ const raumId = Number(req.params.id);
+ if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
+ if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ next();
+}
+
+chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
+ gleicheHerkunft,
+ drinFuerAnhang,
+ express.raw({ type: "*/*", limit: ANHANG_MAX }),
+ (req, res) => {
+ try {
+ const raumId = Number(req.params.id);
+ if (!Buffer.isBuffer(req.body) || !req.body.length) {
+ return res.status(400).json({ fehler: "Keine Datei empfangen." });
+ }
+
+ /* DER TYP KOMMT AUS DEM INHALT, nicht aus dem Namen und nicht aus
+ dem Content-Type. Siehe dateiErkennen(). */
+ const erkannt = dateiErkennen(req.body);
+ if (!erkannt) {
+ return res.status(415).json({
+ fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.",
+ });
+ }
+
+ let begleittext = "";
+ try { begleittext = decodeURIComponent(req.get("x-text") || ""); } catch { begleittext = ""; }
+ begleittext = begleittext.trim().slice(0, TEXT_MAX);
+
+ const name = anhangName(req.get("x-dateiname"), erkannt.endung);
+
+ /* Die Endung auf der Platte kommt aus der ERKENNUNG, nicht aus dem
+ eingeschickten Namen. Damit kann dort nichts liegen, das anders
+ heisst, als es ist. */
+ mkdirSync(ANHANG_ORDNER, { recursive: true });
+ const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
+ writeFileSync(join(ANHANG_ORDNER, aufPlatte), req.body, { flag: "wx" });
+
+ const d = db();
+ const n = jetzt();
+ d.prepare(`INSERT INTO chat_nachrichten
+ (raum_id, person_id, text, erstellt,
+ anhang_datei, anhang_name, anhang_art, anhang_typ,
+ anhang_groesse, anhang_breite, anhang_hoehe)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
+ .run(raumId, req.person.id, begleittext, n,
+ aufPlatte, name, erkannt.art, erkannt.typ,
+ req.body.length, erkannt.breite || null, erkannt.hoehe || null);
+ const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+ d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
+ d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
+ .run(id, raumId, req.person.id);
+
+ const nachricht = {
+ id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
+ rolle: req.person.rolle, text: begleittext, erstellt: n,
+ zurueckgenommen: false, antwort: null,
+ anhang: {
+ art: erkannt.art, typ: erkannt.typ, name,
+ groesse: req.body.length,
+ breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
+ weg: `/workspace/api/chat/anhang/${id}`,
+ },
+ };
+ const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
+ chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
+
+ protokolliere("chat_anhang", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${id} ${erkannt.art} ${Math.round(req.body.length / 1024)} kB`.slice(0, 120),
+ });
+ res.status(201).json({ nachricht });
+ } catch (fehler) {
+ console.error("[chat] Anhang:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Einen Anhang ansehen oder laden.
+ *
+ * Die Kennung ist die der NACHRICHT, nicht die der Datei. Damit
+ * hängt der Zugriff an derselben Prüfung wie der Verlauf: Wer nicht
+ * im Raum ist, bekommt 404 -- und wer das Gespräch weggeräumt hat,
+ * auch. Ein eigener Dateischlüssel wäre eine zweite Tür mit einem
+ * zweiten Schloss. */
+chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+
+ const n = db().prepare(`SELECT id, raum_id, weg_am,
+ anhang_datei, anhang_name, anhang_art, anhang_typ
+ FROM chat_nachrichten WHERE id = ?`).get(id);
+ if (!n || !n.anhang_datei || n.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (!istDrin(n.raum_id, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (n.id <= geloeschtBis(n.raum_id, req.person.id)) {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
+
+ const pfad = join(ANHANG_ORDNER, n.anhang_datei);
+ try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
+
+ /* AUSGELIEFERT WIRD DER TYP AUS DER ERKENNUNG, nie ein
+ eingeschickter. Er steht seit dem Hochladen in der Datenbank und
+ kann seitdem von niemandem mehr beeinflusst werden.
+
+ `nosniff` verbietet dem Browser, sich einen anderen Typ
+ auszudenken. Die enge Richtlinie mit `sandbox` nimmt dem
+ Dokument jede Ausführung -- das ist die Sicherung für den Fall,
+ dass in einem PDF ein Skript steckt (PDF kann das). Bei einem
+ Bild kostet sie nichts. */
+ res.setHeader("Content-Type", n.anhang_typ || "application/octet-stream");
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
+ res.setHeader("Cache-Control", "private, max-age=86400");
+ /* Ansehen ist die Vorgabe -- ein Foto, das man erst herunterladen
+ muss, um es zu sehen, ist kein Foto im Chat. `?laden=1` macht
+ daraus einen Download. */
+ const alsDownload = req.query.laden === "1";
+ res.setHeader("Content-Disposition",
+ `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(n.anhang_name || "anhang")}`);
+ createReadStream(pfad).pipe(res);
+ } catch (fehler) {
+ console.error("[chat] Anhang ausliefern:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* =====================================================================
+ EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR (09.09.2026)
+
+ Filipe: "man muss die chats auch gelöscht bekommen!!!"
+ Auf Nachfrage: nur bei mir.
+
+ Was hier NICHT passiert: Es wird nichts aus der Datenbank entfernt.
+ Das Gegenüber behält seinen Verlauf vollständig -- niemand kann
+ einem anderen die Unterhaltung wegnehmen. Und es wird auch nicht
+ die Mitgliedschaft beendet: Schreibt der andere morgen wieder,
+ kommt das Gespräch mit dem NEUEN zurück, das Alte bleibt weg.
+ Genau so verhält sich jeder Messenger, den Filipe kennt.
+
+ `gelesen_bis` wandert mit. Ohne das käme das Gespräch sofort wieder
+ als "ungelesen" hoch -- weggeräumt und trotzdem eine rote Zahl in
+ der Kopfleiste wäre schlimmer als gar nicht löschen.
+ ===================================================================== */
+chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (req, res) => {
+ try {
+ const raumId = Number(req.params.id);
+ if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
+ if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ const d = db();
+ const bis = d.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?")
+ .get(raumId).m;
+ d.prepare(`UPDATE chat_teilnehmer
+ SET geloescht_bis = ?, geloescht_am = ?,
+ gelesen_bis = CASE WHEN gelesen_bis < ? THEN ? ELSE gelesen_bis END
+ WHERE raum_id = ? AND person_id = ?`)
+ .run(bis, jetzt(), bis, bis, raumId, req.person.id);
+
+ protokolliere("chat_weggeraeumt", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `Raum #${raumId} bis #${bis}`,
+ });
+ res.json({ ok: true, bis });
+ } catch (fehler) {
+ console.error("[chat] Wegräumen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
/* =====================================================================
SUCHE (09.09.2026, Punkt 15)
@@ -483,6 +859,7 @@ chatRouter.get("/workspace/api/chat/suche", (req, res) => {
JOIN chat_raeume r ON r.id = n.raum_id
LEFT JOIN personen p ON p.id = n.person_id
WHERE n.weg_am IS NULL
+ AND n.id > t.geloescht_bis
AND n.text LIKE ? ESCAPE '\\'
ORDER BY n.id DESC LIMIT 60`).all(req.person.id, muster);
@@ -547,7 +924,8 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
- const n = db().prepare("SELECT id, raum_id, person_id FROM chat_nachrichten WHERE id = ?").get(id);
+ const n = db().prepare(
+ "SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id);
if (!n || !istDrin(n.raum_id, req.person.id)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
@@ -558,7 +936,25 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
}
- db().prepare("UPDATE chat_nachrichten SET text = '', weg_am = ? WHERE id = ?").run(jetzt(), id);
+ /* EIN ANHANG WIRD WIRKLICH GELÖSCHT, nicht nur ausgeblendet.
+
+ Beim Text reicht das Leeren -- er steht nur in der Zeile. Eine
+ Datei liegt daneben auf der Platte und hätte ihre eigene Adresse
+ behalten. "Zurückgenommen" hiesse dann: im Verlauf weg, über den
+ Link weiter da. Das wäre keine Rücknahme, sondern der Anschein
+ einer. Erst die Zeile leeren, dann die Datei -- scheitert das
+ Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */
+ const dateiWeg = n.anhang_datei;
+ db().prepare(`UPDATE chat_nachrichten
+ SET text = '', weg_am = ?,
+ anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL,
+ anhang_typ = NULL, anhang_groesse = NULL,
+ anhang_breite = NULL, anhang_hoehe = NULL
+ WHERE id = ?`).run(jetzt(), id);
+ if (dateiWeg) {
+ try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
+ catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
+ }
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
@@ -626,7 +1022,9 @@ async function meldeNeueNachricht(personId, raumId, nachricht) {
/* Der Anfang der Nachricht steht in der Meldung -- eine
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
Öffnen, um zu erfahren, ob es wichtig war. */
- text: String(nachricht.text || "").slice(0, 120),
+ text: String(nachricht.text || "").slice(0, 120)
+ || (nachricht.anhang?.art === "bild" ? "Foto"
+ : nachricht.anhang?.art === "pdf" ? "PDF" : ""),
ziel: `/workspace/chat.html?raum=${raumId}`,
});
} catch (fehler) {
@@ -679,7 +1077,8 @@ chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
const z = db().prepare(`
SELECT COUNT(*) AS n FROM chat_nachrichten m
JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
- WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.person_id <> ?`)
+ WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.id > t.geloescht_bis
+ AND m.person_id <> ?`)
.get(req.person.id, req.person.id);
res.json({ ungelesen: z?.n || 0 });
} catch (fehler) {
@@ -688,6 +1087,28 @@ chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
}
});
+/* ---------- Fehler aus express.raw ---------------------------------------
+ Ist die Datei zu gross, bricht express.raw ab, BEVOR der eigene Code
+ laeuft -- das try/catch dort greift also nicht, und der Fehler landet
+ im allgemeinen Behandler als HTTP 500. Fuer den Absender saehe das
+ aus wie ein kaputter Server statt wie "Datei zu gross"; er wuerde es
+ wieder und wieder versuchen. Derselbe Behandler steht aus demselben
+ Grund in workspace-dateien.js.
+
+ Er muss ZULETZT stehen: Express erkennt ihn an den vier Parametern
+ und ruft ihn nur fuer Fehler aus den Routen davor auf. */
+// eslint-disable-next-line no-unused-vars
+chatRouter.use((fehler, req, res, next) => {
+ if (!req.path.startsWith("/workspace/api/chat")) return next(fehler);
+ if (fehler?.type === "entity.too.large" || fehler?.status === 413) {
+ return res.status(413).json({
+ fehler: `Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1024 / 1024)} MB je Datei.`,
+ });
+ }
+ console.error("[chat] Unerwartet:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+});
+
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
export function siehtZu(personId) {
return (zuschauer.get(Number(personId)) || []).size > 0;
diff --git a/server/workspace.js b/server/workspace.js
index 062faf7c..8506064d 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -395,6 +395,50 @@ function umstellungen(d) {
ohnehin mit. Ein zweiter Schluessel brauchte nur eine zweite
Gelegenheit fuer eine Sperre. */
["chat_nachrichten", "antwort_auf", "INTEGER"],
+
+ /* GESPRAECH LOESCHEN -- NUR BEI MIR (09.09.2026).
+
+ Filipe: "man muss die chats auch geloescht bekommen!!!"
+ Auf Nachfrage entschieden: nur bei mir, nicht bei beiden. Das
+ Gespraech verschwindet aus MEINER Liste, das Gegenueber behaelt
+ seinen Verlauf. Sonst koennte jeder jedem die Unterhaltung
+ vernichten -- und in einem Gespraech, in dem Absprachen stehen,
+ waere das ein Loch, das niemand mehr fuellen kann.
+
+ ZWEI SPALTEN, NICHT EINE, und der Grund ist ein Randfall:
+ `geloescht_bis` haelt fest, bis zu welcher Nachricht ich es nicht
+ mehr sehen will. Bei einem Gespraech OHNE jede Nachricht waere
+ das 0 -- und 0 heisst auch "nie geloescht". `geloescht_am`
+ unterscheidet die beiden Faelle. Kommt spaeter etwas Neues
+ (id > geloescht_bis), taucht das Gespraech von selbst wieder auf;
+ das Alte bleibt weg. */
+ ["chat_teilnehmer", "geloescht_bis", "INTEGER NOT NULL DEFAULT 0"],
+ ["chat_teilnehmer", "geloescht_am", "TEXT"],
+
+ /* ANHAENGE: FOTOS UND PDF (09.09.2026).
+
+ Filipe: "am besten waere es auch wenn man da auch im chat pdfs
+ schicken koennte. pdfs und fotos."
+
+ Je Nachricht hoechstens ein Anhang -- deshalb Spalten und keine
+ eigene Tabelle. Eine Tabelle waere die richtige Wahl fuer
+ "mehrere je Nachricht"; die gibt es hier nicht, und eine leere
+ Verbundabfrage bei jedem Verlauf waere Aufwand fuer einen Fall,
+ den es nicht gibt.
+
+ `anhang_datei` ist der ERZEUGTE Name auf der Platte, niemals der
+ eingeschickte. `anhang_art` ist "bild" oder "pdf" und wird aus
+ den ersten Bytes der Datei bestimmt, nicht aus dem Namen und
+ nicht aus dem Content-Type -- beide kommen vom Absender.
+ Breite und Hoehe stehen dabei, damit der Verlauf beim Laden
+ eines Bildes nicht springt. */
+ ["chat_nachrichten", "anhang_datei", "TEXT"],
+ ["chat_nachrichten", "anhang_name", "TEXT"],
+ ["chat_nachrichten", "anhang_art", "TEXT"],
+ ["chat_nachrichten", "anhang_typ", "TEXT"],
+ ["chat_nachrichten", "anhang_groesse", "INTEGER"],
+ ["chat_nachrichten", "anhang_breite", "INTEGER"],
+ ["chat_nachrichten", "anhang_hoehe", "INTEGER"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
diff --git a/tools/sicherung-holen.sh b/tools/sicherung-holen.sh
index 358f441e..8a3c2377 100644
--- a/tools/sicherung-holen.sh
+++ b/tools/sicherung-holen.sh
@@ -53,11 +53,18 @@ scp -q dogfather-server:/home/dogiweb/workspace-daten/sicherungen/*.db "$ZIEL/"
# achtzehn Seiten rot -- der Steckbrief, mit einem 404 fuer ein Bild.
# Genau dafuer gibt es sie.
#
+# CHAT-ANHAENGE GEHOEREN DAZU (ergaenzt 09.09.2026).
+#
+# Seit diesem Tag koennen im Chat Fotos und PDFs geschickt werden
+# (workspace-chat.js: chat-anhaenge/). Der Ordner wurde hier im selben
+# Zug eingetragen -- nicht spaeter, wenn es auffaellt: Genau so ist die
+# Luecke bei den Profilbildern entstanden.
+#
# Wer hier einen Ordner ergaenzt, traegt ihn auch in ORDNER unten ein;
# die Probe vergleicht die Liste gegen die Ordner, die der Server
# wirklich benutzt, und schlaegt bei einem vergessenen an.
-echo "Hole hochgeladene Dateien, Bilder und die Bibliothek ..."
-ORDNER="dateien wissen profilbilder"
+echo "Hole hochgeladene Dateien, Bilder, Anhaenge und die Bibliothek ..."
+ORDNER="dateien wissen profilbilder chat-anhaenge"
for o in $ORDNER; do
mkdir -p "$ZIEL/$o"
scp -qr "dogfather-server:/home/dogiweb/workspace-daten/$o/." "$ZIEL/$o/" 2>/dev/null || true
diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css
index a799d4bb..095cf8d4 100644
--- a/workspace/assets/css/chat.css
+++ b/workspace/assets/css/chat.css
@@ -927,3 +927,152 @@
@media (forced-colors: active) {
.emoji-tafel, .chat__zum-neuesten { border: 1px solid CanvasText; background: Canvas; }
}
+
+/* =====================================================================
+ ANHÄNGE, ABLAGE 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."
+ ===================================================================== */
+
+/* Der Wegräum-Knopf sitzt im Kopf, rechts. Er ist absichtlich LEISE:
+ Löschen ist die seltene Handlung, und ein roter Knopf, der jeden Tag
+ danebensteht, wird irgendwann aus Versehen getroffen. Rot wird er
+ erst beim Darüberfahren -- dann, wenn jemand ihn wirklich meint. */
+.chat__weg {
+ flex: none; margin-left: auto;
+ width: 34px; height: 34px;
+ display: grid; place-items: center;
+ border: 1px solid var(--rand); border-radius: 10px;
+ background: rgba(255, 255, 255, .03);
+ color: var(--text-leise); cursor: pointer;
+ transition: color var(--tempo), border-color var(--tempo);
+}
+.chat__weg svg {
+ width: 17px; height: 17px;
+ fill: none; stroke: currentColor; stroke-width: 1.7;
+ stroke-linecap: round; stroke-linejoin: round;
+}
+.chat__weg:hover { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
+.chat__weg:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+/* Steht der Knopf da, braucht der Text daneben seinen eigenen Rand --
+ sonst hat .chat__kopf-text keine Grenze und schiebt ihn hinaus. */
+.chat__kopf--verlauf .chat__kopf-text { min-width: 0; flex: 1; }
+
+/* ---- Die Büroklammer ------------------------------------------------ */
+.chat__anhang-feld {
+ /* Nicht display:none -- ein verstecktes Feld lässt sich in manchen
+ Browsern nicht mehr über click() öffnen. Es steht da, nur ohne
+ Fläche. */
+ position: absolute; width: 1px; height: 1px;
+ padding: 0; margin: -1px; overflow: hidden;
+ clip-path: inset(50%); border: 0;
+}
+.chat__anhang {
+ flex: none;
+ width: 40px; height: 40px;
+ display: flex; align-items: center; justify-content: center;
+ appearance: none; border: 1px solid rgba(150, 186, 220, .18);
+ border-radius: 11px;
+ background: rgba(255, 255, 255, .04);
+ color: var(--text-leise); cursor: pointer;
+ transition: color var(--tempo), border-color var(--tempo);
+}
+.chat__anhang svg {
+ width: 19px; height: 19px;
+ fill: none; stroke: currentColor; stroke-width: 1.7;
+ stroke-linecap: round; stroke-linejoin: round;
+}
+.chat__anhang:hover { color: var(--text); border-color: rgba(150, 186, 220, .34); }
+.chat__anhang:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+.chat__anhang:disabled { opacity: .45; cursor: progress; }
+
+/* ---- Ein Foto in der Blase ------------------------------------------ */
+/* Die Höhe ist begrenzt, die Breite folgt dem Bild. Ein Hochformat
+ nähme sonst den halben Verlauf ein und schöbe alles darunter aus dem
+ Bild -- man scrollt dann durch ein Foto statt durch ein Gespräch. */
+.chat-bild {
+ display: block;
+ width: 100%; /* die Hoechstbreite setzt das Skript */
+ margin: 0 0 6px;
+ border-radius: 12px;
+ overflow: hidden;
+ line-height: 0;
+ background: rgba(255, 255, 255, .04);
+}
+/* Die Groesse macht der Kasten (aspect-ratio + max-width, siehe
+ chat.js). Das Bild fuellt ihn nur aus. Stuende die Groesse hier am
+ Bild, waere sie erst bekannt, wenn das Bild da ist -- und genau das
+ war der Fehler, der den Verlauf um 323 px verschoben hat. */
+.chat-bild img {
+ display: block;
+ width: 100%; height: 100%;
+ object-fit: contain;
+}
+/* Ohne bekannte Masse gibt es kein Verhaeltnis, an dem der Kasten
+ haengen koennte. Dann greift der alte Weg -- und der Zuhoerer auf
+ `load` in chat.js haelt den Bildlauf unten. */
+.chat-bild:not([style*="aspect-ratio"]) img { height: auto; max-height: 340px; }
+.chat-bild:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+.chat-bild[data-kaputt="ja"] {
+ line-height: 1.5; padding: 12px 14px;
+ font-size: .84rem; color: var(--text-leise);
+}
+
+/* ---- Ein PDF als Karte ---------------------------------------------- */
+.chat-datei {
+ display: flex; align-items: center; gap: 11px;
+ margin: 0 0 6px; padding: 10px 12px;
+ border: 1px solid rgba(150, 186, 220, .2);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, .04);
+ color: inherit; text-decoration: none;
+ transition: border-color var(--tempo), background var(--tempo);
+}
+.chat-datei:hover {
+ border-color: rgba(150, 186, 220, .38);
+ background: rgba(255, 255, 255, .07);
+}
+.chat-datei:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+.chat-datei__zeichen {
+ flex: none;
+ width: 38px; height: 38px; border-radius: 10px;
+ display: grid; place-items: center;
+ font-size: .72rem; font-weight: 800; letter-spacing: .04em;
+ color: #ff9aa2;
+ background: rgba(255, 154, 162, .12);
+ border: 1px solid rgba(255, 154, 162, .3);
+}
+.chat-datei__mitte { min-width: 0; display: grid; }
+.chat-datei__name {
+ font-size: .86rem; font-weight: 600; color: var(--text);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.chat-datei__mass { font-size: .74rem; color: var(--text-leise); }
+
+/* ---- Die Ablagefläche beim Hineinziehen ------------------------------ */
+.chat__ablage {
+ position: absolute; inset: 8px;
+ z-index: 6;
+ display: grid; place-items: center;
+ border: 2px dashed color-mix(in srgb, var(--akzent) 55%, transparent);
+ border-radius: 16px;
+ background: rgba(8, 12, 20, .82);
+ color: var(--text);
+ font-size: .95rem; font-weight: 650;
+ pointer-events: none; /* sonst frisst sie das drop-Ereignis */
+}
+
+/* ---- Zwischenstand statt Störung ------------------------------------ */
+/* "wird geschickt …" ist keine Fehlermeldung. Rot ist hier das
+ Einzige, was Aufmerksamkeit verlangen darf -- wer es für
+ Zwischenstände mitbenutzt, macht es wertlos. */
+.fehler[data-art="lauf"] { color: var(--text-leise); }
+
+@media (prefers-reduced-motion: reduce) {
+ .chat__weg, .chat__anhang, .chat-datei { transition: none; }
+}
+@media (forced-colors: active) {
+ .chat-datei, .chat__ablage { border: 1px solid CanvasText; }
+}
diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js
index c6605739..29d4ed61 100644
--- a/workspace/assets/js/chat.js
+++ b/workspace/assets/js/chat.js
@@ -87,7 +87,16 @@
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
- const melde = (t) => { $('fehler').textContent = t || ''; };
+ /* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und
+ einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu
+ zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt --
+ und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf.
+ Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */
+ const melde = (t, art = 'fehler') => {
+ const z = $('fehler');
+ z.textContent = t || '';
+ z.dataset.art = t ? art : 'fehler';
+ };
/* ---------- Die Gesprächsliste ------------------------------------------- */
@@ -277,6 +286,8 @@
$('schreiben').hidden = false;
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
+ /* Erst jetzt gibt es etwas zum Wegräumen. */
+ $('gespraech-weg').hidden = false;
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
das Letzte sehen, nicht das Erste. */
@@ -411,10 +422,17 @@
blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg',
'Nachricht zurückgenommen'));
} else {
+ /* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter
+ das Bild, nicht darüber -- man sieht zuerst, worum es geht. */
+ if (n.anhang) blase.append(anhangBauen(n.anhang));
/* textContent, nie innerHTML: Eine Nachricht darf keine
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
- (white-space: pre-wrap). */
- blase.append(mitLinks(n.text));
+ (white-space: pre-wrap).
+
+ NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
+ einem Foto besteht, hätte sonst einen leeren Absatz darunter
+ -- und der nimmt Platz, den niemand erklären kann. */
+ if (n.text) blase.append(mitLinks(n.text));
}
const fuss = el('div', 'chat-nachricht__fuss');
@@ -497,6 +515,111 @@
: 'Zum neuesten';
}
+ /* ---------- Anhänge: Fotos und PDF (09.09.2026) -------------------------
+ Filipe: "pdfs und fotos".
+
+ Ein Foto wird GEZEIGT, ein PDF wird ANGEBOTEN. Das ist kein
+ Schönheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
+ ein PDF muss man ohnehin öffnen -- eine Vorschau davon wäre ein
+ grauer Kasten, der so tut, als könnte man etwas lesen.
+
+ `width`/`height` stehen am Bild, obwohl das CSS die Größe macht.
+ Sie sind das Seitenverhältnis: Damit hält der Browser den Platz
+ frei, BEVOR das Bild da ist. Ohne sie springt der ganze Verlauf,
+ sobald ein Bild fertig geladen ist -- und zwar genau dann, wenn
+ man gerade anfängt zu lesen. */
+ function grosse(bytes) {
+ const b = Number(bytes) || 0;
+ if (b < 1024) return `${b} B`;
+ if (b < 1024 * 1024) return `${Math.round(b / 1024)} kB`;
+ return `${(b / 1048576).toFixed(1)} MB`;
+ }
+
+ function anhangBauen(a) {
+ if (a.art === 'bild') {
+ const kasten = el('a', 'chat-bild');
+ kasten.href = a.weg;
+ kasten.target = '_blank';
+ kasten.rel = 'noopener noreferrer';
+ kasten.title = `${a.name} · ${grosse(a.groesse)} – in neuem Tab öffnen`;
+
+ /* DER PLATZ WIRD AM KASTEN RESERVIERT, NICHT AM BILD.
+ ------------------------------------------------------------------
+ Erster Anlauf: width- und height-Attribut ans , wie man es
+ überall liest. Gemessen war der Verlauf danach 323 px zu hoch --
+ genau die Höhe des Fotos.
+
+ Der Grund: Die Attribute geben dem Browser nur das SEITEN-
+ VERHÄLTNIS. Damit daraus eine Höhe wird, muss die Breite
+ feststehen. Bei `width: auto` und einem noch nicht geladenen
+ Bild ist sie 0 -- das Verhältnis hat nichts, womit es rechnen
+ könnte, der Kasten ist 0 px hoch, und wenn das Foto ankommt,
+ schiebt es alles darunter weg. Man springt beim Öffnen ans
+ Ende und steht eine Sekunde später mittendrin.
+
+ Deshalb bekommt der KASTEN das Verhältnis und eine Höchstbreite
+ (die Breite, die das Bild nach dem Deckel von 340 px Höhe
+ hätte). Damit steht die Breite von Anfang an fest, die Höhe
+ folgt daraus, und es wackelt nichts.
+
+ Gefunden hat das keine Überlegung, sondern eine Zahl: In der
+ Prüfung war der Rest 0 px -- dort lag das Bild schon im
+ Zwischenspeicher. Erst die Messung im Aufnahmeskript, beim
+ ERSTEN Laden, zeigte die 323. */
+ if (a.breite > 0 && a.hoehe > 0) {
+ const DECKEL = 340;
+ const breit = a.hoehe > DECKEL ? Math.round(a.breite * DECKEL / a.hoehe) : a.breite;
+ kasten.style.aspectRatio = `${a.breite} / ${a.hoehe}`;
+ kasten.style.maxWidth = `${breit}px`;
+ }
+
+ const bild = document.createElement('img');
+ bild.src = a.weg;
+ bild.alt = a.name || 'Foto';
+ bild.decoding = 'async';
+ if (a.breite > 0 && a.hoehe > 0) { bild.width = a.breite; bild.height = a.hoehe; }
+ /* KEIN loading="lazy". In einem Verlauf, der beim Öffnen ganz nach
+ unten springt, sind die sichtbaren Bilder die zuletzt
+ geschriebenen -- lazy verzögert genau die, die man sehen will,
+ und spart nichts, weil ein Chat selten hundert Fotos hat.
+
+ WENN EIN MASS FEHLT (seltene WebP-Formen), ist oben nichts
+ reserviert. Dann ist dieser Zuhörer die Sicherung: War man
+ unten, bleibt man unten. Er wird beim Bauen festgehalten, nicht
+ beim Laden gelesen -- bis dahin hat ein Bildlauf-Ereignis den
+ Zustand längst umgeworfen. */
+ const warUnten = unten;
+ bild.addEventListener('load', () => {
+ if (!warUnten) return;
+ const v = $('verlauf');
+ v.scrollTop = v.scrollHeight;
+ unten = true; neuUnten = 0;
+ springKnopfZeigen();
+ });
+ /* Ein Bild, das nicht lädt, hinterlässt sonst ein kaputtes
+ Symbol ohne Erklärung. */
+ bild.addEventListener('error', () => {
+ kasten.dataset.kaputt = 'ja';
+ kasten.textContent = 'Foto lässt sich gerade nicht laden';
+ });
+ kasten.append(bild);
+ return kasten;
+ }
+
+ const karte = el('a', 'chat-datei');
+ karte.href = a.weg;
+ karte.target = '_blank';
+ karte.rel = 'noopener noreferrer';
+ const zeichen = el('span', 'chat-datei__zeichen', 'PDF');
+ zeichen.setAttribute('aria-hidden', 'true');
+ const mitte = el('span', 'chat-datei__mitte');
+ mitte.append(el('span', 'chat-datei__name', a.name || 'Dokument'));
+ mitte.append(el('span', 'chat-datei__mass', `${grosse(a.groesse)} · öffnen`));
+ karte.append(zeichen, mitte);
+ karte.title = `${a.name} · ${grosse(a.groesse)}`;
+ return karte;
+ }
+
/* ---------- Links in Nachrichten (09.09.2026) ---------------------------
Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als
ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender
@@ -1064,6 +1187,182 @@
history.replaceState(null, '', 'chat.html');
});
+ /* =======================================================================
+ ANHÄNGE SCHICKEN (09.09.2026)
+
+ Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
+ schicken könnte. pdfs und fotos."
+
+ DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten:
+ die Büroklammer (immer da, auch am Handy), Hineinziehen (am
+ Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für
+ einen Screenshot -- der liegt in der Zwischenablage und nirgendwo
+ als Datei).
+
+ Alle drei landen in derselben Funktion. Drei Fassungen desselben
+ Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung
+ vergisst.
+
+ GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal
+ tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage,
+ das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit,
+ die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */
+ const ANHANG_MAX = 12 * 1024 * 1024;
+ const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']);
+ let schicktGerade = false;
+
+ async function anhangSchicken(datei) {
+ if (!offen || !datei) return;
+ if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; }
+ if (!ANHANG_TYPEN.has(datei.type)) {
+ melde('Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.');
+ return;
+ }
+ if (datei.size > ANHANG_MAX) {
+ melde(`Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1048576)} MB je Datei.`);
+ return;
+ }
+
+ schicktGerade = true;
+ $('anhang-auf').disabled = true;
+ melde(`„${datei.name}“ wird geschickt …`, 'lauf');
+ /* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
+ das Bild anhängt, will beides in einer Nachricht. */
+ const begleittext = $('text').value.trim();
+ try {
+ const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': datei.type,
+ /* Kopfzeilen sind streng genommen nur ASCII -- ohne die
+ Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
+ 'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
+ 'X-Text': encodeURIComponent(begleittext),
+ },
+ body: datei,
+ });
+ if (!a.ok) {
+ let grund = 'Ging nicht.';
+ try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ }
+ melde(grund);
+ return;
+ }
+ const { nachricht } = await a.json();
+ /* Nur eintragen, wenn sie nicht schon über den Strom kam --
+ sonst stünde dasselbe Foto zweimal da. */
+ if (!nachrichten.some((x) => x.id === nachricht.id)) {
+ nachrichten.push({ ...nachricht, selbst: true });
+ }
+ $('text').value = '';
+ entwurfWeg(offen.id);
+ hoeheAnpassen();
+ zaehlerZeigen();
+ unten = true; neuUnten = 0;
+ verlaufZeichnen();
+ melde('');
+ raeumeLaden();
+ } catch {
+ melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
+ } finally {
+ schicktGerade = false;
+ $('anhang-auf').disabled = false;
+ }
+ }
+
+ $('anhang-auf').addEventListener('click', () => $('anhang-feld').click());
+ $('anhang-feld').addEventListener('change', () => {
+ const datei = $('anhang-feld').files?.[0];
+ /* Zurücksetzen, BEVOR geschickt wird: Sonst löst dieselbe Datei
+ beim zweiten Mal kein `change` aus, und man wundert sich, warum
+ nichts passiert. */
+ $('anhang-feld').value = '';
+ if (datei) void anhangSchicken(datei);
+ });
+
+ /* ---- Hineinziehen ------------------------------------------------------
+ `dragover` MUSS abgefangen werden, sonst öffnet der Browser die
+ Datei einfach als Seite -- der Verlauf wäre weg und das Bild
+ bildschirmfüllend da. */
+ {
+ const flaeche = document.querySelector('.chat__spalte--verlauf');
+ let tiefe = 0; /* dragenter/-leave feuert je Kind */
+ const zeigen = (ja) => {
+ const k = $('ablage');
+ if (k) k.hidden = !ja || !offen;
+ };
+ flaeche.addEventListener('dragenter', (e) => {
+ if (!e.dataTransfer?.types?.includes('Files')) return;
+ tiefe++; zeigen(true);
+ });
+ flaeche.addEventListener('dragover', (e) => {
+ if (!e.dataTransfer?.types?.includes('Files')) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ });
+ flaeche.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zeigen(false); } });
+ flaeche.addEventListener('drop', (e) => {
+ if (!e.dataTransfer?.files?.length) return;
+ e.preventDefault();
+ tiefe = 0; zeigen(false);
+ if (!offen) { melde('Zuerst ein Gespräch öffnen.'); return; }
+ void anhangSchicken(e.dataTransfer.files[0]);
+ });
+ /* Fällt etwas neben dem Fenster, darf der Browser es trotzdem nicht
+ öffnen. */
+ window.addEventListener('dragover', (e) => e.preventDefault());
+ window.addEventListener('drop', (e) => e.preventDefault());
+ }
+
+ /* ---- Einfügen (Strg+V) --------------------------------------------------
+ Der Weg für Screenshots. Ein kopiertes Bild hat keinen Namen --
+ deshalb bekommt es hier einen mit Datum, sonst hiesse jede
+ Bildschirmaufnahme "image.png". */
+ $('text').addEventListener('paste', (e) => {
+ const stuecke = [...(e.clipboardData?.items || [])];
+ const bild = stuecke.find((s) => s.kind === 'file' && ANHANG_TYPEN.has(s.type));
+ if (!bild) return; /* normaler Text: nichts tun */
+ const datei = bild.getAsFile();
+ if (!datei) return;
+ e.preventDefault();
+ const endung = (datei.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
+ const stempel = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-');
+ void anhangSchicken(new File([datei], datei.name && datei.name !== 'image.png'
+ ? datei.name : `bildschirmfoto-${stempel}.${endung}`, { type: datei.type }));
+ });
+
+ /* =======================================================================
+ GESPRÄCH WEGRÄUMEN — NUR BEI MIR
+
+ Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich
+ löschen?" liesse offen, ob der andere seinen Verlauf verliert --
+ und das ist genau die Frage, die man in dem Moment hat. */
+ $('gespraech-weg').addEventListener('click', async () => {
+ if (!offen) return;
+ const wer = offen.name;
+ if (!window.confirm(
+ `Gespräch mit ${wer} wegräumen?\n\n`
+ + 'Es verschwindet nur bei dir. Der Verlauf beim Gegenüber bleibt '
+ + 'vollständig.\n\nSchreibt euch jemand wieder, kommt das Gespräch '
+ + 'mit dem Neuen zurück – das Alte bleibt weg.')) return;
+ try {
+ const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' });
+ if (!a.ok) { melde('Ging nicht.'); return; }
+ entwurfWeg(offen.id);
+ offen = null;
+ nachrichten = [];
+ $('schreiben').hidden = true;
+ $('gespraech-weg').hidden = true;
+ $('verlauf-titel').textContent = 'Wähle ein Gespräch';
+ $('verlauf-unter').textContent = '';
+ $('verlauf-leer').hidden = false;
+ $('chat').dataset.ansicht = 'liste';
+ verlaufZeichnen();
+ history.replaceState(null, '', 'chat.html');
+ await raeumeLaden();
+ melde('');
+ } catch { melde('Keine Verbindung.'); }
+ });
+
/* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden,
dass jetzt wirklich jemand hinsieht. */
document.addEventListener('visibilitychange', () => {
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index ede67924..a4d7e23c 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -14,10 +14,10 @@
-
-
-
-
+
+
+
+
@@ -231,10 +231,10 @@
-
-
-
-
-
+
+
+
+
+