/* ===================================================================== workspace-chat.js — Gespräche zwischen den Leuten im Workspace. Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit ihren manager nachrichten austauschen können, wie erinnerungen, fragen und noch vieles mehr, wo jeder immer mit denen schreiben kann wie verbindung auch läuft, und jeder kann dann eine nachricht reinschreiben, die andere person bekommt eine benachrichtigung und kann dann antworten."* Auf Nachfrage entschieden: Zweier-Gespräche UND Gruppen; Manager und Scouts dürfen sich auch ohne gemeinsamen Creator schreiben. --------------------------------------------------------------------- DIE DREI REGELN, die alles andere tragen 1. WER MIT WEM steht an EINER Stelle: schreibbareIds() in workspace.js. Nicht hier, nicht in der Oberfläche. Jede Route fragt dieselbe Funktion -- eine zweite Regel wäre die sichere Zusage, dass beide irgendwann auseinanderlaufen. 2. WER IM RAUM IST, entscheidet über den Verlauf. Die Mitgliedschaft wird bei JEDEM Zugriff geprüft, nicht nur beim Öffnen. Sonst könnte man eine Raumnummer erraten und mitlesen. 3. EINE NACHRICHT WIRD NICHT GELÖSCHT, sondern geleert. Ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis, dass jemand etwas zurückgenommen hat -- und in einem Gespräch, in dem Absprachen stehen, ist Nachvollziehbarkeit mehr wert als ein sauberer Schnitt. --------------------------------------------------------------------- WAS BEWUSST FEHLT Kein Bearbeiten einer abgeschickten Nachricht. Wer eine Absprache nachträglich umschreiben kann, macht den Verlauf wertlos -- zurücknehmen und neu schreiben ist ehrlicher und hinterlässt eine Spur. (Zurücknehmen selbst ist da, siehe weg_am.) ===================================================================== */ 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, 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 ---------------------------------------------------- */ function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); req.person = person; next(); } function gleicheHerkunft(req, res, next) { const herkunft = req.get("origin"); if (!herkunft) return next(); let erlaubt; try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); next(); } chatRouter.use("/workspace/api/chat", angemeldet); /* ===================================================================== Bausteine ===================================================================== */ /** Ist die Person in diesem Raum -- und nicht ausgetreten? * * DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf * liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht, * macht aus einer Raumnummer einen Schlüssel. */ function istDrin(raumId, personId) { try { const z = db().prepare( "SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL") .get(raumId, personId); return !!z; } catch { return false; } } /** Alle (noch aktiven) Teilnehmer eines Raums. */ /* Das Profilbild gehoert dazu (07.09.2026). Ohne es zeigt die Gespraechsliste Initialen, obwohl ein Bild hochgeladen ist -- der Fehler liegt dann nicht in der Anzeige, sondern in der Abfrage. Die fertige ADRESSE wird danach gebaut, nicht der Dateiname weitergereicht: Sonst setzt jede Stelle im Browser denselben Pfad zusammen. UND DER KOMMENTAR STEHT AUSSERHALB DER ABFRAGE. Beim ersten Anlauf stand er darin -- mit dem Wort `bild` in schraegen Anfuehrungszeichen. Die beenden in JavaScript eine Vorlagenzeichenkette; die halbe SQL wurde damit zu Programmtext, und die Datei liess sich nicht mehr lesen. Ein Kommentar, der die Zeichenkette schliesst, in der er steht. */ function teilnehmerVon(raumId) { return db().prepare(` SELECT t.person_id AS id, p.name, p.rolle, p.bild, t.leitung, t.gelesen_bis FROM chat_teilnehmer t JOIN personen p ON p.id = t.person_id WHERE t.raum_id = ? AND t.raus_am IS NULL ORDER BY ` + ROLLEN_SORTIERUNG + ", p.name").all(raumId) .map((z) => ({ ...z, bild: z.bild ? `/workspace/api/steckbrief/bild/${z.bild}` : null })); } /** Findet das Zweier-Gespräch der beiden -- oder legt es an. * * ES DARF NUR EINES GEBEN. Zwei Gespräche zwischen denselben zwei * Leuten wären ein Verlauf an zwei Orten: Man schreibt in das eine, * der andere liest im anderen und wartet. Deshalb wird zuerst * gesucht, und zwar über die Teilnehmer -- ein "Paarschlüssel" als * Spalte wäre eine zweite Wahrheit neben chat_teilnehmer. */ function direktRaum(personA, idB, { anlegen = true } = {}) { const d = db(); const vorhanden = d.prepare(` SELECT r.id FROM chat_raeume r WHERE r.art = 'direkt' AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?) AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?) AND (SELECT COUNT(*) FROM chat_teilnehmer t WHERE t.raum_id = r.id) = 2`) .get(personA.id, idB); if (vorhanden) return vorhanden.id; if (!anlegen) return null; const n = jetzt(); d.prepare("INSERT INTO chat_raeume (art, erstellt, erstellt_von, letzte_am) VALUES ('direkt',?,?,NULL)") .run(n, personA.id); const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id; const rein = d.prepare( "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)"); rein.run(raumId, personA.id, n); rein.run(raumId, idB, n); 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 * Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für * eine der beiden Seiten immer falsch. */ function raumName(raum, teilnehmer, ichId) { if (raum.art === "gruppe") return raum.name || "Gruppe"; const andere = teilnehmer.filter((t) => t.id !== ichId); return andere.map((t) => t.name).join(", ") || "Gespräch"; } /* ===================================================================== Die Schnittstellen ===================================================================== */ /** Mit wem kann ich ein Gespräch anfangen? */ chatRouter.get("/workspace/api/chat/partner", (req, res) => { try { const ids = schreibbareIds(req.person); const wo = ids === null ? "id <> ?" : `id IN (${ids.map(() => "?").join(",")})`; const werte = ids === null ? [req.person.id] : ids; if (ids !== null && !ids.length) return res.json({ personen: [] }); res.json({ personen: db().prepare( `SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo} ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte), }); } catch (fehler) { console.error("[chat] Partner:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** 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, 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. 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, 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 = ? 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 id > ? AND person_id <> ?`) .get(r.id, r.gelesen_bis, r.geloescht_bis, req.person.id).n; return { id: r.id, art: r.art, name: raumName(r, leute, req.person.id), leitung: !!r.leitung, teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })), letzte: letzte ? { /* 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, ungelesen, }; }); res.json({ raeume }); } catch (fehler) { console.error("[chat] Räume:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Ein Zweier-Gespräch öffnen (und beim ersten Mal anlegen). */ chatRouter.post("/workspace/api/chat/direkt", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { try { const mit = Number(req.body?.mit); if (!Number.isInteger(mit) || mit < 1) return res.status(400).json({ fehler: "ungueltig" }); /* DIE REGEL SITZT HIER, nicht in der Oberfläche. Wer die Anfrage von Hand baut, kommt damit keinen Schritt weiter. */ if (!darfSchreibenMit(req.person, mit)) { return res.status(403).json({ fehler: "Mit dieser Person kannst du nicht schreiben." }); } const ziel = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1").get(mit); if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" }); const raumId = direktRaum(req.person, mit); res.json({ raum_id: raumId }); } catch (fehler) { console.error("[chat] Direkt:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Eine Gruppe anlegen. Wer sie anlegt, führt sie. */ chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { try { const name = String(req.body?.name ?? "").trim(); if (name.length < 2) return res.status(400).json({ fehler: "Die Gruppe braucht einen Namen." }); if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Der Name ist zu lang." }); const roh = Array.isArray(req.body?.mit) ? req.body.mit : []; const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))] .filter((id) => id !== req.person.id); /* JEDE Nummer einzeln gegen die Regel -- eine Gruppe ist kein Schlupfloch, um jemanden zu erreichen, den man einzeln nicht anschreiben dürfte. */ const verboten = mit.filter((id) => !darfSchreibenMit(req.person, id)); if (verboten.length) { return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." }); } if (!mit.length) return res.status(400).json({ fehler: "Wähle mindestens eine Person aus." }); const d = db(); const n = jetzt(); d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe',?,?,?)") .run(name, n, req.person.id); const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)") .run(raumId, req.person.id, n); const rein = d.prepare( "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)"); for (const id of mit) { if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) rein.run(raumId, id, n); } protokolliere("chat_gruppe_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${raumId} ${name} (${mit.length + 1})`.slice(0, 120), }); res.status(201).json({ raum_id: raumId }); } catch (fehler) { console.error("[chat] Gruppe:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Der Verlauf eines Gesprächs. */ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { try { const raumId = Number(req.params.id); if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); /* Was man nicht sehen darf, existiert nicht -- 404 statt 403, sonst liesse sich durch Ausprobieren herausfinden, welche Gespräche es gibt. */ if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); /* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man über `vor`. */ const vor = Number(req.query.vor) || 0; /* MIT DEM ZITAT IN EINER ABFRAGE (09.09.2026, Punkt 15). Der zweite Verbund holt die Nachricht, auf die geantwortet wurde. Sie einzeln nachzuladen waere je Antwort eine weitere Abfrage -- bei 200 Zeilen im schlimmsten Fall 200 Stueck. `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, 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 = ? 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. Die Oberflaeche zieht daraus die Linie "ab hier ist es neu". Sie muss ihn deshalb beim OEFFNEN bekommen -- eine Zeile spaeter (nach dem PUT auf /gelesen) waere er immer die letzte Nachricht, und die Linie staende nie irgendwo. */ const gelesenBis = db().prepare( "SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(raumId, req.person.id)?.gelesen_bis ?? 0; const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId); const leute = teilnehmerVon(raumId); res.json({ raum: { id: raum.id, art: raum.art, name: raumName(raum, leute, req.person.id), teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle, bild: t.bild, leitung: !!t.leitung })), }, gelesen_bis: gelesenBis, /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ nachrichten: reihen.reverse().map((n) => ({ id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null, text: n.weg_am ? null : n.text, zurueckgenommen: !!n.weg_am, erstellt: n.erstellt, selbst: n.person_id === req.person.id, /* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine zurueckgenommene Nachricht bleibt auch im Zitat zurueckgenommen -- sonst waere das Zuruecknehmen ueber ein Zitat zu umgehen. */ 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) || (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) { console.error("[chat] Verlauf:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Etwas schreiben. */ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, express.json({ limit: "16kb" }), async (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 text = String(req.body?.text ?? "").trim(); if (!text) return res.status(400).json({ fehler: "leer" }); if (text.length > TEXT_MAX) { return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` }); } /* WORAUF GEANTWORTET WIRD, MUSS IN DEMSELBEN RAUM LIEGEN. Ohne diese Pruefung koennte jemand die Kennung einer Nachricht aus einem fremden Gespraech mitschicken -- und beim Empfaenger stuende ein Zitat aus einem Raum, den er nie gesehen hat. Die Abfrage bindet deshalb `raum_id` mit ein; passt es nicht, wird das Zitat still weggelassen statt die Nachricht abzulehnen. Der Text ist das Wichtige, das Zitat die Zugabe. */ let antwortAuf = Number(req.body?.antwort_auf) || null; if (antwortAuf) { const da = db().prepare( "SELECT 1 FROM chat_nachrichten WHERE id = ? AND raum_id = ?").get(antwortAuf, raumId); if (!da) antwortAuf = null; } const d = db(); const n = jetzt(); d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt, antwort_auf) VALUES (?,?,?,?,?)") .run(raumId, req.person.id, text, n, antwortAuf); 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); /* Wer schreibt, hat das Eigene gelesen. */ d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?") .run(id, raumId, req.person.id); const zitat = antwortAuf ? d.prepare(` SELECT a.text, a.weg_am, p.name AS von FROM chat_nachrichten a LEFT JOIN personen p ON p.id = a.person_id WHERE a.id = ?`).get(antwortAuf) : null; const nachricht = { id, raum_id: raumId, von_id: req.person.id, von: req.person.name, rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false, antwort: zitat ? { id: antwortAuf, von: zitat.von || "Gelöscht", 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 an die, die es nicht tun. Beides läuft über chatEreignis(), damit die Entscheidung "wer bekommt was" an einer Stelle steht. */ const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id); chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id)); res.status(201).json({ nachricht }); } catch (fehler) { console.error("[chat] Schreiben:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ===================================================================== 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) Filipe: "ich will dass du diese seite viel krasser und detaillierter machst ... alles reinsetzt was wir noch gebrauchen koennten." Ein Chat ohne Suche ist ab dem zweiten Monat ein Archiv, in dem man nichts findet. Das ist die Funktion, die am haeufigsten fehlt und am meisten Zeit spart -- vor Reaktionen, vor Anhaengen, vor allem anderen. DIE EINGRENZUNG AUF DIE EIGENEN RAEUME IST DIE GANZE SICHERHEIT DIESER FUNKTION. Sie steht deshalb im JOIN und nicht in einer nachtraeglichen Pruefung: `chat_teilnehmer` wird mit der eigenen Personenkennung verbunden, und was dort nicht drinsteht, kommt gar nicht erst aus der Datenbank. Ein Filter, der erst hinterher aussortiert, ist eine Zeile davon entfernt, vergessen zu werden. OHNE VOLLTEXTINDEX, MIT ABSICHT. `LIKE '%...%'` kann keinen Index benutzen und liest die Tabelle. Bei einem Team dieser Groesse sind das einige tausend Zeilen -- gemessen unter einer Millisekunde. Ein FTS5-Index waere eine zweite Tabelle, die synchron gehalten werden muss, und genau daran gehen solche Sachen kaputt. Wenn der Verlauf einmal sechsstellig wird, ist das der richtige Zeitpunkt dafuer, nicht heute. `escape` FUER DIE PLATZHALTER: In `LIKE` sind `%` und `_` Zeichen mit Bedeutung. Wer nach "100 %" sucht, wuerde sonst alles finden. ===================================================================== */ chatRouter.get("/workspace/api/chat/suche", (req, res) => { try { const roh = String(req.query.q ?? "").trim(); if (roh.length < 2) return res.json({ frage: roh, treffer: [], zuKurz: true }); if (roh.length > 120) return res.status(400).json({ fehler: "zu_lang" }); /* AUCH DER BACKSLASH SELBST. Er ist hier das Fluchtzeichen (`ESCAPE '\'`); wer ihn eingibt, wuerde sonst das naechste Zeichen entwerten und im Zweifel ein ungueltiges Muster erzeugen. Drei Zeichen haben in `LIKE` eine Bedeutung, nicht zwei. */ const muster = "%" + roh.replace(/[\\%_]/g, (z) => "\\" + z) + "%"; const reihen = db().prepare(` SELECT n.id, n.raum_id, n.text, n.erstellt, p.name AS von, p.rolle, r.art, r.name AS raum_name FROM chat_nachrichten n JOIN chat_teilnehmer t ON t.raum_id = n.raum_id AND t.person_id = ? 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); res.json({ frage: roh, treffer: reihen.map((n) => { const leute = teilnehmerVon(n.raum_id); return { id: n.id, raum_id: n.raum_id, raum: raumName({ id: n.raum_id, art: n.art, name: n.raum_name }, leute, req.person.id), von: n.von || "Gelöscht", rolle: n.rolle, erstellt: n.erstellt, /* Der Ausschnitt wird um den Treffer herum geschnitten, nicht vom Anfang: Bei einer langen Nachricht stuende sonst der Anfang da und das gesuchte Wort waere nicht dabei. */ text: ausschnitt(n.text, roh), }; }), }); } catch (fehler) { console.error("[chat] Suche:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Ein Stueck Text um die Fundstelle herum. */ function ausschnitt(text, frage) { const t = String(text || ""); const i = t.toLowerCase().indexOf(frage.toLowerCase()); if (i < 0 || t.length <= 160) return t.slice(0, 160); const von = Math.max(0, i - 50); const bis = Math.min(t.length, i + frage.length + 90); return (von > 0 ? "… " : "") + t.slice(von, bis) + (bis < t.length ? " …" : ""); } /** Bis hierher gelesen. */ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft, express.json({ limit: "4kb" }), (req, res) => { try { const raumId = Number(req.params.id); const bis = Number(req.body?.bis); if (!Number.isInteger(raumId) || !Number.isInteger(bis)) { return res.status(400).json({ fehler: "ungueltig" }); } if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); /* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine gelesene Nachricht wieder als ungelesen da. */ db().prepare(`UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ? AND gelesen_bis < ?`) .run(bis, raumId, req.person.id, bis); res.json({ ok: true }); } catch (fehler) { console.error("[chat] Gelesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Eine eigene Nachricht zurücknehmen. */ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (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, 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" }); } /* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff in die Geschichte -- und niemand könnte ihm nachher glauben. */ if (n.person_id !== req.person.id) { return res.status(403).json({ fehler: "Nur eigene Nachrichten." }); } /* 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"); protokolliere("chat_zurueckgenommen", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${id}`, }); res.json({ ok: true, empfaenger: empfaenger.length }); } catch (fehler) { console.error("[chat] Zurücknehmen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ===================================================================== Sofort zusehen (Server-Sent Events) ===================================================================== WARUM SSE UND NICHT NACHFRAGEN IM TAKT: Ein Chat, der alle fünf Sekunden nachfragt, ist fünf Sekunden langsam und stellt bei zehn Angemeldeten 7200 Anfragen in der Stunde -- für Nachrichten, die es meistens nicht gibt. WARUM SSE UND NICHT WEBSOCKET: Hier fliesst alles in EINE Richtung, vom Server zum Browser. Geschrieben wird über die normale Schnittstelle. SSE ist dafür gebaut, kommt ohne fremde Bibliothek aus und übersteht einen Verbindungsabriss von selbst -- der Browser verbindet sich neu, ohne dass man dafür Code schreibt. Der Browser holt sich nach einem Abriss die Lücke über den normalen Verlauf nach; deshalb ist hier keine Nachrichtenwarteschlange nötig. */ /** Wer sieht gerade zu: Personennummer → Menge offener Verbindungen. * Eine Person kann an zwei Geräten sitzen. */ const zuschauer = new Map(); function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") { /* 1. An alle offenen Verbindungen der Beteiligten. */ const leute = teilnehmerVon(raumId).map((t) => t.id); for (const personId of leute) { for (const antwort of zuschauer.get(personId) || []) { try { antwort.write(`event: ${art}\ndata: ${JSON.stringify(nachricht)}\n\n`); } catch { /* die Verbindung räumt sich selbst ab */ } } } /* 2. Eine Benachrichtigung an die, die NICHT zusehen. Wer die Seite offen hat, sieht die Nachricht ohnehin -- ihm auch noch eine Meldung aufs Handy zu schicken, ist der schnellste Weg, dass er Benachrichtigungen abschaltet. */ for (const personId of benachrichtigen) { if ((zuschauer.get(personId) || []).length) continue; void meldeNeueNachricht(personId, raumId, nachricht); } } /** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll * auch dann laufen, wenn am Push-Teil etwas klemmt. */ async function meldeNeueNachricht(personId, raumId, nachricht) { try { const { benachrichtige } = await import("./workspace-push.js"); await benachrichtige(personId, "chat_nachricht", { titel: `Nachricht von ${nachricht.von}`, /* 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) || (nachricht.anhang?.art === "bild" ? "Foto" : nachricht.anhang?.art === "pdf" ? "PDF" : ""), ziel: `/workspace/chat.html?raum=${raumId}`, }); } catch (fehler) { console.error("[chat] Benachrichtigung:", fehler?.message); } } chatRouter.get("/workspace/api/chat/strom", (req, res) => { res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", /* Ohne das puffert ein davorliegender Reverse Proxy (Caddy, nginx) den Strom -- und dann kommt die Nachricht erst, wenn genug zusammen ist. Genau das Gegenteil des Zwecks. */ "X-Accel-Buffering": "no", }); res.flushHeaders?.(); res.write("retry: 3000\n\n"); const personId = req.person.id; if (!zuschauer.has(personId)) zuschauer.set(personId, new Set()); zuschauer.get(personId).add(res); /* Ein Lebenszeichen alle 25 Sekunden. Ohne das schliessen manche Vermittlungsstellen die Verbindung nach einer Minute Stille -- der Browser verbindet sich dann zwar neu, aber jedes Mal mit einer Lücke von ein paar Sekunden. */ const puls = setInterval(() => { try { res.write(": puls\n\n"); } catch { /* gleich weg */ } }, 25_000); puls.unref?.(); const aufraeumen = () => { clearInterval(puls); const menge = zuschauer.get(personId); if (menge) { menge.delete(res); if (!menge.size) zuschauer.delete(personId); } }; req.on("close", aufraeumen); req.on("error", aufraeumen); }); /** Wie viele Nachrichten warten insgesamt auf mich? * Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */ chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => { try { 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.id > t.geloescht_bis AND m.person_id <> ?`) .get(req.person.id, req.person.id); res.json({ ungelesen: z?.n || 0 }); } catch (fehler) { console.error("[chat] Ungelesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- 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; }