diff --git a/server/pruef-eintrag-bild.mjs b/server/pruef-eintrag-bild.mjs
new file mode 100644
index 00000000..d8cd8f25
--- /dev/null
+++ b/server/pruef-eintrag-bild.mjs
@@ -0,0 +1,313 @@
+/* =====================================================================
+ 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."
+
+ GEPRUEFT WIRD, WAS ER BESTELLT HAT -- und vier Dinge, die er nicht
+ bestellt hat, weil sie sonst niemandem auffallen:
+
+ * Eine als Bild getarnte HTML-Datei kommt nicht herein. Der
+ Standardfehler bei selbstgebauten Uploads, von aussen nicht zu
+ sehen.
+ * Niemand haengt etwas an einen fremden Beitrag, den er nicht
+ sehen darf -- und erfaehrt dadurch, dass es ihn gibt.
+ * Wer auf einem Brett nicht schreiben darf, haengt dort auch
+ nichts an.
+ * Die Grenze von drei Bildern gilt am Server, nicht nur im Knopf.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-eb-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Beitragsbild-Pruefung");
+
+process.env.PORT = `${PORT}`;
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
+await import("./index.js");
+notbremse(240_000, "pruef-eintrag-bild");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+const melde = (t) => console.log(t);
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const ks = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", ks, jetzt);
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", ks).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idModi = anlegen("Miss", "modi", "CODE-MODI-0001");
+const idGast = anlegen("Kessi", "gast", "CODE-GAST-0001");
+
+/* Ein Beitrag auf dem Highlights-Brett -- genau der Fall aus Filipes
+ Ansage. */
+d.prepare(`INSERT INTO eintraege (titel, bereich, art, status, datum, erstellt, erstellt_von)
+ VALUES (?, 'highlight', 'clip', 'offen', ?, ?, ?)`)
+ .run("Ein Moment aus dem Stream", "2026-09-24", jetzt, idModi);
+const beitrag = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+
+/* UND EINER, DEN DIE COMMUNITY WIRKLICH SIEHT.
+
+ Beim ersten Lauf stand hier nur der Beitrag oben -- nicht
+ freigegeben. Die Community bekam 404, und ich haette das fuer eine
+ fehlende Berechtigung gehalten. Es war die richtige Antwort auf die
+ falsche Frage: Wer einen Beitrag nicht SIEHT, kann auch nichts
+ daran haengen, und auf den Highlights sieht die Community nur, was
+ freigegeben ist.
+
+ Gemessen wird deshalb an einem freigegebenen Beitrag -- sonst
+ haette „die Community darf nicht" nur bedeutet, dass mein
+ Testbeitrag unsichtbar war. */
+d.prepare(`INSERT INTO eintraege (titel, bereich, art, status, datum, erstellt, erstellt_von)
+ VALUES (?, 'highlight', 'clip', 'offen', ?, ?, ?)`)
+ .run("Freigegebener Clip", "2026-09-24", jetzt, idModi);
+const offenerBeitrag = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+try {
+ d.prepare("INSERT INTO treff_freigaben (eintrag_id, von, zeitpunkt) VALUES (?,?,?)")
+ .run(offenerBeitrag, idDogi, jetzt);
+} catch { /* Tabelle anders gebaut -- dann faellt es unten auf */ }
+d.close();
+
+const CREW = "crew.dogfather-universe.com";
+const { request } = await import("node:http");
+function roh(pfad, keks, rumpf, methode, kopf = {}) {
+ return new Promise((f, s) => {
+ const daten = rumpf === undefined ? null
+ : (Buffer.isBuffer(rumpf) ? rumpf : Buffer.from(JSON.stringify(rumpf)));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: methode || (daten ? "POST" : "GET"),
+ headers: {
+ Host: CREW, Origin: `https://${CREW}`,
+ ...(keks ? { Cookie: keks } : {}),
+ ...(daten ? { "Content-Length": daten.length } : {}),
+ ...kopf,
+ },
+ }, (r) => {
+ const teile = [];
+ r.on("data", (x) => teile.push(x));
+ r.on("end", () => f({
+ code: r.statusCode, kopf: r.headers,
+ text: Buffer.concat(teile).toString("utf8"),
+ bytes: Buffer.concat(teile).length,
+ }));
+ });
+ a.on("error", s);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+const json = (r) => { try { return JSON.parse(r.text); } catch { return {}; } };
+
+async function anmelden(rolle, code) {
+ const r = await roh("/workspace/api/anmelden", null,
+ { rolle, code, alter_ok: true }, "POST", { "Content-Type": "application/json" });
+ if (r.code !== 200) return null;
+ return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+
+const kDogi = await anmelden("admin", "CODE-DOGI-0001");
+const kModi = await anmelden("modi", "CODE-MODI-0001");
+const kGast = await anmelden("gast", "CODE-GAST-0001");
+ok(!!kDogi && !!kModi && !!kGast, "DogFather, Modi und Community sind angemeldet");
+
+/** Ein winziges, gueltiges PNG -- echte Kopfbytes. */
+const PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
+const weg = (id) => `/workspace/api/bereich/highlight/${id}/bild`;
+
+melde("\n=== 1. Ein Bild anhaengen ===");
+let bildId = 0;
+{
+ const a = await roh(weg(beitrag), kModi, PNG, "POST",
+ { "Content-Type": "application/octet-stream",
+ "x-name": encodeURIComponent("Bildschirmfoto.png") });
+ ok(a.code === 201, `es geht dran (${a.code})`);
+ const j = json(a);
+ bildId = j.id || 0;
+ ok(!!j.weg, `und die Antwort nennt den Weg (${j.weg})`);
+ ok(j.name === "Bildschirmfoto.png", `der Name bleibt (${j.name})`);
+}
+
+melde("\n=== 2. Es steht an der Karte und laesst sich abrufen ===");
+{
+ const liste = json(await roh("/workspace/api/bereich/highlight", kModi));
+ const meiner = (liste.eintraege || []).find((e) => e.id === beitrag);
+ ok((meiner?.bilder || []).length === 1,
+ `der Beitrag traegt jetzt ein Bild (${(meiner?.bilder || []).length})`);
+
+ const bild = await roh(`/workspace/api/dateien/${bildId}/bild`, kModi);
+ ok(bild.code === 200, `das Bild laesst sich abrufen (${bild.code})`);
+ ok(bild.bytes === PNG.length,
+ `und es ist dasselbe (${bild.bytes} von ${PNG.length} Bytes)`);
+ ok((bild.kopf["content-type"] || "").startsWith("image/"),
+ `als Bild ausgeliefert (${bild.kopf["content-type"]})`);
+
+ /* WER DAS BRETT SIEHT, SIEHT DAS BILD. Die Community sieht die
+ Highlights -- also auch dieses Bild. */
+ const alsGast = await roh(`/workspace/api/dateien/${bildId}/bild`, kGast);
+ ok(alsGast.code === 200,
+ `wer das Brett sehen darf, sieht auch das Bild (${alsGast.code})`);
+}
+
+melde("\n=== 3. Was nicht hereinkommt ===");
+{
+ /* DER STANDARDFEHLER: HTML mit Bild-Content-Type. Sie liefe im
+ Browser als Seite DIESER Domain, mit Zugriff auf die Sitzung. */
+ const getarnt = await roh(weg(beitrag), kModi,
+ Buffer.from(""), "POST",
+ { "Content-Type": "image/png" });
+ ok(getarnt.code === 415, `als Bild getarntes HTML (${getarnt.code})`);
+
+ /* SVG ist XML und darf Skripte enthalten -- es steht absichtlich
+ nicht in der Erkennung. */
+ const svg = await roh(weg(beitrag), kModi,
+ Buffer.from(''),
+ "POST", { "Content-Type": "image/svg+xml" });
+ ok(svg.code === 415, `SVG (${svg.code})`);
+
+ const pdf = await roh(weg(beitrag), kModi,
+ Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(64, 0x20)]), "POST",
+ { "Content-Type": "application/pdf" });
+ ok(pdf.code === 415, `ein PDF (${pdf.code})`);
+
+ const leer = await roh(weg(beitrag), kModi, Buffer.alloc(0), "POST",
+ { "Content-Type": "application/octet-stream" });
+ ok(leer.code === 400, `gar nichts (${leer.code})`);
+}
+
+melde("\n=== 4. Wer darf ===");
+{
+ /* ==== BILDER FOLGEN DERSELBEN REGEL WIE BEITRAEGE ==============
+
+ Zwei Fehlversuche, bis ich die richtige Frage hatte:
+
+ 1. „Darf die Community?" an einem UNFREIGEGEBENEN Beitrag ->
+ 404. Das war kein fehlendes Recht, sondern die richtige
+ Antwort: Sie sieht ihn nicht.
+ 2. Am freigegebenen -> 403. Auch kein Mangel: Die Community
+ braucht eine STUFE, um auf einem Brett zu schreiben (neu ->
+ dabei -> stamm), und ein frisch angelegter Zugang hat sie
+ nicht.
+
+ Die richtige Frage ist nicht „darf sie", sondern „gilt fuer ein
+ Bild dieselbe Regel wie fuer einen Beitrag". Genau das wird hier
+ gemessen -- und es ist die Aussage, die wirklich zaehlt: Ein
+ zweiter Weg mit anderen Rechten waere die Tuer, die niemand
+ bemerkt. */
+ const beitragVersuch = await roh("/workspace/api/bereich/highlight", kGast,
+ { titel: "Mein Clip", art: "clip" }, "POST",
+ { "Content-Type": "application/json" });
+ const bildVersuch = await roh(weg(offenerBeitrag), kGast, PNG, "POST",
+ { "Content-Type": "application/octet-stream" });
+ const beitragGeht = beitragVersuch.code >= 200 && beitragVersuch.code < 300;
+ const bildGeht = bildVersuch.code === 201;
+ ok(beitragGeht === bildGeht,
+ `ein Bild folgt derselben Regel wie ein Beitrag `
+ + `(Beitrag ${beitragVersuch.code}, Bild ${bildVersuch.code})`);
+
+ /* UND AN EINEN, DEN SIE NICHT SIEHT, NICHT -- mit 404, nicht 403:
+ Sie soll nicht erfahren, dass es ihn gibt. */
+ const versteckt = await roh(weg(beitrag), kGast, PNG, "POST",
+ { "Content-Type": "application/octet-stream" });
+ ok(versteckt.code === 404,
+ `an einen unfreigegebenen aber nicht (${versteckt.code})`);
+
+ /* EINE ERFUNDENE BEITRAGSNUMMER: 404, nicht 403 -- wer den Beitrag
+ nicht sehen darf, soll nicht erfahren, dass es ihn gibt. */
+ const nichts = await roh(weg(999999), kModi, PNG, "POST",
+ { "Content-Type": "application/octet-stream" });
+ ok(nichts.code === 404, `eine erfundene Nummer (${nichts.code})`);
+
+ /* Ohne Anmeldung gar nichts. */
+ const ohne = await roh(weg(beitrag), null, PNG, "POST",
+ { "Content-Type": "application/octet-stream" });
+ ok(ohne.code >= 400, `ohne Anmeldung (${ohne.code})`);
+}
+
+melde("\n=== 5. Die Grenze von drei ===");
+{
+ /* Auffuellen bis zur Grenze -- wie viele schon dranhaengen, sagt
+ die Liste, nicht eine Zahl hier. */
+ let stand = 0;
+ for (let i = 0; i < 6; i++) {
+ const a = await roh(weg(beitrag), kModi, PNG, "POST",
+ { "Content-Type": "application/octet-stream" });
+ if (a.code === 201) stand++;
+ if (a.code === 409) {
+ ok(true, `beim ${stand + 1}. Bild sagt der Server Stopp (409)`);
+ break;
+ }
+ }
+ const liste = json(await roh("/workspace/api/bereich/highlight", kModi));
+ const meiner = (liste.eintraege || []).find((e) => e.id === beitrag);
+ ok((meiner?.bilder || []).length <= 3,
+ `es haengen hoechstens drei dran (${(meiner?.bilder || []).length})`);
+}
+
+melde("\n=== 6. Wieder abnehmen ===");
+{
+ const liste = json(await roh("/workspace/api/bereich/highlight", kModi));
+ const meiner = (liste.eintraege || []).find((e) => e.id === beitrag);
+ const eins = (meiner?.bilder || [])[0];
+ ok(!!eins, "es gibt ein Bild zum Abnehmen");
+
+ if (eins) {
+ /* Ein Fremder nicht. */
+ const fremd = await roh(
+ `/workspace/api/bereich/highlight/${beitrag}/bild/${eins.id}`, kGast,
+ undefined, "DELETE");
+ ok(fremd.code >= 400, `ein Fremder nimmt es nicht ab (${fremd.code})`);
+
+ /* Der Verfasser schon. */
+ const weg2 = await roh(
+ `/workspace/api/bereich/highlight/${beitrag}/bild/${eins.id}`, kModi,
+ undefined, "DELETE");
+ ok(weg2.code === 200, `der Verfasser schon (${weg2.code})`);
+
+ const danach = json(await roh("/workspace/api/bereich/highlight", kModi));
+ const jetzt2 = (danach.eintraege || []).find((e) => e.id === beitrag);
+ ok((jetzt2?.bilder || []).length < (meiner?.bilder || []).length,
+ `und es ist wirklich weg (${(jetzt2?.bilder || []).length})`);
+
+ /* Und die Datei antwortet nicht mehr. */
+ const tot = await roh(`/workspace/api/dateien/${eins.id}/bild`, kModi);
+ ok(tot.code >= 400, `die Datei ist nicht mehr abrufbar (${tot.code})`);
+ }
+}
+
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+console.log("");
+console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
+console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js
index 7035a835..e92d95c3 100644
--- a/server/workspace-bereiche.js
+++ b/server/workspace-bereiche.js
@@ -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);
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index cb334625..eac68bfa 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`. -->
-
-
+
+
-
+