diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs
index ccef972f..c52bdee7 100644
--- a/server/pruef-chat-anhaenge.mjs
+++ b/server/pruef-chat-anhaenge.mjs
@@ -49,6 +49,11 @@ notbremse(600_000, "pruef-chat-anhaenge");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
const ANHANG_ORDNER = join(ordner, "chat-anhaenge");
+/* Der Datenordner dieses Laufs. Gebraucht wird er, um nachzusehen, WO
+ eine Datei liegt: in der GIF-Kiste oder bei den Anhaengen. Genau
+ dieser Unterschied ist der Kern des GIF-Abschnitts -- ein
+ verschicktes GIF ist eine KOPIE, kein Verweis. */
+const DATEN_ORDNER_P = ordner;
const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
@@ -834,7 +839,192 @@ async function tonAufnehmen(seite, ms) {
`ein Video im Ton-Behälter wird abgelehnt (HTTP ${alsVideo}) — sonst liefe es ohne Bild und niemand wüsste es`);
}
-melde("\n=== 10. Stille Fehler ===");
+/* =======================================================================
+ DIE GIF-KISTE (23.09.2026)
+
+ Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
+ und gifts reinschicken kann. also nur die modis rechte linke hand
+ und dogfather."
+
+ „gifts" ist hier als GIFs gelesen -- die bewegten Bildchen.
+
+ DIE WICHTIGSTE PRUEFUNG IN DIESEM ABSCHNITT IST DIE VORLETZTE: Ein
+ GIF aus der Kiste zu verschicken KOPIERT die Datei. Verwiese die
+ Nachricht auf dieselbe Datei, dann naehme das Zuruecknehmen EINER
+ Nachricht das GIF fuer alle anderen mit -- und in drei Gespraechen
+ stuende ab da ein kaputtes Bild. Das ist die Sorte Fehler, die erst
+ Wochen spaeter auffaellt und dann nicht mehr zu reparieren ist.
+ ======================================================================= */
+melde("\n=== 10. Die GIF-Kiste ===");
+
+/* EIN ECHTES GIF, kein gebasteltes Muster: Diese 43 Bytes sind das
+ bekannte durchsichtige 1x1-GIF89a -- jedes Programm der Welt oeffnet
+ es. Es geht hier nicht um das Bild, sondern um den Weg. */
+const GIF_B64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+
+{
+ await oeffne(dogi.seite, raumDogi);
+
+ /* ---- Der Knopf ist da -- und bei Patrick nicht ------------------ */
+ ok(await dogi.seite.locator("#gif-auf").isVisible(), "DogFather sieht den GIF-Knopf");
+ await oeffne(pat.seite, raumId);
+ ok(!(await pat.seite.locator("#gif-auf").isVisible()),
+ "der Scout sieht ihn nicht — dieselbe Regel wie bei den Sprachnachrichten");
+
+ /* ---- Die Kiste ist leer, und sie sagt das auch ------------------- */
+ await dogi.seite.click("#gif-auf");
+ await dogi.seite.waitForSelector("#gif-tafel:not([hidden])", { timeout: 8000 });
+ const leerSatz = (await dogi.seite.locator("#gif-leer").innerText()).trim();
+ ok(/noch leer/.test(leerSatz), `die leere Kiste sagt, was zu tun ist ("${leerSatz.slice(0, 48)}…")`);
+
+ /* ---- Ein GIF hineinlegen, ueber den echten Knopf ----------------- */
+ await dogi.seite.setInputFiles("#gif-feld", {
+ name: "daumen-hoch.gif", mimeType: "image/gif", buffer: Buffer.from(GIF_B64, "base64"),
+ });
+ await dogi.seite.waitForTimeout(2200);
+ const kacheln = await dogi.seite.locator(".gif-kachel").count();
+ ok(kacheln === 1, `es liegt ein GIF in der Kiste (${kacheln})`);
+
+ const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const inKiste = db4.prepare(
+ "SELECT id, datei, name, typ, breite, hoehe, von_id FROM chat_gifs ORDER BY id DESC LIMIT 1").get();
+ db4.close();
+ ok(inKiste?.typ === "image/gif", `erkannt als ${inKiste?.typ}`);
+ ok(inKiste?.name === "daumen-hoch", `der Name bleibt ohne Endung ("${inKiste?.name}")`);
+ ok(inKiste?.breite === 1 && inKiste?.hoehe === 1,
+ `die Maße kommen aus der Datei (${inKiste?.breite}×${inKiste?.hoehe})`);
+ ok(existsSync(join(DATEN_ORDNER_P, "chat-gifs", inKiste?.datei || "-")),
+ "die Datei liegt in der eigenen Kiste, nicht bei den Anhängen");
+
+ /* ---- DASSELBE NOCH EINMAL IST KEIN FEHLER ------------------------ */
+ await dogi.seite.setInputFiles("#gif-feld", {
+ /* ANDERER NAME, GLEICHER INHALT -- genau der Fall, der über den
+ Namen nicht zu erkennen wäre: "giphy.gif", "giphy(1).gif",
+ "download.gif" sind fast immer dasselbe Bild. */
+ name: "giphy(1).gif", mimeType: "image/gif", buffer: Buffer.from(GIF_B64, "base64"),
+ });
+ await dogi.seite.waitForTimeout(1800);
+ const nachZweitem = await dogi.seite.locator(".gif-kachel").count();
+ ok(nachZweitem === 1,
+ `dasselbe GIF unter anderem Namen ergibt KEINE zweite Kachel (${nachZweitem}) — verglichen wird der Inhalt`);
+ const hinweis = (await dogi.seite.locator("#fehler").innerText()).trim();
+ ok(/schon in der Kiste/.test(hinweis), ` und es steht als Hinweis da, nicht als Fehler ("${hinweis}")`);
+
+ /* ---- Verschicken ------------------------------------------------- */
+ const vorher = await dogi.seite.locator(".chat-nachricht").count();
+ await dogi.seite.click(".gif-kachel__knopf");
+ await dogi.seite.waitForTimeout(2200);
+ const nachher = await dogi.seite.locator(".chat-nachricht").count();
+ ok(nachher === vorher + 1, `das GIF steht im Verlauf (${vorher} -> ${nachher})`);
+ ok(!(await dogi.seite.locator("#gif-tafel").isVisible()),
+ " und die Kiste geht zu — sonst steht sie vor dem, was man gerade geschickt hat");
+
+ const db5 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const alsNachricht = db5.prepare(`SELECT anhang_art, anhang_typ, anhang_datei, anhang_name
+ FROM chat_nachrichten WHERE anhang_typ = 'image/gif' ORDER BY id DESC LIMIT 1`).get();
+ db5.close();
+ ok(alsNachricht?.anhang_art === "bild",
+ "es ist ein ganz normaler Anhang geworden — keine zweite Sorte Nachricht");
+ ok(alsNachricht?.anhang_name === "daumen-hoch.gif",
+ `mit seinem Namen (${alsNachricht?.anhang_name})`);
+
+ /* ---- DIE EIGENTLICHE AUSSAGE: ES WURDE KOPIERT ------------------- */
+ ok(alsNachricht?.anhang_datei !== inKiste?.datei,
+ "die verschickte Datei ist eine KOPIE, kein Verweis auf die Kiste");
+ ok(existsSync(join(DATEN_ORDNER_P, "chat-anhaenge", alsNachricht?.anhang_datei || "-")),
+ " sie liegt bei den Anhängen");
+
+ /* ---- Aus der Kiste nehmen: das Verschickte bleibt ---------------- */
+ await dogi.seite.evaluate(() => { window.confirm = () => true; });
+ await dogi.seite.click("#gif-auf");
+ await dogi.seite.waitForTimeout(600);
+ await dogi.seite.click(".gif-kachel__weg");
+ await dogi.seite.waitForTimeout(1500);
+ const restKacheln = await dogi.seite.locator(".gif-kachel").count();
+ ok(restKacheln === 0, `aus der Kiste genommen (${restKacheln} übrig)`);
+ ok(!existsSync(join(DATEN_ORDNER_P, "chat-gifs", inKiste?.datei || "-")),
+ " die Datei der Kiste ist weg");
+ /* UND DAS IST DER PUNKT: Das verschickte GIF steht weiter im
+ Gespräch. Wäre es ein Verweis gewesen, stünde dort jetzt ein
+ kaputtes Bild -- in jedem Gespräch, in das es je geschickt wurde. */
+ ok(existsSync(join(DATEN_ORDNER_P, "chat-anhaenge", alsNachricht?.anhang_datei || "-")),
+ " das bereits verschickte GIF bleibt unberührt");
+
+ /* ---- Die Gegenprobe: der Scout kommt an die Kiste nicht heran ---- */
+ const absagen = await absichtlich([pat], () => pat.seite.evaluate(async ([r, b64]) => {
+ const roh = atob(b64);
+ const puffer = new Uint8Array(roh.length);
+ for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
+ const liste = await fetch("/workspace/api/chat/gifs");
+ const rein = await fetch("/workspace/api/chat/gifs", {
+ method: "POST", headers: { "Content-Type": "image/gif", "X-Dateiname": "x.gif" },
+ body: puffer });
+ const raus = await fetch(`/workspace/api/chat/raeume/${r}/gif`, {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ gif_id: 1 }) });
+ return { liste: liste.status, rein: rein.status, raus: raus.status };
+ }, [raumId, GIF_B64]));
+ ok(absagen.liste === 403 && absagen.rein === 403 && absagen.raus === 403,
+ `am Knopf vorbei: ansehen ${absagen.liste}, hineinlegen ${absagen.rein}, schicken ${absagen.raus}`);
+
+ /* ---- Nur GIFs kommen hinein -------------------------------------- */
+ const keinGif = await absichtlich([dogi], () => dogi.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/chat/gifs", {
+ method: "POST",
+ headers: { "Content-Type": "image/gif", "X-Dateiname": "eigentlich.png" },
+ body: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+ 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1]),
+ });
+ return { status: a.status, text: (await a.json())?.fehler || "" };
+ }));
+ ok(keinGif.status === 415,
+ `ein PNG mit dem Etikett "image/gif" kommt nicht hinein (HTTP ${keinGif.status}) — entschieden wird am Inhalt`);
+
+ /* ---- Augenschonend: bei Bewegungsverzicht stehen sie still ------- */
+ const ruhig = await browser.newContext({
+ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce",
+ });
+ const rs = await ruhig.newPage();
+ await rs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await rs.click('.rolle[data-rolle="admin"]');
+ await rs.fill("#code", "CODE-DOGI-0001");
+ await rs.click("button[type=submit]");
+ await rs.waitForURL("**/start.html", { timeout: 25000 });
+ /* Noch ein GIF in die Kiste, sonst ist nichts zu sehen. */
+ await rs.goto(`${BASIS}/workspace/chat.html?raum=${raumDogi}`, { waitUntil: "networkidle" });
+ await rs.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
+ await rs.click("#gif-auf");
+ await rs.waitForTimeout(500);
+ await rs.setInputFiles("#gif-feld", {
+ name: "ruhig.gif", mimeType: "image/gif", buffer: Buffer.from(GIF_B64, "base64"),
+ });
+ await rs.waitForTimeout(2200);
+ const ruhigeKachel = await rs.evaluate(() => {
+ const k = document.querySelector(".gif-kachel__knopf");
+ return { tag: k?.firstElementChild?.tagName || "", da: !!k };
+ });
+ ok(ruhigeKachel.da && ruhigeKachel.tag === "CANVAS",
+ `bei „Bewegung reduzieren" steht das GIF still (${ruhigeKachel.tag}) — zwölf laufende Bildchen sind das Gegenteil von ruhig`);
+ /* GEGENPROBE: Im normalen Fall LAEUFT es. Sonst bewiese die Zeile
+ darüber nur, dass die Kachel immer stillsteht -- und dann sähe
+ niemand, was er verschickt. */
+ /* NEU LADEN, WEIL DIE KISTE SICH GEAENDERT HAT. Dieses Fenster hat
+ die Liste zuletzt geholt, als sie leer war -- ohne das Neuladen
+ zeigte die Gegenprobe auf ein leeres Gitter und wuerde rot, ohne
+ dass irgendetwas kaputt waere. (Beim Bauen genau so passiert.) */
+ await oeffne(dogi.seite, raumDogi);
+ await dogi.seite.click("#gif-auf");
+ await dogi.seite.waitForTimeout(1200);
+ const normaleKachel = await dogi.seite.evaluate(() => ({
+ tag: document.querySelector(".gif-kachel__knopf")?.firstElementChild?.tagName || "",
+ kacheln: document.querySelectorAll(".gif-kachel").length,
+ }));
+ ok(normaleKachel.kacheln === 1 && normaleKachel.tag === "IMG",
+ `Gegenprobe: ohne diese Einstellung läuft es (${normaleKachel.kacheln} Kachel, ${normaleKachel.tag})`);
+ await ruhig.close();
+}
+
+melde("\n=== 11. Stille Fehler ===");
{
/* ABSICHTLICH PROVOZIERTE ABSAGEN ZÄHLEN NICHT.
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index a6ec751b..1f65f64b 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -38,9 +38,10 @@
===================================================================== */
import express from "express";
-import { randomBytes } from "node:crypto";
+import { randomBytes, createHash } from "node:crypto";
import { join, extname, basename } from "node:path";
-import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from "node:fs";
+import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync,
+ copyFileSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
@@ -95,6 +96,49 @@ const ANHANG_NAME_MAX = 200;
sind -- eine Dauer waere eine Behauptung des Absenders. */
const TON_MAX = 4 * 1024 * 1024;
+/* DIE GIF-KISTE DES RUDELS (23.09.2026) -- eigener Ordner, und das ist
+ kein Ordnungssinn.
+
+ Eine Nachricht loeschen entfernt ihre Datei von der Platte (siehe
+ das Zuruecknehmen weiter unten). Laege ein Kachel-GIF in demselben
+ Ordner und mehrere Nachrichten zeigten darauf, wuerde das Loeschen
+ EINER Nachricht das GIF fuer alle anderen mitnehmen -- und in drei
+ Gespraechen stuende ab da ein kaputtes Bild. Deshalb liegt die
+ Kiste fuer sich, und beim Verschicken wird KOPIERT. Ein GIF ist
+ klein genug, dass die Kopie nichts kostet; ein kaputtes Bild in
+ einem fremden Gespraech ist teuer.
+
+ Der Ordner steht ueber join(DATEN_ORDNER, ...) -- genau dieses
+ Muster liest tools/wiederherstellung-proben.mjs aus, um zu pruefen,
+ ob die Sicherung ihn mitnimmt. Eine gepflegte Liste waere die, die
+ beim naechsten Ordner vergessen wird. */
+const GIF_ORDNER = join(DATEN_ORDNER, "chat-gifs");
+
+/* ACHT MEGABYTE. Ein GIF darueber ist kein GIF mehr, sondern ein
+ Video mit einem falschen Hut auf -- und es laeuft in der Auswahl
+ dauerhaft im Kreis, waehrend das Handy heiss wird. */
+const GIF_MAX = 8 * 1024 * 1024;
+
+/** Alle GIFs der Kiste, das Neueste zuerst.
+ * An einer Stelle, weil drei danach fragen: die Liste, das
+ * Hineinlegen (es gibt die frische zurueck) und das Herausnehmen. */
+function gifListe() {
+ return db().prepare(`SELECT g.id, g.name, g.groesse, g.breite, g.hoehe,
+ g.erstellt, p.name AS von
+ FROM chat_gifs g LEFT JOIN personen p ON p.id = g.von_id
+ ORDER BY g.id DESC LIMIT 200`).all()
+ .map((g) => ({
+ id: g.id, name: g.name, groesse: g.groesse,
+ breite: g.breite || 0, hoehe: g.hoehe || 0,
+ von: g.von || null, erstellt: g.erstellt,
+ /* Als fertige ADRESSE, nicht als Dateiname -- sonst setzt jede
+ Stelle im Browser denselben Pfad zusammen, und eine davon
+ setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim
+ Anhang. */
+ weg: `/workspace/api/chat/gifs/${g.id}/bild`,
+ }));
+}
+
const jetzt = () => new Date().toISOString();
/* ---------- Schranke ---------------------------------------------------- */
@@ -1500,6 +1544,9 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
so einem Raum niemanden -- deshalb dort die zweite Bedingung
und hier nicht. */
darf_ton: istTeamDogi(req.person),
+ /* DIE GIF-KISTE, aus derselben Regel. Filipes „also nur" stand
+ im selben Satz wie die Sprachnachrichten. */
+ darf_gif: istTeamDogi(req.person),
angeheftet: angeheftet.map((a) => ({
id: a.id,
von: a.von || "Gelöscht",
@@ -1923,6 +1970,244 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
}
});
+/* =====================================================================
+ DIE GIF-KISTE (23.09.2026)
+
+ Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
+ und gifts reinschicken kann. also nur die modis rechte linke hand
+ und dogfather."
+
+ ICH LESE „gifts" ALS GIFs -- die bewegten Bildchen -- und sage das
+ hier ausdruecklich, statt es stillschweigend anzunehmen. Im
+ Zusammenhang („im chat reinschicken", direkt neben Sprachnachrichten)
+ passt nichts anderes; sollte er Geschenke gemeint haben, steht das
+ hier als Stelle, an der man es sieht.
+
+ WARUM EINE EIGENE KISTE UND KEIN GIPHY: Die Begruendung steht bei
+ der Tabelle in workspace.js -- kurz: fremder Schluessel, fremde
+ Firma, jede Suche geht hinaus, und ab einer Menge kostet es. Die
+ Kiste laeuft hier, kostet nichts und wird mit der Zeit besser:
+ Was darin liegt, hat das Team ausgesucht.
+
+ DIE VIER GELTEN AUCH HIER. „also nur die modis rechte linke hand und
+ dogfather" stand im selben Satz wie die Sprachnachrichten; es gibt
+ keinen Grund, warum es fuer das eine gelten sollte und fuer das
+ andere nicht.
+ ===================================================================== */
+
+/** Vor jeder GIF-Route: Gehoere ich dazu? */
+function nurRudel(req, res, next) {
+ if (!istTeamDogi(req.person)) {
+ return res.status(403).json({ fehler: "Die GIF-Kiste gehört Team Dogi." });
+ }
+ next();
+}
+
+chatRouter.get("/workspace/api/chat/gifs", nurRudel, (req, res) => {
+ try {
+ res.json({ gifs: gifListe(), hoechstens: GIF_MAX });
+ } catch (fehler) {
+ console.error("[chat] GIF-Liste:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Ein GIF in die Kiste legen. */
+chatRouter.post("/workspace/api/chat/gifs", gleicheHerkunft, nurRudel,
+ express.raw({ type: "*/*", limit: GIF_MAX }), (req, res) => {
+ try {
+ if (!Buffer.isBuffer(req.body) || !req.body.length) {
+ return res.status(400).json({ fehler: "Keine Datei empfangen." });
+ }
+ /* AUS DEM INHALT, nicht aus dem Namen und nicht aus dem
+ Content-Type -- beide kommen vom Absender. */
+ const erkannt = dateiErkennen(req.body);
+ if (!erkannt || erkannt.typ !== "image/gif") {
+ return res.status(415).json({ fehler: "In die Kiste kommen nur GIFs." });
+ }
+
+ /* HABEN WIR DAS SCHON? Beantwortet am INHALT, nicht am Namen.
+ Ueber den Namen zu vergleichen waere die naheliegende
+ Abkuerzung -- und sie versagt genau dort, wo Dateien
+ "giphy.gif", "giphy(1).gif" und "download.gif" heissen, also
+ fast immer. Dasselbe GIF waere dreimal dieselbe Kachel. */
+ const pruef = createHash("sha256").update(req.body).digest("hex");
+ const d = db();
+ const schon = d.prepare("SELECT id FROM chat_gifs WHERE pruef = ?").get(pruef);
+ if (schon) {
+ /* KEIN FEHLER. Wer dasselbe GIF noch einmal hineinlegt, wollte
+ es in der Kiste haben -- und es ist drin. Eine Absage waere
+ formal richtig und im Erleben falsch. */
+ return res.status(200).json({ gif: gifListe().find((g) => g.id === schon.id) || null,
+ schon: true });
+ }
+
+ const name = anhangName(req.get("x-dateiname"), ".gif").replace(/\.gif$/i, "") || "GIF";
+ mkdirSync(GIF_ORDNER, { recursive: true });
+ const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}.gif`;
+ writeFileSync(join(GIF_ORDNER, aufPlatte), req.body, { flag: "wx" });
+
+ const n = jetzt();
+ d.prepare(`INSERT INTO chat_gifs
+ (datei, name, pruef, typ, groesse, breite, hoehe, von_id, erstellt)
+ VALUES (?,?,?,?,?,?,?,?,?)`)
+ .run(aufPlatte, name.slice(0, 60), pruef, erkannt.typ, req.body.length,
+ erkannt.breite || null, erkannt.hoehe || null, req.person.id, n);
+ const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+
+ protokolliere("chat_gif_dazu", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${id} ${name} ${Math.round(req.body.length / 1024)} kB`.slice(0, 120),
+ });
+ res.status(201).json({ gif: gifListe().find((g) => g.id === id) || null, schon: false });
+ } catch (fehler) {
+ console.error("[chat] GIF hineinlegen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Ein GIF aus der Kiste ansehen. */
+chatRouter.get("/workspace/api/chat/gifs/:id/bild", nurRudel, (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+ const g = db().prepare("SELECT datei, name, typ FROM chat_gifs WHERE id = ?").get(id);
+ if (!g) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const pfad = join(GIF_ORDNER, g.datei);
+ try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
+ /* Dieselben Kopfzeilen wie beim Anhang -- der Typ aus der
+ Erkennung, nosniff, und eine Richtlinie, die dem Dokument jede
+ Ausfuehrung nimmt. Ein GIF kostet das nichts. */
+ res.setHeader("Content-Type", g.typ || "image/gif");
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
+ res.setHeader("Cache-Control", "private, max-age=86400");
+ createReadStream(pfad).pipe(res);
+ } catch (fehler) {
+ console.error("[chat] GIF ausliefern:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Ein GIF wieder aus der Kiste nehmen.
+ *
+ * WER: der, der es hineingelegt hat -- und wer ohnehin jede Nachricht
+ * wegnehmen darf (DogFather und die rechte Hand). Die Kiste gehoert
+ * allen; ein GIF, das nicht mehr passt, muss jemand herausnehmen
+ * koennen, ohne den zu suchen, der es damals gebracht hat.
+ *
+ * DIE DATEI GEHT MIT. Anders als bei einer Nachricht gibt es hier
+ * keinen Verlauf, den man lesbar halten muesste -- was aus der Kiste
+ * ist, ist aus der Kiste. Bereits verschickte GIFs bleiben
+ * unberuehrt: Sie sind Kopien (siehe oben). */
+chatRouter.delete("/workspace/api/chat/gifs/:id", gleicheHerkunft, nurRudel, (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+ const d = db();
+ const g = d.prepare("SELECT id, datei, name, von_id FROM chat_gifs WHERE id = ?").get(id);
+ if (!g) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (g.von_id !== req.person.id && !darfJedeNachrichtLoeschen(req.person)) {
+ return res.status(403).json({ fehler: "Das hat jemand anderes hineingelegt." });
+ }
+ const weg = d.prepare("DELETE FROM chat_gifs WHERE id = ?").run(id).changes;
+ if (weg) {
+ try { unlinkSync(join(GIF_ORDNER, g.datei)); } catch { /* war schon weg */ }
+ }
+ protokolliere("chat_gif_weg", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${id} ${g.name}`.slice(0, 120),
+ });
+ res.json({ weg: weg > 0, gifs: gifListe() });
+ } catch (fehler) {
+ console.error("[chat] GIF herausnehmen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Ein GIF aus der Kiste in ein Gespraech schicken.
+ *
+ * ES WIRD KOPIERT, NICHT VERWIESEN -- die Begruendung steht oben bei
+ * GIF_ORDNER: Eine geloeschte Nachricht nimmt ihre Datei mit, und ein
+ * gemeinsamer Verweis waere ein kaputtes Bild in drei Gespraechen.
+ *
+ * DANACH IST ES EIN GANZ NORMALER ANHANG. Keine zweite Sorte
+ * Nachricht: Es steht im Verlauf, es laesst sich zuruecknehmen, es
+ * faellt unter dieselbe Nachtruhe. Eine eigene Sorte haette eigene
+ * Rechte, eigene Suche, eigenes Loeschen -- drei Dinge, die es schon
+ * gibt. */
+chatRouter.post("/workspace/api/chat/raeume/:id/gif", gleicheHerkunft, nurRudel,
+ express.json({ limit: "2kb" }), (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)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ /* DIE NACHTRUHE GILT AUCH HIER. Sie nur beim Text und beim
+ Anhang zu pruefen hiesse, dass man nachts zwar nicht schreiben,
+ aber ein GIF schicken kann -- der Weg, den jeder findet, der
+ es einmal versucht. */
+ if (istTreffRaum(raumId) && istNachtruhe()) {
+ const stand = nachtruheStand();
+ return res.status(403).json({ fehler: stand.satz, nachtruhe: stand });
+ }
+
+ const gifId = Number(req.body?.gif_id);
+ const d = db();
+ const g = Number.isInteger(gifId)
+ ? d.prepare("SELECT id, datei, name, typ, groesse, breite, hoehe FROM chat_gifs WHERE id = ?")
+ .get(gifId) : null;
+ if (!g) return res.status(404).json({ fehler: "Dieses GIF gibt es nicht mehr." });
+
+ const quelle = join(GIF_ORDNER, g.datei);
+ try { statSync(quelle); } catch {
+ return res.status(410).json({ fehler: "Die Datei fehlt auf der Platte." });
+ }
+ mkdirSync(ANHANG_ORDNER, { recursive: true });
+ const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}.gif`;
+ copyFileSync(quelle, join(ANHANG_ORDNER, aufPlatte));
+
+ let begleittext = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
+ 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, `${g.name}.gif`, "bild", g.typ,
+ g.groesse, g.breite || null, g.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,
+ von_bild: bildWegFuer(req.person.id),
+ von_kachel: kachelFuer(req.person.id),
+ rolle: req.person.rolle, text: begleittext, erstellt: n,
+ zurueckgenommen: false, antwort: null, rudel: false,
+ anhang: {
+ art: "bild", typ: g.typ, name: `${g.name}.gif`,
+ groesse: g.groesse, breite: g.breite || 0, hoehe: g.hoehe || 0,
+ dauer: 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_gif", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${id} aus Kiste #${g.id}`.slice(0, 120),
+ });
+ res.status(201).json({ nachricht });
+ } catch (fehler) {
+ console.error("[chat] GIF schicken:", 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
diff --git a/server/workspace.js b/server/workspace.js
index d7e46f7f..a02dfb98 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -4505,6 +4505,59 @@ export function db() {
CREATE INDEX IF NOT EXISTS idx_chat_erwaehnungen_person
ON chat_erwaehnungen (person_id, nachricht_id);
+ /* DIE GIF-KISTE DES RUDELS (23.09.2026).
+
+ Filipe: "dan will ich auch dass man im chat auch
+ sprachnachrichten und gifts reinschicken kann. also nur die
+ modis rechte linke hand und dogfather."
+
+ WARUM EINE EIGENE KISTE UND KEIN ANSCHLUSS AN GIPHY ODER
+ TENOR: Beide brauchen ein Konto, einen Schluessel und schicken
+ bei jedem Tastendruck mit, wonach hier gesucht wird -- an eine
+ fremde Firma, aus einem Arbeitsplatz heraus, in dem sonst
+ nichts nach aussen geht. Dazu kosten sie ab einer Menge Geld.
+ Beides steht quer zu dem, wie dieses Haus gebaut ist.
+
+ Die Kiste kostet nichts, laeuft auf demselben Server wie alles
+ andere, und sie wird mit der Zeit besser statt schlechter: Was
+ das Team hineinlegt, hat das Team ausgesucht. Ein fremder
+ Katalog mit zehn Millionen Eintraegen ist nicht mehr Auswahl,
+ sondern weniger -- man sucht laenger und findet Fremdes.
+
+ WARUM EINE EIGENE TABELLE UND NICHT EINFACH ALTE NACHRICHTEN
+ DURCHSUCHEN: Ein GIF in der Kiste ist etwas anderes als ein
+ GIF, das jemand einmal geschickt hat. Die Kiste ist eine
+ Entscheidung ("das wollen wir wieder benutzen"), der Verlauf
+ ist eine Geschichte. Und ein Verlauf laesst sich wegraeumen --
+ die Kiste duerfte davon nicht leer werden.
+
+ "pruef" IST DER INHALTSSCHLUESSEL (SHA-256 der Datei). Er
+ beantwortet "haben wir das schon?" richtig, naemlich am
+ Inhalt: Dasselbe GIF unter drei Namen ist dreimal dieselbe
+ Kachel in der Auswahl. Ueber den Namen zu vergleichen waere
+ die naheliegende Abkuerzung -- und sie versagt genau dort, wo
+ Dateien "giphy.gif", "giphy(1).gif", "download.gif" heissen,
+ also fast immer.
+
+ KEIN ON DELETE CASCADE AUF personen: Wer das Haus verlaesst,
+ nimmt die GIFs des Teams nicht mit. "von_id" wird null, die
+ Kachel bleibt. */
+ CREATE TABLE IF NOT EXISTS chat_gifs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ datei TEXT NOT NULL,
+ name TEXT NOT NULL,
+ pruef TEXT NOT NULL UNIQUE,
+ typ TEXT NOT NULL,
+ groesse INTEGER NOT NULL,
+ breite INTEGER,
+ hoehe INTEGER,
+ von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ erstellt TEXT NOT NULL
+ );
+ /* Gezeigt wird "das Neueste zuerst" -- wer gerade etwas
+ hineingelegt hat, findet es oben. */
+ CREATE INDEX IF NOT EXISTS idx_chat_gifs_neu ON chat_gifs (id DESC);
+
/* REAKTIONEN (14.09.2026).
Filipe: "ich will dass man auf die nachrichten auch reagieren
diff --git a/tools/sicherung-holen.sh b/tools/sicherung-holen.sh
index 8a3c2377..2b5a28d4 100644
--- a/tools/sicherung-holen.sh
+++ b/tools/sicherung-holen.sh
@@ -60,11 +60,33 @@ scp -q dogfather-server:/home/dogiweb/workspace-daten/sicherungen/*.db "$ZIEL/"
# Zug eingetragen -- nicht spaeter, wenn es auffaellt: Genau so ist die
# Luecke bei den Profilbildern entstanden.
#
+# MATERIAL FEHLTE -- GEFUNDEN AM 23.09.2026, NICHT BEMERKT SEIT DEM
+# 22.09.
+#
+# server/workspace-material.js legt seit dem 22.09. einen Ordner
+# "material" an (2,4 MB auf dem Server). Er stand hier nicht. Nach
+# einem Plattenausfall waere die Materialbibliothek weg gewesen, ohne
+# dass die taegliche Meldung je etwas gesagt haette -- genau die
+# Luecke, wegen der diese Probe ueberhaupt gebaut wurde.
+#
+# Gefunden hat es nicht das Lesen, sondern tools/wiederherstellung-
+# proben.mjs: Sie liest die Ordner aus dem Quelltext (join(DATEN_ORDNER,
+# "...")) und vergleicht sie mit dieser Zeile. Das ist der Unterschied
+# zwischen einer Liste, die jemand pflegen muss, und einer, die
+# nachgezaehlt wird.
+#
+# DIE GIF-KISTE GEHOERT DAZU (ergaenzt 23.09.2026, im selben Zug wie
+# die Funktion -- nicht spaeter, wenn es auffaellt). Sie liegt in
+# chat-gifs/ und ist das einzige, was NUR dort liegt: Verschickte GIFs
+# sind Kopien in chat-anhaenge/, die Kiste selbst nicht. Ginge die
+# Platte kaputt, waere ohne diesen Eintrag die gesammelte Auswahl des
+# Teams weg, und die Nachrichten damit auch nicht mehr nachlegbar.
+#
# Wer hier einen Ordner ergaenzt, traegt ihn auch in ORDNER unten ein;
# die Probe vergleicht die Liste gegen die Ordner, die der Server
# wirklich benutzt, und schlaegt bei einem vergessenen an.
echo "Hole hochgeladene Dateien, Bilder, Anhaenge und die Bibliothek ..."
-ORDNER="dateien wissen profilbilder chat-anhaenge"
+ORDNER="dateien wissen profilbilder chat-anhaenge chat-gifs material"
for o in $ORDNER; do
mkdir -p "$ZIEL/$o"
scp -qr "dogfather-server:/home/dogiweb/workspace-daten/$o/." "$ZIEL/$o/" 2>/dev/null || true
diff --git a/tools/wiederherstellung-proben.mjs b/tools/wiederherstellung-proben.mjs
index c35309f4..c2471563 100644
--- a/tools/wiederherstellung-proben.mjs
+++ b/tools/wiederherstellung-proben.mjs
@@ -212,9 +212,22 @@ ok(dateienZahl > 0,
: `alle ${benutzt.size} holt tools/sicherung-holen.sh (${geholt.join(", ")})`);
/* GEGENPROBE: Erkennt der Vergleich einen fehlenden Ordner ueberhaupt?
- Ohne sie bewiese "nichts fehlt" nur, dass die Suche nichts findet. */
- ok([...benutzt, "erfundenerordner"].filter((o) => !geholt.includes(o)).length === 1,
- "Gegenprobe: ein erfundener Ordner WIRD als fehlend erkannt");
+ Ohne sie bewiese "nichts fehlt" nur, dass die Suche nichts findet.
+
+ GEZAEHLT WIRD DIE DIFFERENZ, NICHT DIE SUMME (berichtigt am
+ 23.09.2026). Hier stand `=== 1` -- und das setzte voraus, dass
+ sonst nichts fehlt. An dem Tag, an dem wirklich etwas fehlte
+ (material), wurde die Gegenprobe rot und zeigte damit auf sich
+ selbst statt auf den Fund: zwei Meldungen, eine Ursache, und die
+ zweite lenkt von der ersten ab.
+
+ Eine Gegenprobe soll beweisen, dass der Vergleich REAGIERT. Das
+ tut sie, wenn ein zusaetzlicher Ordner die Zahl um genau eins
+ erhoeht -- unabhaengig davon, wie sie vorher stand. */
+ const fehlendJetzt = fehlend.length;
+ ok([...benutzt, "erfundenerordner"].filter((o) => !geholt.includes(o)).length
+ === fehlendJetzt + 1,
+ `Gegenprobe: ein erfundener Ordner WIRD als fehlend erkannt (${fehlendJetzt} -> ${fehlendJetzt + 1})`);
}
/* ---------------------------------------------------------------------
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index a25cc1c5..8b4e088f 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+