Screenshots an einen Beitrag hängen

Filipe (Runde vom 23.09.2026): „mach das man da bitte screenshots
oder kurzschnitte von den live reinposten kann. nach dem selben
prinzip wie bei den anderen nebendran."

ES FEHLTE WENIGER, ALS MEINE EIGENE NOTIZ BEHAUPTETE. Dort stand „ein
eigener Brocken (Upload, Groessenpruefung, Sicherheit), kein
Nebenbei". Nachgemessen statt geglaubt: Die Spalte `dateien.eintrag_id`
gibt es seit dem Video-Einlesen, die Karten zeichnen ihren
Bildstreifen bereits, und die Auslieferung entscheidet die
Sichtbarkeit schon am BEITRAG statt an der Ablage. Gefehlt hat genau
ein Weg -- das Hochladen. Wieder ein Beleg dafuer, dass auch meine
eigenen Listen altern.

„NACH DEM SELBEN PRINZIP" IST WOERTLICH GENOMMEN: `dateiErkennen` aus
dem Chat (eine Fassung, drei Benutzer -- Chat, Support, Beitraege),
derselbe Ordner wie die Dateiablage (die Auslieferung kennt nur einen
Pfad), `express.raw` mit Rechtepruefung VOR der Annahme des Rumpfes.

DER KNOPF STEHT AN DER KARTE, nicht im Anlege-Formular. Ein
Bildschirmfoto faellt einem meist spaeter ein -- beim Nachschauen,
wenn jemand fragt. Wer es nur beim Anlegen mitgeben koennte, muesste
den Beitrag loeschen und neu schreiben. Er erscheint nur, solange
noch Platz ist (drei je Beitrag), damit er nie eine Absage bringt.

ZWEI FEHLER IN MEINEM EIGENEN CODE, beide beim ersten Laden gefunden:
`DATEN_ORDNER` war nicht importiert, und `bereichVon()` hatte ich
erfunden -- es gibt sie nicht. Der Bereich steht am Eintrag selbst
und ist dort auch richtiger: Er kommt aus der Datenbank, nicht aus
der Adresse.

UND ZWEI MESSFEHLER, beide dieselbe Sorte wie den ganzen Tag: Ich
fragte „darf die Community?" an einem Beitrag, den sie gar nicht
sieht (404 -- richtige Antwort, falsche Frage), dann an einem
freigegebenen (403 -- sie braucht eine Stufe zum Schreiben, auch das
richtig). Die Frage, die wirklich zaehlt, ist eine andere: Gilt fuer
ein Bild dieselbe Regel wie fuer einen Beitrag? Gemessen: Beitrag
403, Bild 403. Ein zweiter Weg mit anderen Rechten waere die Tuer,
die niemand bemerkt.

