From d231e989cf9e9230fc40a7e3c585ae06571bfacb Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 24 Sep 2026 11:44:01 +0200 Subject: [PATCH] =?UTF-8?q?Screenshots=20an=20einen=20Beitrag=20h=C3=A4nge?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/pruef-eintrag-bild.mjs | 313 ++++++++++++++++++++++++++++++++ server/workspace-bereiche.js | 191 ++++++++++++++++++- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/js/bereich.js | 67 +++++++ workspace/aufgaben.html | 38 ++-- workspace/automation.html | 32 ++-- workspace/befinden.html | 34 ++-- workspace/bereich.html | 44 ++--- workspace/bewerben.html | 38 ++-- workspace/bewerbungen.html | 36 ++-- workspace/calls.html | 34 ++-- workspace/chat.html | 38 ++-- workspace/content.html | 34 ++-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 ++-- workspace/entwicklung.html | 36 ++-- workspace/hilfe.html | 30 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 ++-- workspace/leistung.html | 32 ++-- workspace/material.html | 32 ++-- workspace/personen.html | 32 ++-- workspace/profil.html | 36 ++-- workspace/rechte.html | 34 ++-- workspace/report.html | 36 ++-- workspace/scouting.html | 32 ++-- workspace/start.html | 32 ++-- workspace/startcheck.html | 34 ++-- workspace/steckbrief.html | 36 ++-- workspace/support.html | 28 +-- workspace/talente.html | 36 ++-- workspace/team.html | 32 ++-- workspace/teamlage.html | 34 ++-- workspace/teilen.html | 30 +-- workspace/treff-moderation.html | 32 ++-- workspace/treff-regeln.html | 32 ++-- workspace/uebersicht.html | 32 ++-- workspace/unsere-seiten.html | 30 +-- workspace/werdegang.html | 32 ++-- workspace/willkommen.html | 30 +-- workspace/wissen.html | 34 ++-- 43 files changed, 1184 insertions(+), 615 deletions(-) create mode 100644 server/pruef-eintrag-bild.mjs 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`. --> - - + + - +