diff --git a/server/index.js b/server/index.js
index 825b130d..2ef1280c 100644
--- a/server/index.js
+++ b/server/index.js
@@ -19,6 +19,7 @@ import { workspaceRouter, sichtSetzen } from "./workspace.js";
import { aufgabenRouter } from "./workspace-aufgaben.js";
import { zuteilungRouter } from "./workspace-zuteilung.js";
import { willkommenRouter } from "./workspace-willkommen.js";
+import { materialRouter } from "./workspace-material.js";
import { personenRouter } from "./workspace-personen.js";
import { profilRouter } from "./workspace-profil.js";
import { kalenderRouter } from "./workspace-kalender.js";
@@ -279,6 +280,7 @@ app.use(teamlageRouter);
/* VOR aufgabenRouter: Der faengt "/workspace/api/aufgaben/:id" ab und
wuerde "/workspace/api/aufgaben/resuemee" als Aufgabe mit der Nummer
"resuemee" behandeln. Die Reihenfolge ist hier keine Stilfrage. */
+app.use(materialRouter);
app.use(willkommenRouter);
app.use(zuteilungRouter);
app.use(aufgabenRouter);
diff --git a/server/pruef-material.mjs b/server/pruef-material.mjs
new file mode 100644
index 00000000..ae4952a8
--- /dev/null
+++ b/server/pruef-material.mjs
@@ -0,0 +1,444 @@
+/* =====================================================================
+ pruef-material.mjs — BILDER UND VIDEOS ZUM POSTEN, JEDES NUR EINMAL
+ =====================================================================
+
+ Auftrag vom 22.09.2026:
+
+ "sobald ein bild oder ein video was von uns gepostet wurde,
+ runtergeladen wurde, soll direkt blockiert werden so dass man es
+ nicht mehr runterladen kann ... damit nie ein bild zwei mal
+ gepostet wird ... und die rechte hand und dogfather, nur die
+ beiden sollen auch immer sehen koennen wer das bild oder video
+ runtergeladen hat."
+
+ ---------------------------------------------------------------------
+ WAS HIER GEMESSEN WIRD — und warum gerade das:
+
+ (1) DIE EINMALIGKEIT. Der entscheidende Nachweis ist NICHT der
+ gleichzeitige Zugriff: Node arbeitet die beiden Anfragen
+ ohnehin nacheinander ab, und `node:sqlite` ist synchron -- der
+ parallele Fall kann hier gar nicht schiefgehen. Was schiefgehen
+ KANN, ist die Reihenfolge "erst nachsehen, dann schreiben" ohne
+ Bedingung im UPDATE. Genau das faengt der ZWEITE Versuch nach
+ dem ersten ab: Er muss 409 bekommen, und der Vermerk darf sich
+ nicht mehr aendern. Der gleichzeitige Fall steht trotzdem hier --
+ er kostet nichts und faellt auf, falls jemand spaeter ein
+ `await` zwischen Lesen und Schreiben setzt.
+
+ (2) DASS DIE DATEI DANACH WIRKLICH ZU IST. Ein Vermerk in der
+ Datenbank ist keine Sperre, solange die Datei weiter
+ ausgeliefert wird. Geprueft wird der Abruf, nicht das Feld.
+
+ (3) DASS DIE VORSCHAU DIESELBE GRENZE HAT. Sonst waere sie der Weg,
+ ein vergebenes Bild doch noch zu bekommen (Rechtsklick,
+ speichern) -- und die ganze Einmaligkeit eine Behauptung.
+
+ (4) WER WEN SIEHT — fuer alle fuenf Rollen, nicht nur fuer die zwei,
+ die duerfen. Und nicht nur die Angabe `sieht_wer`, sondern das
+ Feld selbst: Eine Liste, die "nein" sagt und die Namen trotzdem
+ mitschickt, waere das Leck.
+
+ (5) DASS DIE COMMUNITY NICHTS EINSTELLT — und das Team schon. Ohne
+ die zweite Haelfte waere die erste auch dann gruen, wenn gar
+ nichts mehr ginge.
+
+ ---------------------------------------------------------------------
+ WARUM node:http UND NICHT fetch: Das Team und die Community melden
+ sich an verschiedenen Waenden an, und welche gilt, entscheidet der
+ Host-Kopf. `fetch` in Node setzt den selbst und ignoriert einen
+ gesetzten -- die Anmeldung ginge an die falsche Wand.
+ ===================================================================== */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request as httpAnfrage } from "node:http";
+import { eigenerPort } from "./helfer-port.mjs";
+import { notbremse } from "./helfer-notbremse.mjs";
+
+const PORT = await eigenerPort(import.meta, "pruef-material");
+
+/* DATEN_ORDNER wird in workspace.js aus dem Pfad der Datenbank
+ abgeleitet (`dirname(DB_PFAD)`) -- das Material landet damit von
+ selbst im Wegwerfordner. Eine eigene Umgebungsvariable dafuer waere
+ eine zweite Wahrheit, die beim naechsten Umbau auseinanderlaeuft. */
+const ordner = mkdtempSync(join(tmpdir(), "ws-material-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+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-material");
+await new Promise((r) => setTimeout(r, 900));
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+let fehler = 0, geprueft = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+const CREW = "crew.dogfather-universe.com";
+
+/* ---------------------------------------------------------------------
+ PERSONEN
+ --------------------------------------------------------------------- */
+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 schluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", schluessel, 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", schluessel).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+anlegen("Rieke", "hand", "CODE-HAND-0001");
+anlegen("Lino", "linke", "CODE-LINK-0001");
+anlegen("Frida", "modi", "CODE-MODI-0001");
+anlegen("Kessi", "gast", "CODE-GAST-0001");
+anlegen("Sam", "gast", "CODE-GAST-0002");
+d.close();
+
+/* ---------------------------------------------------------------------
+ DER DRAHT
+ --------------------------------------------------------------------- */
+function roh(pfad, wie = {}) {
+ return new Promise((fertig, schief) => {
+ const daten = wie.roh !== undefined
+ ? wie.roh
+ : (wie.body === undefined ? null : Buffer.from(JSON.stringify(wie.body)));
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: wie.methode || (daten ? "POST" : "GET"),
+ headers: {
+ Host: CREW,
+ ...(daten ? {
+ "Content-Type": wie.typ || "application/json",
+ "Content-Length": daten.length,
+ } : {}),
+ ...(wie.kopf || {}),
+ ...(wie.keks ? { Cookie: wie.keks } : {}),
+ Origin: `https://${CREW}`,
+ Referer: `https://${CREW}/workspace/`,
+ },
+ }, (antwort) => {
+ const stuecke = [];
+ antwort.on("data", (s) => stuecke.push(s));
+ antwort.on("end", () => {
+ const puffer = Buffer.concat(stuecke);
+ const text = puffer.toString("utf8");
+ fertig({
+ code: antwort.statusCode, kopf: antwort.headers,
+ bytes: puffer.length, text,
+ json: (() => { try { return JSON.parse(text); } catch { return null; } })(),
+ });
+ });
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmelden(rolle, code) {
+ /* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.). */
+ const r = await roh("/workspace/api/anmelden", {
+ body: rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code },
+ });
+ const kekse = [].concat(r.kopf?.["set-cookie"] || []);
+ return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code };
+}
+
+const chef = await anmelden("admin", "CODE-DOGI-0001");
+const hand = await anmelden("hand", "CODE-HAND-0001");
+const linke = await anmelden("linke", "CODE-LINK-0001");
+const modi = await anmelden("modi", "CODE-MODI-0001");
+const kessi = await anmelden("gast", "CODE-GAST-0001");
+const sam = await anmelden("gast", "CODE-GAST-0002");
+
+melde("");
+melde("=== 0. Alle sechs kommen ueberhaupt herein ===");
+for (const [wer, s] of [["DogFather", chef], ["rechte Hand", hand],
+ ["linke Hand", linke], ["Modi", modi], ["Kessi", kessi], ["Sam", sam]]) {
+ ok(s.code === 200 && s.keks.length > 0, `${wer} ist angemeldet (${s.code})`);
+}
+
+/* Ein echtes, winziges PNG (1x1). Der Server prueft die Art -- ein
+ erfundener Byteblock haette den falschen Typ und die Pruefung waere
+ aus dem falschen Grund gruen. */
+const PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64");
+
+function einstellen(keks, name, text = "") {
+ return roh("/workspace/api/material", {
+ methode: "POST", roh: PNG, typ: "image/png", keks,
+ kopf: {
+ "X-Datei-Name": encodeURIComponent(name),
+ "X-Text": encodeURIComponent(text),
+ },
+ });
+}
+const nehmen = (id, keks) =>
+ roh(`/workspace/api/material/${id}/nehmen`, { methode: "POST", keks });
+const liste = (keks) => roh("/workspace/api/material", { keks });
+
+/* =====================================================================
+ 1. WER ETWAS BEREITSTELLEN DARF
+ ===================================================================== */
+melde("");
+melde("=== 1. Wer etwas bereitstellen darf ===");
+{
+ for (const [wer, s] of [["DogFather", chef], ["die rechte Hand", hand],
+ ["die linke Hand", linke], ["ein Modi", modi]]) {
+ const a = await einstellen(s.keks, `Von ${wer}.png`, "Fuer TikTok");
+ ok(a.code === 201 && Number.isInteger(a.json?.id),
+ `${wer} stellt etwas bereit (${a.code}, Nummer ${a.json?.id})`);
+ }
+
+ /* DIE GEGENPROBE, und sie ist Filipes ausdruecklicher Satz:
+ "die community soll keine posten koennen in die kategorie". */
+ const g = await einstellen(kessi.keks, "Von der Community.png");
+ ok(g.code === 403, `die Community NICHT (${g.code})`);
+ ok(g.json?.fehler === "nur_team_stellt_ein",
+ `und bekommt einen Grund, zu dem es einen Satz gibt (${g.json?.fehler})`);
+
+ /* Und es ist auch wirklich nichts angekommen -- eine 403, nach der
+ die Datei trotzdem in der Liste steht, waere die schlimmere Sorte. */
+ const l = await liste(chef.keks);
+ const vonKessi = (l.json?.material || []).filter((m) => m.von === "Kessi").length;
+ ok(vonKessi === 0, `und es liegt auch nichts von ihr da (${vonKessi})`);
+}
+
+/* =====================================================================
+ 2. WAS NICHT HINEINGEHOERT
+ ===================================================================== */
+melde("");
+melde("=== 2. Was nicht hineingehoert ===");
+{
+ const a = await roh("/workspace/api/material", {
+ methode: "POST", roh: Buffer.from("MZ-Programm"),
+ typ: "application/octet-stream", keks: chef.keks,
+ kopf: { "X-Datei-Name": encodeURIComponent("schadprogramm.exe") },
+ });
+ ok(a.code === 400, `ein Programm wird abgelehnt (${a.code})`);
+ ok(a.json?.fehler === "art_nicht_erlaubt", `mit Grund (${a.json?.fehler})`);
+
+ const b = await roh("/workspace/api/material", {
+ methode: "POST", roh: Buffer.alloc(0), typ: "image/png", keks: chef.keks,
+ });
+ ok(b.code === 400 && b.json?.fehler === "datei_fehlt",
+ `eine leere Datei ebenso (${b.code}, ${b.json?.fehler})`);
+}
+
+/* =====================================================================
+ 3. DER KERN: JEDES NUR EINMAL
+ ===================================================================== */
+melde("");
+melde("=== 3. Jedes nur einmal ===");
+{
+ const a = await einstellen(chef.keks, "Umkaempft.png", "Wer zuerst kommt");
+ const id = a.json?.id;
+ ok(Number.isInteger(id), `ein Stueck zum Wettlauf liegt bereit (${id})`);
+
+ /* Beide Anfragen gehen los, bevor die erste beantwortet ist. */
+ const [x, y] = await Promise.all([nehmen(id, kessi.keks), nehmen(id, sam.keks)]);
+ const gewonnen = [x, y].filter((r) => r.code === 200).length;
+ const verloren = [x, y].filter((r) => r.code === 409).length;
+ ok(gewonnen === 1, `genau einer bekommt es (${gewonnen} von 2)`);
+ ok(verloren === 1, `und genau einer bekommt "schon vergeben" (${verloren})`);
+ ok([x, y].find((r) => r.code === 409)?.json?.fehler === "schon_genommen",
+ `mit Grund (${[x, y].find((r) => r.code === 409)?.json?.fehler})`);
+
+ /* DER EIGENTLICHE NACHWEIS: Wer danach kommt, bekommt nichts -- und
+ der Vermerk darf sich nicht mehr aendern. Ein Weg ohne Bedingung
+ im UPDATE wuerde hier den zweiten Namen eintragen, und die Liste
+ saehe danach voellig normal aus. */
+ const vorName = (await liste(chef.keks)).json?.material
+ ?.find((m) => m.id === id)?.genommen_von;
+ const z = await nehmen(id, modi.keks);
+ ok(z.code === 409, `ein Dritter danach ebenso wenig (${z.code})`);
+ const nachName = (await liste(chef.keks)).json?.material
+ ?.find((m) => m.id === id)?.genommen_von;
+ ok(!!vorName && vorName === nachName,
+ `und der Vermerk bleibt stehen (${vorName} -> ${nachName})`);
+}
+
+/* =====================================================================
+ 4. DIE DATEI IST DANACH WIRKLICH ZU
+ ===================================================================== */
+melde("");
+melde("=== 4. Die Datei ist danach wirklich zu ===");
+{
+ const a = await einstellen(chef.keks, "Abgeschlossen.png");
+ const id = a.json?.id;
+
+ const vorher = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks });
+ ok(vorher.code === 403,
+ `vor dem Nehmen bekommt sie niemand (${vorher.code})`);
+
+ const genommen = await nehmen(id, kessi.keks);
+ ok(genommen.code === 200, `Kessi nimmt es (${genommen.code})`);
+
+ const seine = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks });
+ ok(seine.code === 200 && seine.bytes === PNG.length,
+ `und bekommt die Datei (${seine.code}, ${seine.bytes} von ${PNG.length} Bytes)`);
+ ok(/attachment/.test(String(seine.kopf?.["content-disposition"] || "")),
+ "als Download, nicht zum Anschauen im Browser");
+
+ /* DER RIEGEL. Ein Vermerk in der Datenbank ist keine Sperre,
+ solange die Datei weiter ausgeliefert wird. */
+ for (const [wer, s] of [["ein anderer aus der Community", sam],
+ ["ein Modi", modi], ["die linke Hand", linke],
+ ["sogar die rechte Hand", hand]]) {
+ const r = await roh(`/workspace/api/material/${id}/datei`, { keks: s.keks });
+ ok(r.code === 403, `${wer} bekommt sie nicht (${r.code})`);
+ }
+
+ /* UND DIE VORSCHAU HAT DIESELBE GRENZE -- sonst waere sie der Weg,
+ es doch noch zu bekommen. */
+ const schauFremd = await roh(`/workspace/api/material/${id}/vorschau`, { keks: sam.keks });
+ ok(schauFremd.code === 403,
+ `auch die Vorschau ist fuer andere zu (${schauFremd.code})`);
+ const schauSeine = await roh(`/workspace/api/material/${id}/vorschau`, { keks: kessi.keks });
+ ok(schauSeine.code === 200 && schauSeine.bytes === PNG.length,
+ `wer es genommen hat, sieht es weiter (${schauSeine.code})`);
+}
+
+/* =====================================================================
+ 5. SOLANGE ES FREI IST, DARF JEDER HINSEHEN
+ =====================================================================
+ Die Gegenprobe zu Abschnitt 4: Waere die Vorschau schlicht immer
+ gesperrt, waeren alle Zeilen oben ebenfalls gruen -- und niemand
+ koennte je entscheiden, ob ein Bild zu seinem Kanal passt. */
+melde("");
+melde("=== 5. Solange es frei ist, darf jeder hinsehen ===");
+{
+ const a = await einstellen(chef.keks, "Noch frei.png");
+ const id = a.json?.id;
+ for (const [wer, s] of [["die Community", kessi], ["ein Modi", modi],
+ ["die linke Hand", linke], ["die rechte Hand", hand]]) {
+ const r = await roh(`/workspace/api/material/${id}/vorschau`, { keks: s.keks });
+ ok(r.code === 200 && r.bytes === PNG.length,
+ `${wer} sieht die Vorschau (${r.code}, ${r.bytes} Bytes)`);
+ }
+}
+
+/* =====================================================================
+ 6. WER SIEHT, WER ES GENOMMEN HAT
+ =====================================================================
+ "die rechte hand und dogfather, NUR die beiden" -- gemessen fuer
+ alle fuenf Rollen, und nicht nur an der Angabe `sieht_wer`, sondern
+ am Feld selbst. */
+melde("");
+melde("=== 6. Wer sieht, wer es genommen hat ===");
+{
+ const soll = { admin: true, hand: true, linke: false, modi: false, gast: false };
+ const kekse = { admin: chef, hand, linke, modi, gast: kessi };
+ let vergebenGesamt = 0;
+ for (const [rolle, darf] of Object.entries(soll)) {
+ const a = await liste(kekse[rolle].keks);
+ ok(a.code === 200, `${rolle}: die Liste kommt (${a.code})`);
+ ok(a.json?.sieht_wer === darf,
+ ` und "sieht_wer" ist ${darf} (${a.json?.sieht_wer})`);
+
+ const vergeben = (a.json?.material || []).filter((m) => m.genommen);
+ const mitNamen = vergeben.filter((m) => m.genommen_von !== undefined).length;
+ vergebenGesamt = Math.max(vergebenGesamt, vergeben.length);
+ ok(vergeben.length > 0 && (darf ? mitNamen === vergeben.length : mitNamen === 0),
+ ` ${vergeben.length} vergeben, davon ${mitNamen} mit Namen`);
+ }
+ /* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Ohne vergebene Stuecke waere
+ "keine Namen dabei" trivial wahr. */
+ ok(vergebenGesamt >= 2,
+ `es lagen ueberhaupt vergebene Stuecke vor (${vergebenGesamt})`);
+}
+
+/* =====================================================================
+ 7. JEDER SIEHT, WAS ER SELBST GENOMMEN HAT
+ ===================================================================== */
+melde("");
+melde("=== 7. Jeder sieht, was er selbst genommen hat ===");
+{
+ /* SAM MUSS ERST WIRKLICH ETWAS HABEN. Im Wettlauf oben hat Kessi
+ gewonnen -- ohne diesen Schritt vergliche die Gegenprobe unten
+ zwei Nullen und waere gruen, ohne irgendetwas gesehen zu haben.
+ Genau der gruene Haken ueber der Null. */
+ const fuerSam = await einstellen(chef.keks, "Fuer Sam.png");
+ const holt = await nehmen(fuerSam.json?.id, sam.keks);
+ ok(holt.code === 200, `Sam nimmt sich eines (${holt.code})`);
+
+ const meine = ((await liste(kessi.keks)).json?.material || [])
+ .filter((m) => m.von_mir_genommen);
+ ok(meine.length >= 1, `Kessi sieht ihre eigenen (${meine.length})`);
+
+ const fremde = ((await liste(sam.keks)).json?.material || [])
+ .filter((m) => m.von_mir_genommen);
+ const ids = new Set(meine.map((m) => m.id));
+ const verwechselt = fremde.filter((m) => ids.has(m.id)).length;
+ ok(verwechselt === 0,
+ `und keines davon steht bei Sam als "von mir" (${verwechselt})`);
+
+ /* Gegenprobe gegen die Liste des DogFathers: Sams eigene Sicht muss
+ zu dem passen, was dort mit seinem Namen steht. */
+ const samHat = ((await liste(chef.keks)).json?.material || [])
+ .filter((m) => m.genommen_von === "Sam").length;
+ ok(samHat > 0 && fremde.length === samHat,
+ `Sams eigene Sicht deckt sich mit der Liste des DogFathers (${fremde.length} / ${samHat})`);
+}
+
+/* =====================================================================
+ 8. DIE AGENTUR HAT HIER NICHTS
+ ===================================================================== */
+melde("");
+melde("=== 8. Die Grenzen des Bereichs ===");
+{
+ const { darfSeite } = await import("./rechte.js");
+ for (const r of ["admin", "hand", "linke", "modi", "gast"]) {
+ ok(darfSeite({ rolle: r, haus: "crew" }, "/workspace/material.html"),
+ `${r} darf die Seite oeffnen`);
+ }
+ for (const r of ["creator", "manager", "scout", "spicy"]) {
+ ok(!darfSeite({ rolle: r, haus: "agentur" }, "/workspace/material.html"),
+ `${r} aus der Agentur nicht`);
+ }
+
+ /* Und die Kachel gibt es -- sonst waere die Seite da und niemand
+ faende sie. Abgeleitet aus derselben Quelle wie die Startseite. */
+ const w = await import("./workspace.js");
+ for (const r of ["gast", "modi", "hand", "linke", "admin"]) {
+ const treffer = [...(w.bereicheFuer({ rolle: r, haus: "crew" }) || [])]
+ .filter((k) => k.ziel === "material.html");
+ ok(treffer.length === 1 && treffer[0].name === "Material",
+ `${r} hat genau eine Kachel dorthin (${treffer.length}, "${treffer[0]?.name}")`);
+ }
+}
+
+/* =====================================================================
+ ERGEBNIS
+ ===================================================================== */
+melde("");
+/* Die Zahl gehoert in die Bedingung, nicht nur in den Text: Ein Lauf,
+ der nichts geprueft hat, ist kein bestandener Lauf. */
+if (geprueft < 40) {
+ console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 40+`);
+ fehler++;
+}
+melde(fehler === 0
+ ? `Alles in Ordnung. ${geprueft} Pruefungen, 0 Fehler.`
+ : `${fehler} FEHLER bei ${geprueft} Pruefungen.`);
+melde("");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index 71306585..ff66ece8 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -315,6 +315,12 @@ export const SEITEN = {
Erklaerung, nicht den Zugang. */
"/workspace/willkommen.html": [...ALLE, "gast"],
+ /* MATERIAL (22.09.2026). Dieselben Rollen wie der Treff: Das Team
+ stellt ein, die Community holt sich etwas. WER was darf, steht
+ nicht hier, sondern in workspace-material.js -- diese Tafel
+ entscheidet nur, wer die Seite oeffnen darf. */
+ "/workspace/material.html": ["gast", "modi", "hand", "linke", "admin"],
+
"/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"],
/* DIE ANRUF-PROBE (19.09.2026). Sie sagt einem Menschen in seinem
eigenen Browser, warum es nicht klingelt -- die Frage, die sich
diff --git a/server/workspace-material.js b/server/workspace-material.js
new file mode 100644
index 00000000..094bd2ce
--- /dev/null
+++ b/server/workspace-material.js
@@ -0,0 +1,389 @@
+/* =====================================================================
+ MATERIAL — was das Team bereitstellt und die Community postet
+ =====================================================================
+
+ Auftrag vom 22.09.2026:
+
+ *"ich brauch auch noch eine neue kachel im bereich community, wo
+ wir als team also, dogfather, rechte hand, linke hand und modis
+ bilder reinschicken können, mit text und wo die leute sei es
+ community oder modis sich die bilder nehmen können zum posten.
+ die community soll keine posten können in die kategorie, die
+ sollen die runterladen können um die auf tiktok oder insta zu
+ posten. sobald ein bild oder ein video was von uns gepostet
+ wurde, runtergeladen wurde, soll direkt blockiert werden so dass
+ man es nicht mehr runterladen kann, es soll direkt markiert
+ werden als schon benutz, damit nie ein bild zwei mal gepostet
+ wird ... und die rechte hand und dogfather, nur die beiden sollen
+ auch immer sehen können wer das bild oder video runtergeladen
+ hat."*
+
+ ---------------------------------------------------------------------
+ DER KERN IST DIE EINMALIGKEIT, und sie ist der Grund, warum das
+ Nehmen und das Herunterladen ZWEI Schritte sind:
+
+ "nehmen" schreibt in EINER Abfrage fest, wer es hat -- mit der
+ Bedingung `genommen_von IS NULL`. Wer zu spaet kommt,
+ aendert null Zeilen und bekommt 409. Zwei Menschen
+ koennen dasselbe Bild damit nicht gleichzeitig holen,
+ auch wenn sie im selben Augenblick tippen.
+ "datei" liefert erst danach aus, und nur an den, der es
+ genommen hat.
+
+ Ein einziger Schritt ("laden und dabei markieren") haette dieselbe
+ Luecke wie ein Pool ohne Sperre: Zwei Anfragen, beide sehen "frei",
+ beide laden. Genau dieser Fall ist am 21.09. beim Aufgaben-Pool
+ schon einmal gebaut und gemessen worden.
+
+ ---------------------------------------------------------------------
+ WER WAS DARF -- und warum nicht mehr:
+
+ einstellen Team Dogi: DogFather, rechte und linke Hand, Modis.
+ Die Community NICHT -- Filipe ausdruecklich.
+ nehmen alle, die den Treff sehen (also auch die Community).
+ wer hat es NUR DogFather und die rechte Hand. Nicht die linke,
+ nicht die Modis -- wer etwas genommen hat, soll sich
+ nicht beobachtet fuehlen.
+ loeschen wer es eingestellt hat, plus DogFather.
+
+ ---------------------------------------------------------------------
+ WAS AUSDRUECKLICH NICHT GEHT: ein genommenes Stueck wieder
+ freigeben. Das waere der Weg, ueber den doch etwas zweimal
+ gepostet wird -- und genau das soll nicht passieren. Wer sich
+ vertan hat, sagt es; DogFather stellt es neu ein. Ein Klick, der
+ eine Zusage zuruecknimmt, ist hier gefaehrlicher als ein Umweg.
+ ===================================================================== */
+
+import express from "express";
+import { join, extname } from "node:path";
+import { mkdirSync, writeFileSync, createReadStream, unlinkSync, statSync } from "node:fs";
+import { randomBytes } from "node:crypto";
+import {
+ db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER,
+ istDogFather, TEAM_DOGI_ROLLEN, TREFF_ROLLEN,
+} from "./workspace.js";
+
+export const materialRouter = express.Router();
+
+const ORDNER = join(DATEN_ORDNER, "material");
+const MAX_BYTES = 60 * 1024 * 1024; /* 60 MB -- ein kurzes Video passt. */
+const TEXT_MAX = 600;
+const NAME_MAX = 160;
+
+/* Was wir annehmen. Absichtlich eine kurze Liste: Alles, was ein
+ Browser nicht von selbst als Bild oder Video anzeigt, ist hier
+ entweder ein Versehen oder ein Versuch. */
+const ARTEN = {
+ "image/jpeg": "bild", "image/png": "bild", "image/webp": "bild",
+ "image/gif": "bild", "image/avif": "bild",
+ "video/mp4": "video", "video/quicktime": "video", "video/webm": "video",
+};
+
+/* ---------------------------------------------------------------------
+ DIE ABLAGE
+ --------------------------------------------------------------------- */
+let bereit = false;
+function tabellen() {
+ if (bereit) return;
+ const d = db();
+ d.exec(`
+ CREATE TABLE IF NOT EXISTS material (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ datei TEXT NOT NULL,
+ dateiname TEXT NOT NULL,
+ typ TEXT NOT NULL,
+ art TEXT NOT NULL CHECK (art IN ('bild','video')),
+ groesse INTEGER NOT NULL DEFAULT 0,
+ text TEXT NOT NULL DEFAULT '',
+ von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ erstellt TEXT NOT NULL,
+ genommen_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ genommen_am TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_material_frei
+ ON material (genommen_von, erstellt);
+ `);
+ bereit = true;
+}
+
+/* ---------------------------------------------------------------------
+ WER DARF WAS
+ ---------------------------------------------------------------------
+
+ ABGELEITET, NICHT ABGESCHRIEBEN: `TEAM_DOGI_ROLLEN` und
+ `TREFF_ROLLEN` stehen in workspace.js und entscheiden dort schon,
+ wer zum Team und wer zum Treff gehoert. Eine eigene Liste hier
+ waere die, die beim naechsten neuen Rollennamen vergessen wird. */
+
+/** Wer Material einstellen darf: das Team, nicht die Community. */
+export const darfMaterialEinstellen = (person) =>
+ !!person && (istDogFather(person) || TEAM_DOGI_ROLLEN.has(person.rolle));
+
+/** Wer sich etwas nehmen darf: jeder, der den Treff sieht. */
+export const darfMaterialNehmen = (person) =>
+ !!person && TREFF_ROLLEN.has(person.rolle);
+
+/** Wer sehen darf, WER sich etwas genommen hat. Nur diese zwei. */
+export const siehtWerGenommenHat = (person) =>
+ !!person && (istDogFather(person) || person.rolle === "hand");
+
+function angemeldet(req, res, next) {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ /* 404 UND NICHT 403: Wer "kein Zugriff" liest, weiss, dass es hier
+ etwas gibt. Dieselbe Regel wie ueberall im Haus. */
+ if (!darfMaterialNehmen(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ req.person = person;
+ tabellen();
+ next();
+}
+
+materialRouter.use("/workspace/api/material", angemeldet);
+
+/* ---------------------------------------------------------------------
+ DIE LISTE
+ --------------------------------------------------------------------- */
+materialRouter.get("/workspace/api/material", (req, res) => {
+ try {
+ const zeigtNamen = siehtWerGenommenHat(req.person);
+ const reihen = db().prepare(`
+ SELECT m.id, m.dateiname, m.art, m.groesse, m.text, m.erstellt,
+ m.genommen_am, m.genommen_von,
+ v.name AS von_name,
+ g.name AS genommen_name
+ FROM material m
+ LEFT JOIN personen v ON v.id = m.von_id
+ LEFT JOIN personen g ON g.id = m.genommen_von
+ ORDER BY (m.genommen_von IS NOT NULL), m.erstellt DESC
+ `).all();
+
+ res.json({
+ darf_einstellen: darfMaterialEinstellen(req.person),
+ sieht_wer: zeigtNamen,
+ max_bytes: MAX_BYTES,
+ material: reihen.map((z) => ({
+ id: z.id,
+ name: z.dateiname,
+ art: z.art,
+ groesse: z.groesse,
+ text: z.text,
+ von: z.von_name || "Jemand",
+ erstellt: z.erstellt,
+ genommen: !!z.genommen_von,
+ /* WER ES GENOMMEN HAT, STEHT NUR FUER ZWEI DRIN. Fuer alle
+ anderen fehlt das Feld ganz -- nicht `null`: Ein Feld, das
+ da ist und leer bleibt, laedt dazu ein, es spaeter "zu
+ fuellen". */
+ ...(zeigtNamen && z.genommen_von
+ ? { genommen_von: z.genommen_name || "Jemand", genommen_am: z.genommen_am }
+ : {}),
+ /* Und jeder sieht, ob ER es war -- sonst wuesste er nicht mehr,
+ was er selbst schon geholt hat. */
+ von_mir_genommen: z.genommen_von === req.person.id,
+ })),
+ });
+ } catch (f) {
+ console.error("[material] Liste:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------------------------------------------------------------------
+ EINSTELLEN
+ ---------------------------------------------------------------------
+
+ Die Datei kommt roh im Rumpf, Name und Text in Kopfzeilen -- genau
+ wie in workspace-dateien.js. Kein Formularparser, keine neue
+ Abhaengigkeit. */
+materialRouter.post(
+ "/workspace/api/material",
+ (req, res, next) => {
+ /* STEHT VOR express.raw: Wer nicht einstellen darf, soll nicht
+ erst 60 MB hochladen duerfen, um dann eine Absage zu bekommen. */
+ if (!darfMaterialEinstellen(req.person)) {
+ return res.status(403).json({ fehler: "nur_team_stellt_ein" });
+ }
+ next();
+ },
+ express.raw({ type: "*/*", limit: MAX_BYTES }),
+ (req, res) => {
+ try {
+ if (!req.body || !req.body.length) {
+ return res.status(400).json({ fehler: "datei_fehlt" });
+ }
+ const typ = String(req.get("content-type") || "").split(";")[0].trim().toLowerCase();
+ const art = ARTEN[typ];
+ if (!art) return res.status(400).json({ fehler: "art_nicht_erlaubt" });
+
+ let name = "";
+ try { name = decodeURIComponent(req.get("x-datei-name") || ""); } catch { name = ""; }
+ name = name.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX)
+ || (art === "bild" ? "Bild" : "Video");
+
+ let text = "";
+ try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; }
+ text = text.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, TEXT_MAX);
+
+ mkdirSync(ORDNER, { recursive: true });
+ const endung = (extname(name).match(/^\.[A-Za-z0-9]{1,8}$/) || [""])[0].toLowerCase();
+ const datei = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`;
+ writeFileSync(join(ORDNER, datei), req.body, { flag: "wx" });
+
+ const { lastInsertRowid } = db().prepare(`
+ INSERT INTO material (datei, dateiname, typ, art, groesse, text, von_id, erstellt)
+ VALUES (?,?,?,?,?,?,?,?)
+ `).run(datei, name, typ, art, req.body.length, text, req.person.id,
+ new Date().toISOString());
+
+ protokolliere(req.person.id, "material_eingestellt",
+ `${art} "${name}" (${req.body.length} Bytes)`, echteIp(req));
+ res.status(201).json({ id: Number(lastInsertRowid) });
+ } catch (f) {
+ console.error("[material] Einstellen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ },
+);
+
+/* ---------------------------------------------------------------------
+ NEHMEN — der Schritt, der es einmalig macht
+ --------------------------------------------------------------------- */
+materialRouter.post("/workspace/api/material/:id/nehmen", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "gibt_es_nicht" });
+
+ const vorher = db().prepare("SELECT id, genommen_von FROM material WHERE id = ?").get(id);
+ if (!vorher) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* EINE ABFRAGE ENTSCHEIDET. `genommen_von IS NULL` in der
+ Bedingung heisst: Wer zu spaet kommt, aendert null Zeilen. Zwei
+ Menschen koennen dasselbe Stueck damit nicht gleichzeitig
+ holen, auch wenn sie im selben Augenblick tippen. */
+ const wirkung = db().prepare(`
+ UPDATE material SET genommen_von = ?, genommen_am = ?
+ WHERE id = ? AND genommen_von IS NULL
+ `).run(req.person.id, new Date().toISOString(), id);
+
+ if (!wirkung.changes) {
+ const wer = siehtWerGenommenHat(req.person)
+ ? db().prepare(`SELECT p.name FROM material m
+ LEFT JOIN personen p ON p.id = m.genommen_von
+ WHERE m.id = ?`).get(id)?.name
+ : null;
+ return res.status(409).json({
+ fehler: "schon_genommen",
+ ...(wer ? { wer } : {}),
+ });
+ }
+
+ protokolliere(req.person.id, "material_genommen", `Material ${id}`, echteIp(req));
+ res.json({ ok: true, weg: `/workspace/api/material/${id}/datei` });
+ } catch (f) {
+ console.error("[material] Nehmen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------------------------------------------------------------------
+ DIE VORSCHAU — jeder darf SEHEN, was frei ist
+ ---------------------------------------------------------------------
+
+ Ohne sie muesste man blind entscheiden: Ein Dateiname sagt nicht, ob
+ ein Bild zum eigenen Kanal passt. Und wer blind nimmt, verbrennt ein
+ Stueck, das jemand anders gut gebrauchen koennte -- bei genau einem
+ Versuch je Bild waere das teuer.
+
+ SIE GILT NUR FUER FREIES MATERIAL. Was vergeben ist, wird nicht mehr
+ ausgeliefert -- sonst waere die Vorschau der Weg, ein Bild doch noch
+ zu bekommen (Rechtsklick, speichern), und die ganze Einmaligkeit
+ waere eine Behauptung.
+
+ AUSNAHME: Wer es selbst genommen hat, sieht es weiter -- sonst
+ haette er nach dem Laden ein leeres Feld vor sich und wuesste nicht
+ mehr, was er geholt hat. */
+materialRouter.get("/workspace/api/material/:id/vorschau", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(404).end();
+ const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id);
+ if (!z) return res.status(404).end();
+ if (z.genommen_von && z.genommen_von !== req.person.id
+ && !istDogFather(req.person)) {
+ return res.status(403).end();
+ }
+ const pfad = join(ORDNER, z.datei);
+ try { statSync(pfad); } catch { return res.status(410).end(); }
+ res.setHeader("Content-Type", z.typ);
+ /* KURZ ZWISCHENSPEICHERN, ABER PRIVAT: Wer die Liste zweimal
+ oeffnet, soll die Bilder nicht zweimal laden -- aber sie duerfen
+ nie in einem gemeinsamen Zwischenspeicher landen. */
+ res.setHeader("Cache-Control", "private, max-age=300");
+ createReadStream(pfad).pipe(res);
+ } catch (f) {
+ console.error("[material] Vorschau:", f?.message);
+ res.status(503).end();
+ }
+});
+
+/* ---------------------------------------------------------------------
+ DIE DATEI — nur an den, der sie genommen hat
+ --------------------------------------------------------------------- */
+materialRouter.get("/workspace/api/material/:id/datei", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* WER SIE BEKOMMT: der, der sie genommen hat -- und DogFather, der
+ nachsehen koennen muss, was er verteilt hat. Sonst niemand;
+ auch nicht, wer sie eingestellt hat, denn dann waere die Sperre
+ ueber den Umweg "ich stelle es selbst ein" offen. */
+ const meins = z.genommen_von === req.person.id;
+ if (!meins && !istDogFather(req.person)) {
+ return res.status(403).json({ fehler: "nicht_genommen" });
+ }
+
+ const pfad = join(ORDNER, z.datei);
+ try { statSync(pfad); } catch { return res.status(410).json({ fehler: "datei_weg" }); }
+
+ res.setHeader("Content-Type", z.typ);
+ res.setHeader("Content-Disposition",
+ `attachment; filename*=UTF-8''${encodeURIComponent(z.dateiname)}`);
+ res.setHeader("Cache-Control", "private, no-store");
+ createReadStream(pfad).pipe(res);
+ } catch (f) {
+ console.error("[material] Datei:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------------------------------------------------------------------
+ WEGNEHMEN
+ --------------------------------------------------------------------- */
+materialRouter.delete("/workspace/api/material/:id", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const z = db().prepare("SELECT * FROM material WHERE id = ?").get(id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (z.von_id !== req.person.id && !istDogFather(req.person)) {
+ return res.status(403).json({ fehler: "nicht_deins" });
+ }
+ db().prepare("DELETE FROM material WHERE id = ?").run(id);
+ try { unlinkSync(join(ORDNER, z.datei)); } catch { /* schon weg */ }
+ protokolliere(req.person.id, "material_entfernt", `Material ${id}`, echteIp(req));
+ res.json({ ok: true });
+ } catch (f) {
+ console.error("[material] Entfernen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* Fehler aus express.raw -- eine zu grosse Datei ist kein Absturz. */
+materialRouter.use("/workspace/api/material", (fehler, req, res, next) => {
+ if (fehler?.type === "entity.too.large") {
+ return res.status(413).json({ fehler: "datei_zu_gross" });
+ }
+ return next(fehler);
+});
diff --git a/server/workspace.js b/server/workspace.js
index 274915ac..39eec9d6 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -1283,6 +1283,22 @@ const TREFF_BEREICHE = [
genauso heisst. Zwei Namen fuer dieselbe Sache waren schon einmal
ein echter Fehler (die zwei Kacheln "Chat" am 19.09.) -- man klickt
den falschen und sucht den Unterschied. */
+ /* MATERIAL (22.09.2026).
+
+ Filipe: "ich brauch auch noch eine neue kachel im bereich
+ community, wo wir als team ... bilder reinschicken koennen, mit
+ text und wo die leute sei es community oder modis sich die bilder
+ nehmen koennen zum posten ... sobald ein bild oder ein video ...
+ runtergeladen wurde, soll direkt blockiert werden."
+
+ SIE STEHT VOR "DRAUSSEN": Wer hier etwas holt, geht danach
+ hinaus und postet es. Die Reihenfolge ist der Weg.
+
+ Ton 42 ist GERECHNET, nicht gegriffen -- tools/kachel-farbe-
+ einzeln.mjs, Abstand 0,0823 zum naechsten Nachbarn (1,3-fach
+ besser als der erste Vorschlag), Kontrast 6,08:1. */
+ { ...TREFF_GRUPPE, name: "Material", unter: "Bilder und Videos zum Posten – jedes nur einmal",
+ zeichen: "galerie", ton: 42, ziel: "material.html", szene: "studio" },
{ ...TREFF_GRUPPE, name: "Draußen", unter: "Ist er live? Kanäle, Shop & dein Rabatt",
zeichen: "hinaus", ton: 38, ziel: "unsere-seiten.html", szene: "portal" },
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
diff --git a/tools/material-blick.mjs b/tools/material-blick.mjs
new file mode 100644
index 00000000..cd5efcdf
--- /dev/null
+++ b/tools/material-blick.mjs
@@ -0,0 +1,289 @@
+/* =====================================================================
+ DIE MATERIAL-SEITE MIT DREI PAAR AUGEN
+
+ Aufruf: node tools/material-blick.mjs
+ BREITE=412 node tools/material-blick.mjs (Handy)
+
+ WARUM DREI SICHTEN UND NICHT EINE. Auf dieser Seite sieht jede
+ Rolle etwas anderes, und die Unterschiede sind der ganze Sinn:
+
+ DogFather sieht den Kasten zum Bereitstellen UND die Namen
+ derer, die sich etwas genommen haben.
+ ein Modi sieht den Kasten, aber KEINE Namen.
+ die Community sieht weder noch -- nur das, was sie holen darf.
+
+ Wer die Seite nur als DogFather ansieht, beurteilt eine Seite, die
+ es fuer die Community nicht gibt. Genau dieser Fehler ist am
+ 11.09. beim Community-Blick schon einmal passiert.
+
+ UND ES LIEGT ECHTES MATERIAL DARIN. Ein leeres Raster zu
+ fotografieren beantwortet nur die Frage, ob es leer ist -- nicht,
+ ob die Vorschau passt, ob die Karten gleich hoch sind oder ob der
+ Text unter einem Bild lesbar bleibt.
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawn } from "node:child_process";
+import { DatabaseSync } from "node:sqlite";
+import { scryptSync, randomBytes } from "node:crypto";
+import { request as anfrage } from "node:http";
+
+const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
+const HIER = join(WURZEL, "server");
+const PORT = Number(process.env.PORT_BLICK || 4371);
+const BASIS = `http://127.0.0.1:${PORT}`;
+const CREW = "crew.dogfather-universe.com";
+const ordner = mkdtempSync(join(tmpdir(), "ws-mat-blick-"));
+const DB = join(ordner, "workspace.db");
+let kind = null;
+
+async function start() {
+ kind = spawn(process.execPath, [join(HIER, "index.js")], {
+ env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
+ SITE_ACCESS_SECRET: "lokaler-test",
+ SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
+ stdio: ["ignore", "pipe", "pipe"] });
+ let aus = "";
+ kind.stdout.on("data", (d) => { aus += d; });
+ kind.stderr.on("data", (d) => { aus += d; });
+ for (let i = 0; i < 80; i++) {
+ await new Promise((r) => setTimeout(r, 250));
+ if (kind.exitCode !== null) break;
+ try {
+ const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
+ if (a.status < 500) return;
+ } catch { /* noch nicht oben */ }
+ }
+ throw new Error("Server startete nicht:\n" + aus.slice(-1500));
+}
+
+function personAnlegen(name, rolle, code) {
+ const d = new DatabaseSync(DB);
+ const salz = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salz, 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, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salz, 32768, new Date().toISOString());
+ d.close();
+}
+
+await start();
+kind.kill();
+await new Promise((r) => setTimeout(r, 600));
+personAnlegen("Filipe", "admin", "CODE-DOGI-0001");
+personAnlegen("Rieke", "hand", "CODE-HAND-0001");
+personAnlegen("Frida", "modi", "CODE-MODI-0001");
+personAnlegen("Mara", "gast", "CODE-GAST-0001");
+personAnlegen("Jonas", "gast", "CODE-GAST-0002");
+await start();
+
+function rohAnfrage(pfad, host, koerper, kopf = {}, methode = "POST") {
+ return new Promise((f, s2) => {
+ const daten = Buffer.isBuffer(koerper) ? koerper
+ : (koerper == null ? null : Buffer.from(JSON.stringify(koerper)));
+ const a = anfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ Host: host,
+ ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
+ ...kopf,
+ },
+ }, (an) => {
+ let t = "";
+ an.on("data", (c) => { t += c; });
+ an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers }));
+ });
+ a.on("error", s2);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmelden(rolle, code) {
+ let a = await rohAnfrage("/workspace/api/anmelden", CREW, { rolle, code });
+ if (a.code === 400 && a.text.includes("alter_offen")) {
+ a = await rohAnfrage("/workspace/api/anmelden", CREW, { rolle, code, alter_ok: true });
+ }
+ if (a.code !== 200) throw new Error(`Anmeldung ${rolle} -> ${a.code} ${a.text.slice(0, 160)}`);
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+
+/* ---- ECHTES MATERIAL HINEINLEGEN -----------------------------------
+
+ Die Buehnenbilder des Hauses. Sie sind echte Fotos in echter
+ Groesse -- ein 1x1-Pixel-Platzhalter wuerde jede Frage nach dem
+ Seitenverhaeltnis und der Ladezeit unbeantwortet lassen. */
+const kDogi = await anmelden("admin", "CODE-DOGI-0001");
+const kHand = await anmelden("hand", "CODE-HAND-0001");
+const kModi = await anmelden("modi", "CODE-MODI-0001");
+const kGast = await anmelden("gast", "CODE-GAST-0001");
+const kJonas = await anmelden("gast", "CODE-GAST-0002");
+
+const BILDER = [
+ ["crew-gate-960.webp", "Stream-Ausschnitt gestern Abend.webp",
+ "Für TikTok – passt zum Clip von gestern, gern mit Musik drunter."],
+ ["crew-gate-hoch-580.webp", "Hochformat für Insta Story.webp",
+ "Schon im 9:16-Format, geht direkt als Story raus."],
+ ["gate-1280.webp", "Ankündigung Freitag.webp", ""],
+ ["crew-gate-1280.webp", "Dogi im Studio.webp",
+ "Bitte nur mit dem Text „Freitag 20 Uhr“ posten."],
+ ["gate-960.webp", "Thumbnail Variante B.webp", "Falls A nicht zieht."],
+];
+console.log("=== Material einstellen ===");
+let nr = 0;
+for (const [datei, name, text] of BILDER) {
+ const rohBild = readFileSync(join(WURZEL, "workspace/assets/img/buehne", datei));
+ const wer = nr % 2 === 0 ? kDogi : kHand;
+ const a = await rohAnfrage("/workspace/api/material", CREW, rohBild, {
+ Cookie: wer, "Content-Type": "image/webp",
+ "Content-Length": rohBild.length,
+ "X-Datei-Name": encodeURIComponent(name),
+ "X-Text": encodeURIComponent(text),
+ Origin: `https://${CREW}`,
+ });
+ console.log(` ${String(a.code).padEnd(4)} ${name} (${Math.round(rohBild.length / 1024)} KB)`);
+ nr++;
+}
+
+/* ZWEI STUECKE SIND SCHON WEG. Ohne sie waere „Schon benutzt" leer,
+ und genau dieser Block ist der, den Filipe sehen will. */
+const alle = JSON.parse((await rohAnfrage("/workspace/api/material", CREW, null,
+ { Cookie: kDogi }, "GET")).text).material;
+for (const [i, keks] of [[3, kGast], [4, kJonas]]) {
+ const a = await rohAnfrage(`/workspace/api/material/${alle[i].id}/nehmen`, CREW, null,
+ { Cookie: keks, Origin: `https://${CREW}` });
+ console.log(` genommen: ${alle[i].name} -> ${a.code}`);
+}
+
+/* ---- Und jetzt hinsehen -------------------------------------------- */
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+const BREITE = Number(process.env.BREITE || 1500);
+const ANHANG = BREITE < 700 ? "-handy" : "";
+
+async function hinsehen(titel, keks, marke) {
+ const ctx = await browser.newContext({
+ viewport: { width: BREITE, height: BREITE < 700 ? 915 : 1100 },
+ deviceScaleFactor: 1, isMobile: BREITE < 700, hasTouch: BREITE < 700 });
+ await ctx.addCookies(keks.split("; ").filter(Boolean).map((z) => {
+ const [name, ...rest] = z.split("=");
+ return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await ctx.newPage();
+ const schlimm = [];
+ seite.on("console", (m) => { if (m.type() === "error") schlimm.push(m.text().slice(0, 120)); });
+ seite.on("requestfailed", (r) => schlimm.push(`${r.url().slice(-50)} (${r.failure()?.errorText})`));
+ seite.on("response", (r) => { if (r.status() >= 400) schlimm.push(`${r.url().slice(-50)} -> ${r.status()}`); });
+
+ await seite.goto(`${BASIS}/workspace/material.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1800);
+
+ const z = await seite.evaluate(() => {
+ const karten = [...document.querySelectorAll(".m-karte")];
+ const bilder = [...document.querySelectorAll(".m-karte__bild")];
+ const kasten = document.getElementById("neu-block");
+ const wegBlock = document.getElementById("weg-block");
+ /* Kontrast messen, wo es zaehlt: der Text unter dem Bild. */
+ const probe = document.querySelector(".m-karte__text, .m-karte__name");
+ const farbe = probe ? getComputedStyle(probe).color : "";
+ let hinter = "";
+ for (let e = probe; e; e = e.parentElement) {
+ const h = getComputedStyle(e).backgroundColor;
+ if (h && !/rgba\(0, 0, 0, 0\)/.test(h)) { hinter = h; break; }
+ }
+ return {
+ titel: document.querySelector("h1")?.textContent?.trim() || "",
+ unterzeile: (document.querySelector(".unterzeile")?.textContent || "")
+ .replace(/\s+/g, " ").trim(),
+ karten: karten.length,
+ frei: document.querySelectorAll("#frei .m-karte").length,
+ weg: document.querySelectorAll("#weg .m-karte").length,
+ wegBlockDa: !!wegBlock && !wegBlock.hidden,
+ kastenDa: !!kasten && !kasten.hidden,
+ /* Wie viele Karten nennen einen NAMEN dessen, der es hat? */
+ mitNamen: [...document.querySelectorAll(".m-karte__stand")]
+ .filter((e) => /hat es genommen/.test(e.textContent)).length,
+ staende: [...new Set([...document.querySelectorAll(".m-karte__stand")]
+ .map((e) => e.textContent.replace(/\s+/g, " ").trim()))],
+ knoepfe: [...new Set([...document.querySelectorAll(".m-karte button")]
+ .map((b) => b.textContent.trim()))],
+ /* Sind die Karten gleich hoch? Verschiedene Hoehen in einem
+ Raster sehen nach Versehen aus. */
+ hoehen: [...new Set(karten.map((k) => Math.round(k.getBoundingClientRect().height)))],
+ /* UND STEHEN DIE KNOEPFE AUF EINER LINIE? Gleich hohe Karten
+ genuegen dafuer nicht -- bei unterschiedlich langem Text
+ bleibt unten unterschiedlich viel Luft, und die Knoepfe
+ rutschen auseinander. Genau das war am 22.09. der Fall. */
+ knopfLinien: (() => {
+ const erste = [...document.querySelectorAll("#frei .m-karte")]
+ .map((k) => k.getBoundingClientRect().top);
+ const oben = erste.length ? erste[0] : 0;
+ return [...new Set([...document.querySelectorAll("#frei .m-karte")]
+ .filter((k) => Math.abs(k.getBoundingClientRect().top - oben) < 2)
+ .map((k) => {
+ const b = k.querySelector("button, a.knopf");
+ return b ? Math.round(b.getBoundingClientRect().top) : null;
+ }).filter((v) => v !== null))];
+ })(),
+ wegHoehen: [...new Set([...document.querySelectorAll("#weg .m-karte")]
+ .map((k) => Math.round(k.getBoundingClientRect().height)))],
+ bilderGeladen: bilder.filter((b) => b.naturalWidth > 0).length,
+ bilderGesamt: bilder.length,
+ /* Laeuft etwas ueber den Rand hinaus? */
+ zuBreit: karten.filter((k) => k.getBoundingClientRect().right
+ > document.documentElement.clientWidth + 1).length,
+ spalten: (() => {
+ const oben = karten.map((k) => Math.round(k.getBoundingClientRect().top));
+ return oben.length ? oben.filter((t) => t === oben[0]).length : 0;
+ })(),
+ seitenhoehe: document.body.scrollHeight,
+ farbe, hinter,
+ kopfSatz: (document.getElementById("frei-satz")?.textContent || "").trim(),
+ wegSatz: (document.getElementById("weg-satz")?.textContent || "").trim(),
+ };
+ });
+
+ console.log("");
+ console.log(`=== ${titel} ===`);
+ console.log(` „${z.titel}" · ${z.unterzeile.slice(0, 90)}`);
+ console.log(` Kasten zum Bereitstellen: ${z.kastenDa ? "DA" : "weg"}`);
+ console.log(` ${z.frei} frei, ${z.weg} vergeben (Block „Schon benutzt": ${z.wegBlockDa ? "da" : "weg"})`);
+ console.log(` Karten mit Namen: ${z.mitNamen} ${marke}`);
+ console.log(` Zustaende: ${z.staende.join(" | ") || "(keine)"}`);
+ console.log(` Knoepfe: ${z.knoepfe.join(" | ") || "(keine)"}`);
+ console.log(` Vorschau geladen: ${z.bilderGeladen}/${z.bilderGesamt}`);
+ console.log(` Raster: ${z.spalten} Spalten, ${z.hoehen.length} verschiedene Hoehen (${z.hoehen.join(", ")})`);
+ console.log(` Knoepfe der ersten Zeile auf ${z.knopfLinien.length} Hoehe(n): ${z.knopfLinien.join(", ")}`
+ + (z.knopfLinien.length > 1 ? " << stehen nicht auf einer Linie" : ""));
+ console.log(` „Schon benutzt": Kartenhoehe ${z.wegHoehen.join(", ") || "(keine)"} px`);
+ console.log(` ueber den Rand: ${z.zuBreit} · Seitenhoehe ${z.seitenhoehe} px`);
+ console.log(` Text ${z.farbe} auf ${z.hinter}`);
+ console.log(` oben: „${z.kopfSatz}"`);
+ console.log(` unten: „${z.wegSatz}"`);
+ if (schlimm.length) console.log(` !! ${[...new Set(schlimm)].join(" · ")}`);
+
+ const pfad = `C:/Users/qciga/AppData/Local/Temp/material-${titel.toLowerCase()
+ .replace(/[^a-z]+/g, "-").replace(/^-|-$/g, "")}${ANHANG}.png`;
+ await seite.screenshot({ path: pfad, fullPage: true });
+ console.log(` -> ${pfad}`);
+ await ctx.close();
+ return z;
+}
+
+const a1 = await hinsehen("DogFather", kDogi, "(erwartet: 2)");
+const a2 = await hinsehen("Modi", kModi, "(erwartet: 0)");
+const a3 = await hinsehen("Community", kGast, "(erwartet: 0)");
+
+console.log("");
+console.log("=== Was sich unterscheidet ===");
+console.log(` Kasten: DogFather ${a1.kastenDa} · Modi ${a2.kastenDa} · Community ${a3.kastenDa}`);
+console.log(` Namen: DogFather ${a1.mitNamen} · Modi ${a2.mitNamen} · Community ${a3.mitNamen}`);
+console.log(` frei: ${a1.frei} / ${a2.frei} / ${a3.frei}`);
+
+await browser.close();
+kind.kill();
+await new Promise((r) => setTimeout(r, 400));
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 588b7a8d..7839f09e 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`. -->
-
-
+
+
-
+