Gemessen: pruef-eintrag-bild, 24 Pruefungen, 0 Fehler -- darunter
als Bild getarntes HTML (415), SVG (415, es ist XML und darf Skripte
enthalten), PDF (415), die Grenze von drei am Server, und das
Abnehmen samt Datei. Gruen: pruef-highlights (31), pruef-anhaenge,
pruef-fassungen, pruef-galerie, pruef-video, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-24 11:44:01 +02:00
co-authored by Claude Opus 5
parent 7acd7a7674
commit d231e989cf
43 changed files with 1184 additions and 615 deletions
+190 -1
View File
@@ -15,6 +15,13 @@
===================================================================== */
import express from "express";
/* Fuer das Anhaengen von Bildern an einen Beitrag (24.09.2026).
`dateiErkennen` kommt aus dem Chat -- dort steht die einzige
Fassung, und an ihr haengt die Sicherheit aller Uploads im Haus. */
import { writeFileSync, mkdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { dateiErkennen } from "./workspace-chat.js";
import { TREFF_START, VORSCHLAG_HINWEIS } from "./workspace-treff-start.js";
import { vorlagenFuer } from "./workspace-treff-vorlagen.js";
import { ARBEIT_START } from "./workspace-arbeit-start.js";
@@ -57,8 +64,22 @@ import { darfSeite } from "./rechte.js";
passieren, gewinnt der Treff (er ist oeffentlich, dort waere ein
falscher Text teurer). */
const STARTKATALOG = { ...ARBEIT_START, ...TREFF_START };
/* WOHIN DIE BILDER EINES BEITRAGS GEHEN (24.09.2026).
DERSELBE ORDNER WIE DIE DATEIABLAGE -- und das ist Absicht, keine
Bequemlichkeit: Die Auslieferung (/api/dateien/:id/bild) sucht dort
und kennt nur einen Pfad. Ein eigener Ordner haette eine zweite
Stelle gebraucht, die ihn kennt, und Coverbilder aus dem
Video-Einlesen liegen ohnehin schon hier.
Beide Namen stehen ueber join(DATEN_ORDNER, ...) -- genau dieses
Muster liest tools/wiederherstellung-proben.mjs aus, um zu pruefen,
ob die Sicherung sie mitnimmt. */
const BILD_ORDNER_EINTRAG = join(DATEN_ORDNER, "dateien");
const ABLAGE_ORDNER_EINTRAG = join(DATEN_ORDNER, "dateien");
import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather,
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather,
externPruefen, externSql,
ohneTeamDogi, ohneAgentur, siehtModis, fuehrtTeamDogi, MODI_BEREICHE_ERLAUBT, brettHinterSeiteOffen, TEAM_DOGI_ROLLEN,
TREFF_BRETTER, TREFF_ROLLEN, AUSSEN_ROLLEN,
@@ -2534,6 +2555,174 @@ bereicheRouter.patch("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (re
}
});
/* =====================================================================
EIN BILD AN EINEN BEITRAG (24.09.2026)
Filipe: "mach das man da bitte screenshots oder kurzschnitte von
den live reinposten kann. nach dem selben prinzip wie bei den
anderen nebendran."
"NACH DEM SELBEN PRINZIP" IST WOERTLICH ZU NEHMEN. Die Anzeige gab
es schon: Die Karte zeichnet ihren Bildstreifen aus "e.bilder",
die Auslieferung entscheidet die Sichtbarkeit am BEITRAG (wer das
Brett sehen darf, sieht das Bild), und die Spalte
"dateien.eintrag_id" wird seit dem Video-Einlesen benutzt. Gefehlt
hat genau ein Weg -- dieser.
WER DARF: Wer den Beitrag ueberhaupt sehen darf UND auf diesem
Brett schreiben darf. Beides zusammen, nicht eines davon: "sehen"
allein hiesse, dass die Community Bilder an fremde Beitraege
haengt; "schreiben" allein waere ein Weg, die Existenz eines
Beitrags zu erfahren, den man nicht sehen soll.
DER TYP KOMMT AUS DEM INHALT. dateiErkennen() liest die ersten
Bytes; Dateiname und Content-Type kommen vom Absender und sind
frei erfunden. Dieselbe Funktion wie im Chat und im Support --
eine zweite Fassung waere die, die beim naechsten Format vergessen
wird.
DREI BILDER JE BEITRAG. Nicht als Schikane: Wer fuenf
Bildschirmfotos anhaengt, erklaert damit nichts besser -- er
verschiebt die Arbeit zu dem, der sie ansehen muss. Und der
Bildstreifen einer Karte traegt drei nebeneinander, ohne dass die
Karte auseinanderfaellt.
===================================================================== */
const BILD_MAX_EINTRAG = 12 * 1024 * 1024;
const BILDER_JE_EINTRAG = 3;
/** Vor express.raw: Darf diese Person hier ueberhaupt etwas anhaengen?
*
* Die Berechtigung steht VOR der Annahme des Rumpfes -- sonst
* wanderten zwoelf Megabyte durch die Leitung, nur um danach
* verworfen zu werden, und jeder Angemeldete koennte an jeder
* Beitragsnummer Speicher verbrauchen. Dieselbe Reihenfolge wie beim
* Chat-Anhang. */
function darfBildAnhaengen(req, res, next) {
const bereich = String(req.params.bereich);
const id = Number(req.params.id);
if (!BEREICHE[bereich] || !Number.isInteger(id)) {
return res.status(400).json({ fehler: "ungueltig" });
}
const eintrag = holen(req, id);
if (!eintrag || eintrag.bereich !== bereich) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const warum = darfSchreiben(req.person, bereich);
if (warum) return res.status(403).json({ fehler: warum });
req.eintragFuerBild = eintrag;
next();
}
bereicheRouter.post("/workspace/api/bereich/:bereich/:id(\\d+)/bild",
gleicheHerkunft,
darfBildAnhaengen,
express.raw({ type: "*/*", limit: BILD_MAX_EINTRAG }),
(req, res) => {
let geschrieben = null;
try {
const eintrag = req.eintragFuerBild;
if (!Buffer.isBuffer(req.body) || !req.body.length) {
return res.status(400).json({ fehler: "Keine Datei empfangen." });
}
const erkannt = dateiErkennen(req.body);
if (!erkannt || erkannt.art !== "bild") {
return res.status(415).json({
fehler: "Hier geht ein Foto - PNG, JPEG, WebP oder GIF.",
});
}
const schon = db().prepare(
"SELECT COUNT(*) AS n FROM dateien WHERE eintrag_id = ?").get(eintrag.id).n;
if (schon >= BILDER_JE_EINTRAG) {
return res.status(409).json({
fehler: "An diesem Beitrag haengen schon "
+ BILDER_JE_EINTRAG + " Bilder. Nimm erst eines weg.",
});
}
mkdirSync(BILD_ORDNER_EINTRAG, { recursive: true });
const dateiname = Date.now().toString(36) + "-"
+ randomBytes(8).toString("hex") + erkannt.endung;
writeFileSync(join(BILD_ORDNER_EINTRAG, dateiname), req.body, { flag: "wx" });
geschrieben = join(BILD_ORDNER_EINTRAG, dateiname);
let roh = "";
try { roh = decodeURIComponent(req.get("x-name") || ""); } catch { roh = ""; }
const name = (roh.replace(/[\u0000-\u001f\u007f]/g, "").trim()
|| ("bild" + erkannt.endung)).slice(0, 120);
const info = db().prepare(
"INSERT INTO dateien (name_original, name_datei, groesse, typ, status,"
+ " notiz, hochgeladen_von, erstellt, eintrag_id)"
+ " VALUES (?,?,?,?,'entwurf',NULL,?,?,?)")
.run(name, dateiname, req.body.length, erkannt.typ,
req.person.id, new Date().toISOString(), eintrag.id);
protokolliere("eintrag_bild", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
/* `bereichVon` hatte ich erfunden -- es gibt sie nicht. Der
Bereich steht am Eintrag selbst, und dort ist er auch
richtiger: Er kommt aus der Datenbank, nicht aus der
Adresse. */
detail: (eintrag.bereich + " #" + eintrag.id + " " + name).slice(0, 120),
});
res.status(201).json({
id: Number(info.lastInsertRowid),
name,
weg: "/workspace/api/dateien/" + Number(info.lastInsertRowid) + "/bild",
});
} catch (fehler) {
/* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst
sammelt der Ordner Bilder an, auf die nichts zeigt. */
if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } }
console.error("[bereich] Bild anhaengen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ein Bild wieder abnehmen.
*
* WER: wer den Beitrag selbst geschrieben hat -- und wer ihn ohnehin
* loeschen duerfte. Ein Bild, das nicht passt, muss jemand
* abnehmen koennen, ohne den ganzen Beitrag zu entfernen. */
bereicheRouter.delete("/workspace/api/bereich/:bereich/:id(\\d+)/bild/:bild(\\d+)",
gleicheHerkunft, (req, res) => {
try {
const bereich = String(req.params.bereich);
const id = Number(req.params.id);
const eintrag = holen(req, id);
if (!eintrag || eintrag.bereich !== bereich) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const treffBrett = TREFF_BRETTER.includes(bereich);
const darf = treffBrett
? TREFF_TEAM_ROLLEN.has(req.person.rolle) || eintrag.erstellt_von === req.person.id
: istLeitung(req.person) || eintrag.erstellt_von === req.person.id;
if (!darf) return res.status(403).json({ fehler: "nicht_erlaubt" });
const bild = db().prepare(
"SELECT id, name_datei FROM dateien WHERE id = ? AND eintrag_id = ?")
.get(Number(req.params.bild), eintrag.id);
if (!bild) return res.status(404).json({ fehler: "nicht_gefunden" });
db().prepare("DELETE FROM dateien WHERE id = ?").run(bild.id);
try { unlinkSync(join(BILD_ORDNER_EINTRAG, bild.name_datei)); } catch { /* schon weg */ }
/* Auch im Ablage-Ordner nachsehen: Coverbilder aus dem
Video-Einlesen liegen dort, nicht im neuen Ordner. */
try { unlinkSync(join(ABLAGE_ORDNER_EINTRAG, bild.name_datei)); } catch { /* egal */ }
protokolliere("eintrag_bild_weg", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: (bereich + " #" + eintrag.id + " Bild " + bild.id).slice(0, 120),
});
res.json({ ok: true });
} catch (fehler) {
console.error("[bereich] Bild abnehmen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
bereicheRouter.delete("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (req, res) => {
try {
const bereich = String(req.params.bereich);