Chat: Fotos und PDFs schicken, Gespraeche wegraeumen

Filipe, mit Bildschirmfoto: "man muss die chats auch geloescht
bekommen!!! am besten waere es auch wenn man da auch im chat pdfs
schicken koennte. pdfs und fotos."

Zwei Entscheidungen vorher abgestimmt, weil sie den Bau bestimmen:
Geloescht wird NUR BEI MIR. Schicken duerfen ALLE im Gespraech, auch
Creator -- anders als in der Dateiablage, wo etwas in einem Bereich
landet, den mehrere sehen. Hier bekommt es genau der, mit dem man
ohnehin gerade spricht.

--- ANHAENGE ---

Drei Wege zur selben Sache, weil Leute unterschiedlich arbeiten: die
Bueroklammer, Hineinziehen und Einfuegen mit Strg+V (der Weg fuer einen
Screenshot -- der liegt in der Zwischenablage und nirgends als Datei).
Alle drei laufen durch dieselbe Funktion; drei Fassungen waeren drei
Gelegenheiten, dass eine die Pruefung vergisst.

Ein Foto wird GEZEIGT, ein PDF wird als Karte ANGEBOTEN. Das ist kein
Schoenheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
ein PDF muss man ohnehin oeffnen -- eine Vorschau davon waere ein
grauer Kasten, der so tut, als koennte man etwas lesen.

DIE SICHERHEIT STEHT IN dateiErkennen(). Dateiname und Content-Type
kommen vom Absender und sind frei erfunden; der Typ wird deshalb aus
den ersten Bytes bestimmt (PNG/GIF/WebP/JPEG/PDF, mit Breite und Hoehe
im selben Durchgang). SVG steht absichtlich NICHT in der Liste -- das
ist XML, das Skripte enthalten darf, ein als Bild getarntes Programm.
Ausgeliefert wird spaeter genau der ERKANNTE Typ, nie der eingeschickte,
dazu nosniff und "default-src 'none'; sandbox".

Auf der Platte bekommt jede Datei einen erzeugten Zufallsnamen, in
einem Ordner ausserhalb des Repos. Zuruecknehmen loescht die Datei
wirklich -- sonst waere sie im Verlauf weg und ueber die Adresse noch
da, also nur der Anschein einer Ruecknahme.

--- WEGRAEUMEN ---

Zwei Spalten in chat_teilnehmer statt einer, wegen eines Randfalls: Bei
einem Gespraech ohne jede Nachricht waere die Grenze 0 -- und 0 heisst
sonst "nie geloescht". geloescht_am unterscheidet die beiden.

Die Grenze wirkt in der Liste, im Verlauf, in der Vorschau, in der
SUCHE, bei den Anhaengen und in der Ungelesen-Zahl. Die Suche ist die
Stelle, an der so etwas typischerweise durchrutscht: aus der Liste weg,
ueber die Suche noch da.

--- DER FUND: 323 PIXEL ---

Ein Bild ist spaeter fertig als der Rest der Seite. Beim Oeffnen springt
der Verlauf ans Ende, dann kommt das Foto an und schiebt alles darunter
weg -- man steht ploetzlich mittendrin, ohne etwas angeklickt zu haben.

Das width/height-Attribut am <img>, wie man es ueberall liest, hilft
hier NICHT: Es gibt nur das Seitenverhaeltnis. Damit daraus eine Hoehe
wird, muss die Breite feststehen -- bei "width: auto" und einem nicht
geladenen Bild ist sie 0. Der Platz wird jetzt am KASTEN reserviert
(aspect-ratio + max-width aus den gemessenen Massen).

Und die Pruefung dazu hatte denselben Fehler wie das Auge: Sie mass in
einem Fenster, in dem das Bild laengst im Zwischenspeicher lag, und
meldete tadellose 0 px. Sie misst jetzt in einem FRISCHEN Browser --
der Fall, um den es geht, ist der erste.

--- SICHERUNG ---

chat-anhaenge/ ist im selben Zug in tools/sicherung-holen.sh
eingetragen, nicht spaeter, wenn es auffaellt: Genau so ist die Luecke
bei den Profilbildern entstanden. tools/wiederherstellung-proben.mjs
bestaetigt: "der Server benutzt 4 Datenordner", keiner fehlt.

--- Pruefung ---

server/pruef-chat-anhaenge.mjs, neu, 60 Pruefungen, alle gruen.
Abschnitt 3 versucht ausdruecklich, hereinzukommen: HTML als bild.png,
SVG als foto.png, SVG als svg, eine Textdatei, ein PNG-Kopf ohne Inhalt
-- alle fuenf mit 415 abgewiesen, ein echtes JPEG danach mit 201
angenommen (sonst bewiese der Block nur, dass gar nichts durchkommt).
13 MB geben 413 mit lesbarem Text, nicht 500.

Konsolenfehler werden nach ZEITPUNKT getrennt, nicht nach Statusnummer:
Was waehrend eines absichtlichen Versuchs entsteht, ist gewollt. Nach
Nummer zu filtern waere bequem und wuerde ab morgen echte 404 mit
verschlucken.

Ausserdem gruen: pruef-chat, pruef-chat-optik, pruef-chat-ausbau,
pruef-css-klassen. Angesehen bei 1440 px und bei 390 px.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-09 12:51:54 +02:00
co-authored by Claude Opus 5
parent e28724564a
commit 14e9bbf7a5
25 changed files with 1795 additions and 212 deletions
+631
View File
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj\n"
+ "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
+ "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
+ "trailer<</Root 1 0 R>>\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
<img> 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: '<html><body><script>alert(document.cookie)</script></body></html>' },
"image/png", "harmlos.png"],
["SVG, das sich png nennt",
{ art: "roh", text: '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>' },
"image/png", "logo.png"],
["SVG als svg",
{ art: "roh", text: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' },
"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);
+437 -16
View File
@@ -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;
+44
View File
@@ -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);