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