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`. --> - - + + - +