diff --git a/server/mess-kachelreihen.mjs b/server/mess-kachelreihen.mjs new file mode 100644 index 00000000..9c53119f --- /dev/null +++ b/server/mess-kachelreihen.mjs @@ -0,0 +1,246 @@ +/* ===================================================================== + WIE STEHEN DIE KACHELN — je Rolle, Gruppe für Gruppe (24.09.2026) + + --------------------------------------------------------------------- + WARUM ES DIESE DATEI GIBT + + Filipe schickt seine Wünsche zur Startseite fast immer als + Bildschirmfoto: „die kachel auf screen 2 soll zwischen den kacheln + auf screen3 sein", „wechsel bitte die reihenfolge, links soll + unterstützen sein". Beides sind Fragen nach einer REIHE, nicht nach + einer Liste. + + Und genau da liegt die Falle: Die Reihenfolge im Quelltext ist NICHT + die Reihenfolge auf dem Bildschirm. + + * Das Raster hat drei Spalten, „Willkommen" belegt zwei — alles + danach verschiebt sich um eins. + * Gruppiert wird nach dem ERSTEN Auftreten einer Gruppe im Feld; + eine Kachel am Ende der Liste kann dadurch mitten auf der Seite + landen. + * Und eine Kachel ohne `gruppe` bekommt einen eigenen Abschnitt + ohne Überschrift — genau so stand der Support einen halben Tag + lang allein in einem namenlosen Kasten, und beim Lesen des + Quelltextes sieht man das nicht. + + Wer das im Kopf ausrechnet, rechnet früher oder später falsch. + Deshalb wird es hier GEMESSEN: angemeldet, im Browser, je Rolle. + + Sie prüft NICHTS und meldet keinen Fehlschlag. Sie beantwortet eine + Frage, die man vor jeder Umsortierung stellen muss. + + Sie heißt `mess-` und nicht `pruef-`: Die Portvergabe leitet sich + aus den `pruef-`-Namen ab, und eine Messdatei darf die Nummern der + Prüfungen nicht verschieben. + + Aufrufen mit: node server/mess-kachelreihen.mjs + Nur eine Rolle: ROLLE=modi node server/mess-kachelreihen.mjs + Bild: SCHIRM=1 node server/mess-kachelreihen.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { request as httpAnfrage } from "node:http"; +import { createServer as httpsServer } from "node:https"; + +const PORT = 5387, HP = 5388; +const CREW = "crew.dogfather-universe.com"; +const ordner = mkdtempSync(join(tmpdir(), "ws-kachelreihen-")); +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 }); +}; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(240_000, "mess-kachelreihen"); +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); }); + +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 sch = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", sch, jetzt); + +const anlegen = (name, rolle, code) => { + const s = randomBytes(16).toString("hex"); + const h = scryptSync(code, s, 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, h, s, 32768, + createHmac("sha256", sch).update(code).digest("hex"), jetzt); +}; +/* Die fünf Rollen, die auf der Crew-Adresse eine Startseite haben. */ +const ROLLEN = [ + ["Filipe", "admin", "CODE-DOGI-0001"], + ["VanVan", "hand", "CODE-HAND-0001"], + ["Kessi", "linke", "CODE-LINKE-0001"], + ["Diene", "modi", "CODE-MODI-0001"], + ["Zuschauer", "gast", "CODE-GAST-0001"], +]; +for (const [name, rolle, code] of ROLLEN) anlegen(name, rolle, code); +d.close(); + +/* ÜBER DIE CREW-ADRESSE, nicht über 127.0.0.1: Welches Haus gemeint + ist, leitet der Server aus dem `Host`-Kopf ab. Über die Zahladresse + landet man im Agenturhaus, und dort gibt es weder `gast` noch + `modi` — man misst dann eine Seite, die niemand so sieht. */ +const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: art, + headers: { Host: CREW, "Content-Type": "application/json" }, + }, (r) => { + let t = ""; r.on("data", (x) => { t += x; }); + r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); +}); + +const kekse = {}; +for (const [, rolle, code] of ROLLEN) { + const an = await anfrage("/workspace/api/anmelden", "POST", + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); + if (an.code !== 200) { + console.log(` ABBRUCH: ${rolle} ließ sich nicht anmelden (HTTP ${an.code}). ` + + "Ohne Anmeldung gibt es keine Startseite zu messen."); + process.exit(2); + } + kekse[rolle] = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; "); +} + +const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl, + "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], +{ stdio: "ignore" }); +const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, + (von, nach) => { + const w = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HP}` }, + }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); }); + von.pipe(w); + }); +await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r)); + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], +}); +try { + const nur = process.env.ROLLE; + for (const [, rolle] of ROLLEN) { + if (nur && nur !== rolle) continue; + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1800 } }); + await kontext.addCookies(kekse[rolle].split("; ").map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: CREW, path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { + if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) { + konsole.push(e.text().slice(0, 110)); + } + }); + await seite.goto(`https://${CREW}:${HP}/workspace/start.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(800); + + const gruppen = await seite.evaluate(() => + [...document.querySelectorAll("section")].map((s) => { + const titel = s.querySelector("h2, .gruppe__titel")?.textContent?.trim() || ""; + const k = [...s.querySelectorAll(".kachel")].map((x) => { + const r = x.getBoundingClientRect(); + return { + n: x.querySelector(".kachel__name")?.textContent, + x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width), + }; + }); + return k.length ? { titel, k } : null; + }).filter(Boolean)); + + console.log(""); + console.log(`=== ${rolle} ===`); + for (const g of gruppen) { + /* NACH ZEILEN SORTIEREN, nicht nach Feldreihenfolge -- das ist ja + gerade die Frage. Gerundet auf 20 px, damit zwei Kacheln + derselben Zeile mit leicht verschiedener Oberkante + zusammenbleiben. */ + const zeilen = new Map(); + for (const k of g.k) { + const y = Math.round(k.y / 20) * 20; + if (!zeilen.has(y)) zeilen.set(y, []); + zeilen.get(y).push(k); + } + const sortiert = [...zeilen.entries()].sort((a, b) => a[0] - b[0]); + const name = g.titel || "!! OHNE ÜBERSCHRIFT !!"; + console.log(` [${name}]`); + for (const [, ks] of sortiert) { + console.log(" " + ks.sort((a, b) => a.x - b.x) + .map((k) => `${k.n} (${k.b}px)`).join(" | ")); + } + } + /* EIN ABSCHNITT OHNE ÜBERSCHRIFT IST EIN BEFUND, kein Zufall: + Er entsteht, wenn eine Kachel keine `gruppe` hat. */ + const ohne = gruppen.filter((g) => !g.titel); + if (ohne.length) { + console.log(` !! ${ohne.length} Abschnitt(e) OHNE Überschrift: ` + + ohne.map((g) => g.k.map((k) => k.n).join(", ")).join(" / ")); + } + if (konsole.length) console.log(" !! Konsole: " + konsole[0]); + if (process.env.SCHIRM) { + /* MIT `GRUPPE="Für dich"` nur diesen einen Abschnitt -- ein Bild + der ganzen Seite ist 1280 mal viertausend Pixel, und darauf + sucht man die drei Kacheln, um die es gerade geht. */ + const nurGruppe = process.env.GRUPPE; + const window0 = await seite.evaluate(() => window.scrollY); + const feld = nurGruppe ? await seite.evaluate((titel) => { + const s2 = [...document.querySelectorAll("section")] + .find((x) => (x.querySelector("h2, .gruppe__titel")?.textContent || "").trim() === titel); + return s2 ? s2.getBoundingClientRect().toJSON() : null; + }, nurGruppe) : null; + const datei = `server/mess-kachelreihen-${rolle}.png`; + try { + if (feld) { + /* MIT `fullPage`, auch beim Ausschnitt: Die Gruppe liegt + meist unterhalb des Fensters, und ohne fullPage kennt + Playwright dort keine Koordinaten -- der Aufruf scheitert. + Gemessen am 24.09.2026, als genau das passierte. */ + await seite.screenshot({ path: datei, fullPage: true, clip: { + x: Math.max(0, feld.x - 10), y: Math.max(0, feld.y + window0 - 10), + width: Math.min(1280, feld.width + 20), height: feld.height + 20 } }); + } else { + await seite.screenshot({ path: datei, fullPage: true }); + } + console.log(` -- Bild: ${datei}`); + } catch (f) { + /* NICHT STILL SCHEITERN. Ein Lauf, der erfolgreich aussieht und + kein Bild hinterlaesst, kostet drei Wiederholungen. */ + console.log(` !! Bild ging nicht: ${String(f?.message || f).slice(0, 120)}`); + } + } + await kontext.close(); + } +} finally { + await browser.close(); + try { vorbau.close(); } catch { /* egal */ } + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(0); +} diff --git a/server/workspace.js b/server/workspace.js index d98346b4..c9967c67 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1630,6 +1630,25 @@ export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "linke", "admin"]); eigenes Zeichen zu erfinden hiesse, zwei Dinge zu unterscheiden, die sich fuer den Benutzer gleich anfuehlen. */ const SUPPORT_KACHEL = { + /* SIE GEHOERT ZU "FUER DICH" (nachgetragen 24.09.2026). + + Filipe, mit zwei Bildschirmfotos: "die kachel auf screen 2 soll + zwischen den kacheln auf screen3 sein bitte" -- also zwischen + "Wer sieht was" und "Vertraulich melden". + + Dabei fiel ein zweiter Fehler auf, den er gar nicht gemeldet hat: + Die Kachel hatte GAR KEINE Gruppe. Sie stand deshalb in einem + eigenen Abschnitt ohne Ueberschrift, allein, mit einem + Aufklapp-Kopf, auf dem nur "1" stand -- bei JEDER Rolle. Gebaut + habe ich das heute Morgen, und im Bildschirmfoto war es zu sehen; + mir ist es nicht aufgefallen, weil ich auf die Kachel geschaut + habe und nicht auf das, was um sie herum steht. + + "Deine eigene Seite" ist derselbe Untertitel wie beim + vertraulichen Meldeweg -- und das ist kein Zufall: Bei der + Community steht der Support VOR ihm und waere sonst die erste + Kachel der Gruppe, deren Untertitel dann fehlte. */ + gruppe: "Für dich", gruppeUnter: "Deine eigene Seite", name: "Support", unter: "Etwas kaputt? Schreib es mit Bild", zeichen: "schutz", ton: 44, ziel: "support.html", szene: "studio", }; @@ -1940,8 +1959,29 @@ const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, HILFE_KACHEL, STECKBRIEF_KACHEL_TR export function bereicheFuer(person) { const liste = bereicheRoh(person); if (liste === null || !Array.isArray(liste) || !liste.length) return liste; - return liste.some((k) => k?.ziel === "support.html") - ? liste : [...liste, SUPPORT_KACHEL]; + if (liste.some((k) => k?.ziel === "support.html")) return liste; + + /* VOR DEN VERTRAULICHEN MELDEWEG, NICHT ANS ENDE (24.09.2026). + + Filipe: "die kachel auf screen 2 soll zwischen den kacheln auf + screen3 sein bitte" -- zwischen "Wer sieht was" und "Vertraulich + melden". + + GESUCHT WIRD DER NACHBAR, NICHT EINE STELLE. Eine feste Zahl + ("an Position 12 einschieben") waere beim naechsten Umbau still + falsch -- dieselbe Ueberlegung wie bei vorGruppe() weiter oben. + Gefunden wird ueber das ZIEL der Nachbarkachel; das aendert sich + nicht, wenn jemand ihren Namen umschreibt. + + FINDET SICH DER NACHBAR NICHT, wird angehaengt. Ein Modi hat den + vertraulichen Meldeweg nicht -- bei ihm steht der Support dann am + Ende von "Fuer dich", und das ist richtig: Er ist dort die + letzte der drei eigenen Seiten. Eine Kachel wegzulassen, weil + eine Reihenfolge nicht aufgeht, waere der schlechtere Tausch. */ + const nachbar = liste.findIndex((k) => k?.ziel === HILFE_KACHEL.ziel); + return nachbar < 0 + ? [...liste, SUPPORT_KACHEL] + : [...liste.slice(0, nachbar), SUPPORT_KACHEL, ...liste.slice(nachbar)]; } function bereicheRoh(person) {