/* =====================================================================
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);