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