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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index c9f0ea38..de193f3c 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -140,10 +140,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index ba402b88..86fd88d3 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -149,14 +149,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index b59d5692..8ed22162 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -82,11 +82,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index efae6096..6a2cc803 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -14,17 +14,17 @@ - - + + - - - + + + @@ -112,6 +112,18 @@

Wähle ein Gespräch

+ + + +

Links ein Gespräch auswählen – oder mit Neu eines anfangen. @@ -160,6 +179,19 @@ nicht, sonst käme man nie zu einer zweiten Zeile. Die Tastenbelegung steht jetzt im title -- sie ist ein Hinweis für Wiederkehrer, keine Aufforderung. --> + + + @@ -215,10 +247,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index a2b9e14e..91ee3229 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -172,12 +172,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index eabf755f..6cf8a930 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -101,10 +101,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 965c3a53..d57b1f7c 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -32,7 +32,7 @@ - + @@ -401,6 +401,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 2a5cc6bd..0a298878 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -340,11 +340,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 94d3ecc0..f1192056 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -217,10 +217,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 01898bfa..b9fe60aa 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -335,10 +335,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 0b5d8c9d..be95ed78 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -207,11 +207,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 4da829c0..ec97ceb6 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -99,11 +99,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index a535941f..f24e4fbb 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -132,10 +132,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 78e665e5..df112033 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -14,12 +14,12 @@ - - + + - - + + @@ -667,10 +667,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 06e16ef6..3b589396 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -78,11 +78,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 9a44a3ab..820e5643 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -119,10 +119,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 0f6e68f4..8222c270 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -14,13 +14,13 @@ - - + + - - - + + + @@ -142,11 +142,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 80c5c66b..200bbeba 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -14,11 +14,11 @@ - - - - - + + + + + @@ -183,10 +183,10 @@ - - - - - + + + + +