diff --git a/server/mess-anleitung-crew.mjs b/server/mess-anleitung-crew.mjs new file mode 100644 index 00000000..d2e34e28 --- /dev/null +++ b/server/mess-anleitung-crew.mjs @@ -0,0 +1,134 @@ +/* ===================================================================== + WAS SEHEN DIE VIER CREW-ROLLEN HEUTE AUF willkommen.html? + + Ausgangsmessung vom 06.10.2026, bevor der Bauplan auf der + Crew-Seite umgesetzt wird. Ohne sie waere hinterher nicht zu + sagen, was die Arbeit bewirkt hat -- und ob die Seite vorher + wirklich leer war oder nur anders. + + Gemessen wird am HOSTNAMEN, nicht an der Rolle: Erst der + Hostname macht `person.haus` zu "crew", und genau daran haengt + die Schranke im Servermodul. + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request } from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-anlcrew-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "mess-anleitung-crew"); +process.env.PORT = `${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(120_000, "mess-anleitung-crew"); +await new Promise((r) => setTimeout(r, 900)); + +const CREW = "crew.dogfather-universe.com"; + +/** Ein Aufruf mit frei waehlbarem Host-Kopf -- `fetch` laesst `Host` + * nicht setzen, deshalb node:http. */ +function ruf(pfad, { keks = "", methode = "GET", rumpf = null } = {}) { + return new Promise((fertig) => { + const kopf = { Host: CREW }; + if (keks) kopf.Cookie = keks; + if (rumpf) { kopf["Content-Type"] = "application/json"; } + const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, headers: kopf }, + (r) => { + let t = ""; + r.on("data", (c) => { t += c; }); + r.on("end", () => fertig({ + status: r.statusCode, + keks: (r.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "), + text: t, + json: (() => { try { return JSON.parse(t); } catch { return null; } })(), + })); + }); + a.on("error", () => fertig({ status: 0, text: "", json: null, keks: "" })); + if (rumpf) a.write(rumpf); + a.end(); + }); +} + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await ruf("/workspace/api/anmelden", { methode: "POST", rumpf: JSON.stringify({ rolle: "admin", code: "x" }) }); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} + +const ROLLEN = [["gast", "Community"], ["modi", "Modi"], ["linke", "Linke Hand"], ["hand", "Rechte Hand"]]; +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + for (const [rolle] of ROLLEN) { + const salt = randomBytes(16).toString("hex"); + const code = `CODE-${rolle.toUpperCase()}-0001`; + 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(rolle, rolle, hash, salt, 32768, new Date().toISOString()); + } + d.close(); +} + +console.log("\n=== Was die Crew-Rollen heute auf willkommen.html bekommen ==="); +for (const [rolle, wie] of ROLLEN) { + const an = await ruf("/workspace/api/anmelden", { + methode: "POST", rumpf: JSON.stringify({ rolle, code: `CODE-${rolle.toUpperCase()}-0001` }), + }); + if (an.status !== 200) { console.log(` ${wie.padEnd(12)} kommt nicht herein (${an.status})`); continue; } + const seite = await ruf("/workspace/willkommen.html", { keks: an.keks }); + const api = await ruf("/workspace/api/anleitung", { keks: an.keks }); + const karten = api.json?.karten?.length ?? "-"; + console.log(` ${wie.padEnd(12)} Seite ${seite.status} · Schnittstelle ${api.status}` + + ` · Karten ${karten}` + + (api.json?.fehler ? ` · "${api.json.fehler}"` : "")); +} + +/* ==== FUEHREN DIE KACHELN DER LINKEN HAND INS LEERE? ================ + + Der Bauplan spricht der Linken Hand vier Kacheln ab (Talente, + Personen & Zugaenge, Wer sieht was, Vertraulich melden). Die + Kachelliste gibt sie ihr trotzdem. Entweder irrt der Plan -- oder + sie sieht vier Kacheln, die auf Seiten fuehren, die sie nicht + oeffnen darf. Gefragt wird der laufende Server, nicht die Tabelle. */ +{ + console.log("\n=== Linke Hand: fuehren ihre Kacheln irgendwohin? ==="); + const an = await ruf("/workspace/api/anmelden", { + methode: "POST", rumpf: JSON.stringify({ rolle: "linke", code: "CODE-LINKE-0001" }), + }); + for (const pfad of ["/workspace/talente.html", "/workspace/personen.html", + "/workspace/rechte.html", "/workspace/hilfe.html", "/workspace/aufgaben.html"]) { + const a2 = await ruf(pfad, { keks: an.keks }); + const wohin = a2.status >= 300 && a2.status < 400 ? " -> umgeleitet" : ""; + console.log(` ${pfad.replace("/workspace/", "").padEnd(18)} ${a2.status}${wohin}`); + } +} + +/* Und was steht in der Datenbank -- je Haus. */ +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + console.log("\n=== Inhalte in der Datenbank ==="); + for (const r of d.prepare( + "SELECT haus, rolle, COUNT(*) n FROM anl_karte GROUP BY haus, rolle ORDER BY haus, rolle").all()) { + console.log(` ${r.haus.padEnd(8)} ${r.rolle.padEnd(10)} ${r.n} Karten`); + } + const crew = d.prepare("SELECT COUNT(*) n FROM anl_karte WHERE haus = 'crew'").get().n; + console.log(` -> fuer die Crew: ${crew} Karten`); + d.close(); +} + +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(0); diff --git a/server/pruef-sackgassen.mjs b/server/pruef-sackgassen.mjs index 70f10191..3e399a9e 100644 --- a/server/pruef-sackgassen.mjs +++ b/server/pruef-sackgassen.mjs @@ -143,6 +143,54 @@ melde("\n=== Sackgassen: führt irgendein Weg ins Leere? ===\n"); ok(ROLLEN.length >= 3, `die Anmeldewand bietet ${ROLLEN.length} Rollen an: ${ROLLEN.join(", ")}`); ok(alleSeiten.length >= 20, `die Rechtetafel kennt ${alleSeiten.length} Seiten`); +/* ==== KEINE KACHEL FUEHRT AUF EINE GESPERRTE SEITE ================= + + NACHGETRAGEN AM 06.10.2026, nachdem genau das hier durchgerutscht + ist: Die linke Hand sah vier Kacheln (Talente, Personen & Zugaenge, + Wer sieht was, Vertraulich melden), die alle mit 302 wegleiteten. + + WARUM DIESE DATEI ES NICHT GEMERKT HAT, obwohl sie "Sackgassen" + heisst: Sie nimmt die Rollen von der ANMELDEWAND -- und die steht + auf der Agenturadresse. Modi, linke Hand und Gast gibt es dort + nicht; sie wurden nie geprueft. Eine Pruefung, die ihre Faelle aus + einer Liste nimmt, prueft nur, was auf der Liste steht. + + DESHALB OHNE BROWSER UND OHNE ANMELDUNG: Gefragt werden die beiden + Stellen, die es wissen -- `bereicheFuer` (welche Kacheln) und + `darfSeite` (welche Seiten). Damit sind ALLE Rollen BEIDER Haeuser + erfasst, auch die, die auf keiner Anmeldewand stehen. */ +{ + const { bereicheFuer } = await import("./workspace.js"); + const { darfSeite } = await import("./rechte.js"); + const FAELLE = [ + ["crew", "gast"], ["crew", "modi"], ["crew", "linke"], ["crew", "hand"], ["crew", "admin"], + ["agentur", "creator"], ["agentur", "scout"], ["agentur", "manager"], + ["agentur", "spicy"], ["agentur", "admin"], + ]; + let geprueftKacheln = 0; + const tote = []; + for (const [haus, rolle] of FAELLE) { + const person = { id: 1, rolle, haus }; + const liste = bereicheFuer(person); + /* `null` heisst "der Browser nimmt seine eigene Liste" -- dort gibt + es nichts zu pruefen, und das ist kein Mangel. */ + if (!Array.isArray(liste)) continue; + for (const k of liste) { + const z = String(k?.ziel || "").split("?")[0]; + if (!z.endsWith(".html")) continue; + geprueftKacheln++; + if (!darfSeite(person, "/workspace/" + z)) tote.push(`${haus}/${rolle}: ${k.name} -> ${z}`); + } + } + /* Die Zahl gehoert in die Bedingung: Kaeme gar keine Kachel zurueck, + waere "keine tote dabei" gruen, ohne eine einzige angesehen zu + haben. */ + ok(geprueftKacheln > 100 && tote.length === 0, + tote.length + ? `${tote.length} Kachel(n) fuehren auf gesperrte Seiten: ${tote.join(" | ")}` + : `keine der ${geprueftKacheln} Kacheln fuehrt auf eine gesperrte Seite`); +} + /* ---- Anmelden ------------------------------------------------------- */ async function anmelden(rolle) { let a = await roh("/workspace/api/anmelden", {}, { rolle, code: CODES[rolle] }); diff --git a/server/workspace.js b/server/workspace.js index 171ca6e2..2d143515 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2208,8 +2208,40 @@ export const brettAufDieserAdresse = (person, bereich) => beim Lesen aus wie der Schutz, und man sucht den echten nicht mehr. Der echte ist das Tor in workspace-bereiche.js und in der Video-Route -- beide gemessen in pruef-haus-trennung. */ +/** Eine Kachel, deren Seite diese Rolle nicht oeffnen darf, gibt es nicht. + * + * GEMESSEN AM 06.10.2026, beim Abgleich des Bauplans mit dem Haus: + * Die LINKE HAND sah vier Kacheln, die ins Leere fuehrten -- + * Talente, Personen & Zugaenge, Wer sieht was, Vertraulich melden. + * Alle vier antworteten mit 302 und leiteten sie weg; `aufgaben.html` + * als Gegenprobe mit 200. Der Bauplan hatte recht, die Kachelliste + * nicht. + * + * WARUM ES PASSIERT IST: Die Liste der linken Hand entsteht aus der + * der rechten MINUS einer Kachel. Jede Seite, die spaeter nur fuer + * die rechte Hand freigegeben wurde, kam damit still bei der linken + * mit -- die Rechtetafel wusste es, die Kachelliste nicht. + * + * ABGELEITET STATT GEPFLEGT: Gefragt wird dieselbe Tafel, die auch + * die Seite selbst schuetzt (`darfSeite`). Damit kann diese Sorte + * Fehler bei KEINER Rolle wiederkommen, nicht nur bei dieser einen. + * Nachgemessen ueber alle Rollen beider Haeuser: Es waren genau + * diese vier, sonst nichts -- die Regel nimmt also niemandem etwas + * weg, was er heute benutzt. + * + * Ziele ohne `.html` (aussen liegende Verweise) bleiben unberuehrt; + * die Rechtetafel kennt sie nicht und soll sie nicht kennen. */ +function ohneTotwege(liste, person) { + if (!Array.isArray(liste)) return liste; + return liste.filter((k) => { + const z = String(k?.ziel || "").split("?")[0]; + if (!z.endsWith(".html")) return true; + return darfSeite(person, "/workspace/" + z); + }); +} + export function bereicheFuer(person) { - const liste = bereicheRoh(person); + const liste = ohneTotwege(bereicheRoh(person), person); if (liste === null || !Array.isArray(liste) || !liste.length) return liste; if (liste.some((k) => k?.ziel === "support.html")) return liste; diff --git a/tools/pdf-text.mjs b/tools/pdf-text.mjs new file mode 100644 index 00000000..9c6de5e4 --- /dev/null +++ b/tools/pdf-text.mjs @@ -0,0 +1,174 @@ +/* ===================================================================== + PDF-Text herausholen -- ohne fremde Bibliothek. + + Gebaut am 06.10.2026, weil auf diesem Rechner weder poppler noch + eine PDF-Bibliothek liegt und ein Bauplan gelesen werden musste. + + WIE ES ARBEITET: Alle Stroeme entpacken, die Seiten in ihrer + Reihenfolge einsammeln, und den Text ueber die ToUnicode-Tabellen + der jeweiligen Schrift zurueckuebersetzen. Ohne diese Tabellen + kaeme Buchstabensalat heraus -- die Schriften sind Teilmengen, in + denen <54> irgendein Zeichen bedeuten kann. + + AUFRUF: node tools/pdf-text.mjs [ziel.txt] + ===================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { inflateSync } from "node:zlib"; + +const quelle = process.argv[2]; +const ziel = process.argv[3] || null; +if (!quelle) { console.error("Aufruf: node tools/pdf-text.mjs [ziel.txt]"); process.exit(2); } + +const roh = readFileSync(quelle); +const text = roh.toString("latin1"); + +/* ---- 1. Alle Objekte einsammeln ------------------------------------ */ +const objekte = new Map(); // nummer -> { dict, daten } +{ + const re = /(\d+)\s+(\d+)\s+obj\b/g; + let m; + while ((m = re.exec(text))) { + const nr = Number(m[1]); + const ab = m.index + m[0].length; + const bis = text.indexOf("endobj", ab); + if (bis < 0) continue; + const koerper = text.slice(ab, bis); + let daten = null; + const sm = /(^|[^d])stream\r?\n/.exec(koerper); + if (sm) { + const sAb = ab + sm.index + sm[0].length; + const sBis = text.indexOf("endstream", sAb); + if (sBis > 0) { + try { daten = inflateSync(roh.subarray(sAb, sBis)); } + catch { daten = roh.subarray(sAb, sBis); } + } + } + objekte.set(nr, { dict: sm ? koerper.slice(0, sm.index) : koerper, daten }); + } +} + +/* ---- 2. ToUnicode-Tabellen ----------------------------------------- */ +const tabellen = new Map(); // objektnummer -> Map(code -> zeichen) + +function tabelleLesen(nr) { + if (tabellen.has(nr)) return tabellen.get(nr); + const o = objekte.get(nr); + const karte = new Map(); + tabellen.set(nr, karte); + if (!o?.daten) return karte; + const t = o.daten.toString("latin1"); + + /* Einzelzuordnungen: <01> <0041> */ + for (const block of t.match(/beginbfchar([\s\S]*?)endbfchar/g) || []) { + const re = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g; let m; + while ((m = re.exec(block))) karte.set(parseInt(m[1], 16), ausHex(m[2])); + } + /* Bereiche: <01> <05> <0041> oder <01> <03> [<0041> <0042> <0043>] */ + for (const block of t.match(/beginbfrange([\s\S]*?)endbfrange/g) || []) { + const re = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\[([\s\S]*?)\])/g; + let m; + while ((m = re.exec(block))) { + const von = parseInt(m[1], 16), bis = parseInt(m[2], 16); + if (m[3]) { + const basis = parseInt(m[3], 16); + for (let c = von; c <= bis && c - von < 65536; c++) { + karte.set(c, String.fromCodePoint(basis + (c - von))); + } + } else if (m[4]) { + const liste = m[4].match(/<([0-9A-Fa-f]+)>/g) || []; + liste.forEach((h, i) => karte.set(von + i, ausHex(h.slice(1, -1)))); + } + } + } + return karte; +} + +function ausHex(h) { + let s = ""; + for (let i = 0; i + 3 < h.length + 1; i += 4) { + const c = parseInt(h.slice(i, i + 4), 16); + if (!Number.isNaN(c)) s += String.fromCharCode(c); + } + return s; +} + +/* ---- 3. Seiten in der richtigen Reihenfolge ------------------------ */ +const seiten = []; +for (const [nr, o] of objekte) { + if (!/\/Type\s*\/Page[^s]/.test(o.dict)) continue; + seiten.push({ nr, dict: o.dict }); +} +seiten.sort((a, b) => a.nr - b.nr); + +/* ---- 4. Text je Seite ---------------------------------------------- */ +const alles = []; +for (const [i, seite] of seiten.entries()) { + /* Welche Schrift heisst wie? /F5 12 0 R */ + const schriften = new Map(); + const fm = /\/Font\s*<<([\s\S]*?)>>/.exec(seite.dict); + if (fm) { + const re = /\/(\w+)\s+(\d+)\s+\d+\s+R/g; let m; + while ((m = re.exec(fm[1]))) { + const fo = objekte.get(Number(m[2])); + const tu = fo && /\/ToUnicode\s+(\d+)\s+\d+\s+R/.exec(fo.dict); + schriften.set("/" + m[1], tu ? tabelleLesen(Number(tu[1])) : new Map()); + } + } + + /* Inhalt -- kann ein Verweis oder eine Liste sein. */ + const cm = /\/Contents\s+(?:(\d+)\s+\d+\s+R|\[([^\]]*)\])/.exec(seite.dict); + let inhalt = ""; + if (cm?.[1]) inhalt = objekte.get(Number(cm[1]))?.daten?.toString("latin1") || ""; + else if (cm?.[2]) { + for (const r of cm[2].match(/(\d+)\s+\d+\s+R/g) || []) { + inhalt += (objekte.get(Number(r))?.daten?.toString("latin1") || "") + "\n"; + } + } + + let karte = new Map(); + let zeile = ""; + const raus = []; + /* Jede Textanweisung der Reihe nach. `Td`/`TD`/`Tm`/`T*` mit + Zeilenvorschub trennt Zeilen -- sonst klebt die ganze Seite + zusammen und kein Mensch liest es. */ + const re = /\/(\w+)\s+[\d.]+\s+Tf|<([0-9A-Fa-f\s]*)>\s*Tj|\[([\s\S]*?)\]\s*TJ|([-\d.]+)\s+([-\d.]+)\s+Td|T\*|ET/g; + let m; + let letztesY = null; + while ((m = re.exec(inhalt))) { + if (m[1]) { karte = schriften.get("/" + m[1]) || new Map(); continue; } + if (m[2] !== undefined) { zeile += entziffern(m[2], karte); continue; } + if (m[3] !== undefined) { + for (const st of m[3].match(/<([0-9A-Fa-f\s]*)>/g) || []) { + zeile += entziffern(st.slice(1, -1), karte); + } + /* Grosse Rueckschritte im TJ sind Wortabstaende. */ + continue; + } + if (m[4] !== undefined) { + const y = Number(m[5]); + if (letztesY !== null && Math.abs(y) > 0.1) { raus.push(zeile); zeile = ""; } + letztesY = y; + continue; + } + /* T* oder ET */ + if (zeile.trim()) { raus.push(zeile); zeile = ""; } + } + if (zeile.trim()) raus.push(zeile); + alles.push(`\n═══ Seite ${i + 1} ═══\n` + raus.join("\n")); +} + +function entziffern(hex, karte) { + const h = hex.replace(/\s+/g, ""); + let s = ""; + for (let i = 0; i + 1 < h.length + 1; i += 2) { + const c = parseInt(h.slice(i, i + 2), 16); + if (Number.isNaN(c)) continue; + s += karte.get(c) ?? ""; + } + return s; +} + +const fertig = alles.join("\n"); +if (ziel) { writeFileSync(ziel, fertig, "utf8"); console.log(`${seiten.length} Seiten -> ${ziel} (${fertig.length} Zeichen)`); } +else console.log(fertig);