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:
+437
-16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user