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