Chat: die GIF-Kiste des Rudels -- und eine Luecke in der Sicherung

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
ausdruecklich, statt es stillschweigend anzunehmen. Im Zusammenhang
("im chat reinschicken", direkt neben Sprachnachrichten) passt nichts
anderes. Sollte er Geschenke gemeint haben, sagt er es, und dann baue
ich das.

EINE EIGENE KISTE STATT GIPHY ODER TENOR
----------------------------------------
Beide brauchen ein Konto und einen Schluessel und bekommen 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 laeuft auf demselben Server wie alles andere, kostet nichts
und wird mit der Zeit besser statt schlechter: Was darin liegt, hat das
Team ausgesucht. Zehn gute GIFs, die alle kennen, sind im Alltag mehr
wert als zehn Millionen fremde -- ein Katalog mit allem ist nicht mehr
Auswahl, sondern weniger.

HABEN WIR DAS SCHON? WIRD AM INHALT BEANTWORTET
-----------------------------------------------
Der Schluessel ist der SHA-256 der Datei. 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 Bild waere dreimal dieselbe Kachel.

Dasselbe GIF zweimal hineinzulegen ist deshalb KEIN Fehler: Es ist
drin, und genau das wollte man. Eine Absage waere formal richtig und im
Erleben falsch.

DAS WICHTIGSTE STUECK: ES WIRD KOPIERT, NICHT VERWIESEN
-------------------------------------------------------
Eine Nachricht zurueckzunehmen entfernt ihre Datei von der Platte. Laege
ein Kachel-GIF in demselben Ordner und mehrere Nachrichten zeigten
darauf, 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.

Deshalb liegt die Kiste in einem eigenen Ordner (chat-gifs/), und beim
Verschicken wird kopiert. Danach ist es ein ganz normaler Anhang: im
Verlauf, in der Suche, zuruecknehmbar, unter derselben Nachtruhe. Keine
zweite Sorte Nachricht mit eigenen Rechten und eigenem Loeschen.

Geprueft wird genau das: GIF hineinlegen, verschicken, aus der Kiste
nehmen -- und danach steht das verschickte immer noch im Gespraech.

"ALSO NUR" GILT AUCH HIER
-------------------------
Ansehen, hineinlegen, verschicken: alle drei nur fuer Team Dogi und
DogFather (istTeamDogi). Der Satz stand im selben Atemzug wie die
Sprachnachrichten; es gibt keinen Grund, warum er fuer das eine gelten
sollte und fuer das andere nicht. Gemessen auch am Knopf vorbei:
403/403/403.

AUGENSCHONEND -- HIER EINE ECHTE FRAGE, KEINE FORMSACHE
--------------------------------------------------------
Zwoelf gleichzeitig laufende Bildchen sind das Gegenteil von ruhig. Wer
"Bewegung reduzieren" gesetzt hat, bekommt deshalb STEHENDE Bilder: das
erste Einzelbild, im Browser auf eine Zeichenflaeche gemalt (kostet
keine zweite Datei), und es laeuft erst, wenn man es anfasst oder mit
der Tastatur darauf steht.

Fuer alle anderen laufen sie. Eine GIF-Auswahl, in der sich nichts
bewegt, ist eine, in der man nicht sieht, was man verschickt -- deshalb
steht dazu eine Gegenprobe in der Pruefung.

NEBENBEFUND, UND ER WIEGT SCHWERER ALS DIE GIFS
-----------------------------------------------
Weil die Kiste einen neuen Datenordner braucht, lief
tools/wiederherstellung-proben.mjs -- und meldete, dass der Ordner
"material" seit dem 22.09. NICHT gesichert wird. 2,4 MB
Materialbibliothek auf dem Server, nach einem Plattenausfall weg, ohne
dass die taegliche Meldung je etwas gesagt haette. Genau die Luecke,
wegen der diese Probe ueberhaupt gebaut wurde -- und sie hat sie
gefunden, weil sie die Ordner aus dem QUELLTEXT liest statt aus einer
gepflegten Liste.

Eingetragen. Danach frisch geholt und zurueckgespielt: 18 von 18 gruen,
"DIE SICHERUNG LAESST SICH ZURUECKSPIELEN" (vorher 3 Fehler).

Dabei war die Gegenprobe daneben selbst kaputt: Sie prueft, ob ein
erfundener Ordner als fehlend erkannt wird, und verlangte dafuer
`=== 1`. Das setzt voraus, dass sonst nichts fehlt. An dem Tag, an dem
wirklich etwas fehlte, wurde sie rot und zeigte damit auf sich selbst
statt auf den Fund -- zwei Meldungen, eine Ursache, und die zweite
lenkt von der ersten ab. Jetzt zaehlt sie die Differenz.

GEPRUEFT
--------
pruef-chat-anhaenge: 109 Pruefungen, 0 Fehler (vorher 86, davor 60).
pruef-chat, pruef-chat-aufloesen (126), pruef-chat-optik: gruen.
tools/wiederherstellung-proben: 18 von 18.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-23 02:07:56 +02:00
co-authored by Claude Opus 5
parent 2d14907d0d
commit 5e503463f5
46 changed files with 1568 additions and 592 deletions
+287 -2
View File
@@ -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