diff --git a/server/pruef-wege-nach-draussen.mjs b/server/pruef-wege-nach-draussen.mjs
new file mode 100644
index 00000000..ef317248
--- /dev/null
+++ b/server/pruef-wege-nach-draussen.mjs
@@ -0,0 +1,513 @@
+/* =====================================================================
+ WEGE NACH DRAUSSEN -- 19.09.2026
+
+ Zwei Aufträge von Filipe, und beide haben dieselbe Wurzel:
+
+ „ich brauch im community bereich noch eine kachel, die wenn man
+ drauf drückt dan kacheln hat die auf die website von dogfather
+ universe geht ... und die shop site von vanvan."
+
+ „die community soll sofort auf das video geschickt werden sobald
+ sie auf die kachel drücken, also die ganze kacheln will ich egal
+ wo man drauf drückt, foto text.... egal wo."
+
+ Beides führt AUS dem Treff hinaus. Und beides ist die Sorte
+ Funktion, die man für erledigt hält, weil sie im Quelltext
+ dasteht -- bis jemand daraufklickt und nichts passiert.
+
+ DIE DREI FALLEN, DIE HIER ABGESICHERT WERDEN:
+
+ 1. EIN LINK IM LINK. Die Video-Karte enthält bereits Links und
+ Knöpfe. Hätte ich die ganze Karte in ein gepackt, würde der
+ Browser das still reparieren -- und ein Teil der Karte wäre tot,
+ ohne Fehlermeldung. Deshalb wird hier gemessen, dass BEIDES
+ funktioniert: die Fläche UND die Knöpfe darin.
+
+ 2. EINE FLÄCHE, DIE ÜBERALL KLICKT. Genauso falsch wäre das
+ Gegenteil: Wer auf „löschen“ tippt, darf nicht bei TikTok
+ landen. Die Gegenprobe steht deshalb gleichberechtigt daneben.
+
+ 3. EIN TOTER WEG. Eine Kachel, die ein Mitglied sieht und nicht
+ öffnen darf, ist schlimmer als keine. Genau diese Sorte hat die
+ Sichtprüfung am 18.09. zehnmal gefunden -- alle derselbe
+ Chat-Knopf.
+
+ Aufrufen mit: node server/pruef-wege-nach-draussen.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request as httpAnfrage } from "node:http";
+import { portMussFreiSein } from "./helfer-port.mjs";
+
+const PORT = await portMussFreiSein(4196, "pruef-wege-nach-draussen");
+const ordner = mkdtempSync(join(tmpdir(), "ws-wege-"));
+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(150_000, "pruef-wege-nach-draussen");
+await new Promise((r) => setTimeout(r, 700));
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", 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";
+const WURZEL = join(import.meta.dirname, "..");
+
+function roh(pfad, host, kopf = {}, koerper = null) {
+ return new Promise((fertig, schief) => {
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: koerper ? "POST" : "GET",
+ headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (s) => { text += s; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
+ });
+ a.on("error", schief);
+ if (koerper) a.write(JSON.stringify(koerper));
+ a.end();
+ });
+}
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+
+/* ---- Leute anlegen ------------------------------------------------- */
+const LEUTE = [
+ ["Filipe", "admin", "CODE-DOGI-0001"],
+ ["Mara", "gast", "CODE-GAST-0001"],
+ ["Nico", "creator", "CODE-CREA-0001"],
+];
+{
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ for (const [name, rolle, code] of LEUTE) {
+ 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();
+}
+
+async function anmelden(rolle, code, host) {
+ let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
+ if (a.code === 400 && a.text.includes("alter_offen")) {
+ a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true });
+ }
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+
+/* =======================================================================
+ 1. DIE KACHEL -- GIBT ES SIE, UND FUEHRT SIE IRGENDWOHIN?
+ ======================================================================= */
+melde("");
+melde("=== 1. Die Kachel „Unsere Seiten“ ===");
+/* GEFRAGT WIRD, WAS EIN MITGLIED BEKOMMT -- nicht, was in einer
+ internen Liste steht. `TREFF_BEREICHE` ist absichtlich nicht
+ exportiert; `bereicheFuer` ist die Funktion, die auch den Browser
+ beliefert. Damit misst dieser Abschnitt denselben Weg, den die
+ Community geht, statt einen daneben. */
+const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js");
+{
+ const TREFF_BEREICHE = bereicheFuer({ rolle: "gast" });
+ ok(Array.isArray(TREFF_BEREICHE) && TREFF_BEREICHE.length > 0,
+ `ein Mitglied bekommt ${TREFF_BEREICHE?.length} Kacheln`);
+ const kachel = TREFF_BEREICHE.find((k) => k.ziel === "unsere-seiten.html");
+ ok(!!kachel, "sie steht in der Liste der Treff-Kacheln");
+ ok(kachel?.name === "Unsere Seiten", `sie heisst „${kachel?.name}“`);
+ ok(!!kachel?.unter && kachel.unter.length > 5,
+ `und sagt, was dahinter steckt („${kachel?.unter}“)`);
+
+ /* SIE DARF KEIN BRETT SEIN. Waere sie eins, wuerde der Server ein
+ Brett „unsere-seiten“ erwarten, das es nicht gibt -- und die
+ Ableitung unten nimmt sie nur deshalb nicht mit, weil im Ziel kein
+ „?b=“ steht. Das ist eine stille Abhaengigkeit, also wird sie
+ gemessen. */
+ ok(!TREFF_BRETTER.includes("unsere-seiten"),
+ `sie ist kein Brett (${TREFF_BRETTER.length} Bretter abgeleitet)`);
+ ok(TREFF_BRETTER.length === 7,
+ `es bleiben genau die sieben Bretter (${TREFF_BRETTER.join(", ")})`);
+
+ /* DAS RASTER. Drei Spalten, „Der Treff“ belegt zwei -- die Summe
+ muss durch drei teilbar sein, sonst klafft eine Luecke. */
+ const plaetze = TREFF_BEREICHE.reduce((n, k) => n + (k.gross ? 2 : 1), 0);
+ ok(plaetze % 3 === 0,
+ `das Raster geht auf: ${TREFF_BEREICHE.length} Kacheln = ${plaetze} Plaetze, ${plaetze / 3} volle Reihen`);
+
+ /* EIGENER TON. Zwei Kacheln mit derselben Farbe nebeneinander sind
+ auf einem Bildschirmfoto nicht zu unterscheiden. */
+ const toene = TREFF_BEREICHE.map((k) => k.ton);
+ ok(new Set(toene).size === toene.length,
+ `jede Kachel hat einen eigenen Ton (${toene.join(", ")})`);
+
+ /* UND DEN TON MUSS ES IM CSS GEBEN. Fehlt er, faellt die Kachel auf
+ die Vorgabefarbe zurueck -- und sieht aus wie eine andere. */
+ const css = readFileSync(join(WURZEL, "workspace/assets/css/start.css"), "utf8");
+ ok(css.includes(`[data-ton="${kachel?.ton}"]`),
+ `Ton ${kachel?.ton} ist im CSS hinterlegt`);
+
+ /* UND DAS ZEICHEN. Ein unbekannter Name gibt ein leeres Feld -- ohne
+ Fehlermeldung. */
+ const zeichenDatei = readFileSync(join(WURZEL, "workspace/assets/js/bereiche.js"), "utf8");
+ ok(new RegExp(`^\\s{4}${kachel?.zeichen}: \\{`, "m").test(zeichenDatei),
+ `das Zeichen „${kachel?.zeichen}“ gibt es wirklich`);
+}
+
+/* =======================================================================
+ 2. DARF SIE AUCH JEMAND OEFFNEN? -- der tote Weg
+ ======================================================================= */
+melde("");
+melde("=== 2. Wer darf hinein ===");
+const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
+const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
+const kCreator = await anmelden("creator", "CODE-CREA-0001", CREW);
+{
+ ok(!!kGast && !!kDogi, "Mitglied und DogFather sind angemeldet");
+
+ const a = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kGast });
+ ok(a.code === 200, `ein Mitglied bekommt die Seite (${a.code})`);
+ ok(a.text.includes("DOGI10"), "der Rabattcode steht darin");
+ ok(a.text.includes("vans-diy-bastelbedarf.com"), "die Adresse des Shops steht darin");
+ ok(a.text.includes("dogfather-universe.com"), "die Adresse der Website steht darin");
+
+ /* KEIN PERSOENLICHER CODE AUF EINER SEITE FUER ALLE. In derselben
+ Datei des Shops steht ein zweiter, der ausdruecklich „Nur fuer
+ Filipe persoenlich" ist. Stuende er hier, waere er binnen eines
+ Tages in jedem Gruppenchat. */
+ ok(!/FILIPE\d/i.test(a.text),
+ "der persoenliche Partnercode steht NICHT darin");
+
+ const b = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kDogi });
+ ok(b.code === 200, `DogFather auch (${b.code})`);
+
+ /* GEGENPROBE -- ohne sie beweist „200“ nichts. Die Agentur hat mit
+ dem Treff nichts zu tun. */
+ const c = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kCreator });
+ ok(c.code === 403 || c.code === 302,
+ `ein Creator kommt NICHT hinein (${c.code})`);
+
+ const d = await roh("/workspace/unsere-seiten.html", CREW);
+ ok(d.code === 302 || d.code === 401 || d.code === 403,
+ `ohne Anmeldung auch nicht (${d.code})`);
+}
+
+/* =======================================================================
+ 3. EIN HIGHLIGHT MIT VIDEO -- die Daten dafuer
+ ======================================================================= */
+melde("");
+melde("=== 3. Drei Karten zum Vergleich ===");
+const VIDEO = "https://www.tiktok.com/@dogfather0804/video/7500000000000000000";
+{
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ const wer = d.prepare("SELECT id FROM personen WHERE name = 'Filipe'").get().id;
+ const jetzt = new Date().toISOString();
+
+ /* (a) mit Video -- muss klickbar sein */
+ d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von,"
+ + " quelle_url, quelle_kanal) VALUES (?,?,?,?,?,?,?,?,?,?)")
+ .run("highlight", "clip", "Wer ist euer Komet", "Schreibt es in die Kommentare",
+ jetzt.slice(0, 10), "offen", jetzt, wer, VIDEO, "tiktok");
+
+ /* (b) ohne Video -- darf NICHT klickbar sein */
+ d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von)"
+ + " VALUES (?,?,?,?,?,?,?,?)")
+ .run("highlight", "clip", "Nur ein Bild", "Ohne Quelle",
+ jetzt.slice(0, 10), "offen", jetzt, wer);
+
+ /* (c) Video bei TikTok geloescht -- darf NICHT klickbar sein, sonst
+ fuehrt der Klick auf eine Fehlerseite und WIR sehen kaputt aus */
+ d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von,"
+ + " quelle_url, quelle_kanal, quelle_fehlt_seit) VALUES (?,?,?,?,?,?,?,?,?,?,?)")
+ .run("highlight", "clip", "Nicht mehr da", "Wurde geloescht",
+ jetzt.slice(0, 10), "offen", jetzt, wer, VIDEO, "tiktok", jetzt);
+
+ /* EIN ECHTES BILD AN DIE ERSTE KARTE.
+
+ Filipe hat „foto" ausdrücklich genannt -- es ist die größte Fläche
+ der Karte und das, worauf ein Daumen zuerst landet. Ohne
+ angehängtes Bild meldete dieser Abschnitt beim ersten Lauf
+ „Bildweg nicht gemessen", und damit wäre genau der Teil ungeprüft
+ geblieben, um den es geht.
+
+ ES MUSS EIN ECHTES PNG SEIN. Die Bildroute entscheidet an den
+ ersten Bytes der Datei, nicht am behaupteten Typ -- und schlägt
+ sie fehl, entfernt der Fehlerbehandler im Browser den Rahmen
+ wieder. Eine leere Datei hätte also dasselbe Loch hinterlassen,
+ nur unauffälliger. */
+ const EIN_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64");
+ {
+ const { mkdirSync, writeFileSync } = await import("node:fs");
+ const bildOrdner = join(ordner, "dateien");
+ mkdirSync(bildOrdner, { recursive: true });
+ writeFileSync(join(bildOrdner, "probe-standbild.png"), EIN_PNG);
+ const mitVideoId = d.prepare(
+ "SELECT id FROM eintraege WHERE bereich = 'highlight' AND quelle_url IS NOT NULL"
+ + " AND quelle_fehlt_seit IS NULL").get().id;
+ d.prepare("INSERT INTO dateien (name_original, name_datei, groesse, typ, status,"
+ + " hochgeladen_von, erstellt, eintrag_id) VALUES (?,?,?,?,?,?,?,?)")
+ .run("standbild.png", "probe-standbild.png", EIN_PNG.length, "image/png",
+ "freigegeben", wer, jetzt, mitVideoId);
+ }
+
+ /* FREIGEBEN -- sonst sieht ein Mitglied gar nichts.
+
+ Im Treff ist ein Beitrag erst sichtbar, wenn jemand aus dem Team
+ ihn freigegeben hat (`treff_freigaben`). Beim ersten Lauf hat
+ genau das gefehlt: Die drei Karten standen in der Datenbank, das
+ Brett blieb leer, und vier Prüfungen meldeten „0 Karten" -- was wie
+ ein Fehler am Klickverhalten aussah und keiner war. Wer eine
+ Oberfläche prüft, muss den Zustand herstellen, in dem sie im
+ Alltag steht. */
+ const wann = new Date().toISOString();
+ for (const z of d.prepare("SELECT id FROM eintraege WHERE bereich = 'highlight'").all()) {
+ d.prepare("INSERT INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)")
+ .run(z.id, wann, wer);
+ }
+ const n = d.prepare("SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'highlight'").get().n;
+ const f = d.prepare("SELECT COUNT(*) AS n FROM treff_freigaben").get().n;
+ d.close();
+ ok(n === 3, `drei Karten angelegt: mit Video, ohne Video, geloescht (${n})`);
+ ok(f === 3, `und alle drei freigegeben, sonst sieht ein Mitglied nichts (${f})`);
+}
+
+/* =======================================================================
+ 4. IM BROWSER -- klickt die Flaeche wirklich?
+
+ ALLES DAVOR IST VORARBEIT. Ob eine Karte anklickbar ist, entscheidet
+ sich nicht im Quelltext, sondern unter einem Finger.
+ ======================================================================= */
+melde("");
+melde("=== 4. Im Browser ===");
+{
+ let pw = null;
+ try {
+ pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+ } catch { /* dritter Ausgang unten */ }
+
+ if (!pw) {
+ /* DRITTER AUSGANG: nicht „bestanden“, sondern „konnte nicht
+ nachsehen" -- und die Zaehlung sagt das auch. */
+ melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
+ melde(" Das ist kein Befund. Die Abschnitte 1-3 gelten weiter.");
+ } else {
+ const browser = await pw.chromium.launch();
+ try {
+ for (const breite of [1400, 412]) {
+ melde("");
+ melde(` --- Breite ${breite} px ---`);
+ const ctx = await browser.newContext({
+ viewport: { width: breite, height: 950 },
+ isMobile: breite < 700, hasTouch: breite < 700 });
+ /* ANMELDUNG ALS KEKS, NICHT ÜBER DAS FORMULAR. Der Weg über die
+ Oberfläche prüft hier nichts, was nicht anderswo schon
+ geprüft wird -- er bricht nur bei jeder Änderung an der
+ Anmeldeseite, und dann sieht diese Prüfung kaputt aus,
+ obwohl an den Wegen nach draußen nichts fehlt. */
+ await ctx.addCookies(kGast.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 kaputt = [];
+ seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
+
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/start.html`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+
+ /* ---- 4a. Steht die Kachel wirklich auf dem Bildschirm? ---- */
+ const kachel = await seite.evaluate(() => {
+ const a = [...document.querySelectorAll('a[href*="unsere-seiten"]')][0];
+ if (!a) return null;
+ const r = a.getBoundingClientRect();
+ return { text: a.textContent.trim().slice(0, 40), breit: Math.round(r.width),
+ hoch: Math.round(r.height), sichtbar: r.width > 0 && r.height > 0 };
+ });
+ ok(!!kachel && kachel.sichtbar,
+ `die Kachel ist da und sichtbar (${kachel?.breit}x${kachel?.hoch} px)`);
+ /* GROSS GENUG FUER EINEN DAUMEN. 44 px ist das Mass, unter dem
+ ein Ziel auf dem Handy regelmaessig verfehlt wird. */
+ ok((kachel?.hoch || 0) >= 44,
+ `und hoch genug zum Antippen (${kachel?.hoch} px, mindestens 44)`);
+
+ /* ---- 4b. Die Seite selbst ---- */
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(500);
+
+ /* window.open abfangen, damit keine echten Fenster aufgehen --
+ und damit messbar wird, WOHIN es gegangen waere. */
+ await seite.evaluate(() => {
+ window.__wohin = [];
+ window.open = (u) => { window.__wohin.push(String(u)); return null; };
+ });
+
+ const karten = await seite.evaluate(() =>
+ [...document.querySelectorAll(".aussen[data-weg]")].map((k) => k.dataset.weg));
+ ok(karten.length === 2, `zwei Karten nach draussen (${karten.length})`);
+ ok(karten.some((w) => w.includes("dogfather-universe.com")), "eine zur Website");
+ ok(karten.some((w) => w.includes("vans-diy-bastelbedarf.com")), "eine zum Shop");
+
+ /* KLICK AUF DEN TEXT -- nicht auf den Knopf. Genau das war
+ Filipes Wunsch: „egal wo man drauf drückt“. */
+ await seite.click(".aussen--shop .aussen__satz");
+ await seite.waitForTimeout(250);
+ let wohin = await seite.evaluate(() => window.__wohin);
+ ok(wohin.length === 1 && wohin[0].includes("vans-diy-bastelbedarf"),
+ `ein Klick auf den TEXT fuehrt zum Shop (${wohin.join(", ") || "nichts"})`);
+
+ /* GEGENPROBE: Der Kopierknopf darf NICHT zusaetzlich die Karte
+ ausloesen. Ohne diese Zeile waere „die Flaeche klickt“ auch
+ dann gruen, wenn sie ueberall klickt -- auch dort, wo es
+ stoert. */
+ await seite.evaluate(() => { window.__wohin = []; });
+ await seite.click("#code-knopf");
+ await seite.waitForTimeout(250);
+ wohin = await seite.evaluate(() => window.__wohin);
+ ok(wohin.length === 0,
+ `der Kopierknopf oeffnet NICHTS (${wohin.length} Fenster)`);
+
+ const hinweis = await seite.textContent("#code-hinweis");
+ ok(/kopiert|markiert|abschreiben/.test(hinweis || ""),
+ `und sagt, was er getan hat: ${(hinweis || "").trim()}`);
+
+ /* GEGENPROBE 2: Der eigene Knopf oeffnet genau EINMAL -- nicht
+ zweimal, weil die Karte mitklickt. */
+ await seite.evaluate(() => {
+ window.__wohin = [];
+ /* Der Knopf ist ein echter Link; sein Standardverhalten
+ unterbinden, sonst navigiert die Prueferin weg. */
+ for (const a of document.querySelectorAll(".aussen__knopf")) {
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ window.__wohin.push(a.href);
+ });
+ }
+ });
+ await seite.click(".aussen--dogi .aussen__knopf");
+ await seite.waitForTimeout(250);
+ wohin = await seite.evaluate(() => window.__wohin);
+ ok(wohin.length === 1,
+ `der Knopf „Seite oeffnen“ wirkt genau einmal (${wohin.length}x)`);
+
+ /* ---- 4c. Die Highlight-Karten ---- */
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/bereich.html?b=highlight`,
+ { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1200);
+ await seite.evaluate(() => {
+ window.__wohin = [];
+ window.open = (u) => { window.__wohin.push(String(u)); return null; };
+ });
+
+ const stand = await seite.evaluate(() =>
+ [...document.querySelectorAll(".eintrag-karte")].map((k) => ({
+ titel: k.querySelector(".eintrag-titel")?.textContent?.trim() || "",
+ klickbar: k.dataset.klickbar === "ja",
+ })));
+ ok(stand.length === 3, `drei Karten stehen da (${stand.length})`);
+
+ const mitVideo = stand.find((s) => s.titel.includes("Komet"));
+ const ohneVideo = stand.find((s) => s.titel.includes("Nur ein Bild"));
+ const fort = stand.find((s) => s.titel.includes("Nicht mehr da"));
+
+ ok(mitVideo?.klickbar === true, "die Karte MIT Video ist klickbar");
+ /* DIE BEIDEN GEGENPROBEN. Ohne sie waere „klickbar“ auch dann
+ gruen, wenn ALLE Karten klicken -- und ein Klick auf ein
+ geloeschtes Video fuehrt auf eine Fehlerseite. */
+ ok(ohneVideo?.klickbar === false, "die Karte OHNE Video ist es nicht");
+ ok(fort?.klickbar === false,
+ "und die mit geloeschtem Video auch nicht (sonst Fehlerseite)");
+
+ /* Klick auf die Ueberschrift -- „egal wo“. */
+ const titelWahl = ".eintrag-karte[data-klickbar='ja'] .eintrag-titel";
+ await seite.click(titelWahl);
+ await seite.waitForTimeout(250);
+ wohin = await seite.evaluate(() => window.__wohin);
+ ok(wohin.length === 1 && wohin[0] === VIDEO,
+ `ein Klick auf die UEBERSCHRIFT fuehrt zum Video (${wohin[0] || "nichts"})`);
+
+ /* Und auf den Fliesstext. */
+ await seite.evaluate(() => { window.__wohin = []; });
+ const textWahl = ".eintrag-karte[data-klickbar='ja'] .eintrag-text";
+ if (await seite.$(textWahl)) {
+ await seite.click(textWahl);
+ await seite.waitForTimeout(250);
+ wohin = await seite.evaluate(() => window.__wohin);
+ ok(wohin.length === 1 && wohin[0] === VIDEO,
+ `und ein Klick auf den TEXT ebenso (${wohin[0] || "nichts"})`);
+ } else {
+ ok(false, "der Fliesstext der Karte liess sich nicht finden");
+ }
+
+ /* GEGENPROBE: Auf der NICHT klickbaren Karte passiert nichts. */
+ await seite.evaluate(() => { window.__wohin = []; });
+ const stiller = ".eintrag-karte:not([data-klickbar='ja']) .eintrag-titel";
+ await seite.click(stiller);
+ await seite.waitForTimeout(250);
+ wohin = await seite.evaluate(() => window.__wohin);
+ ok(wohin.length === 0,
+ `auf einer Karte ohne Video passiert NICHTS (${wohin.length})`);
+
+ /* DER TIKTOK-KNOPF MUSS WEITER EIN LINK SEIN. Waere er von
+ einem umschlossen worden, haette der Browser ihn still
+ aufgeloest -- die haeufigste Art, eine Karte kaputtzumachen. */
+ const knopf = await seite.evaluate(() => {
+ const a = document.querySelector(".eintrag-karte[data-klickbar='ja'] .videozeile__weg");
+ return a ? { tag: a.tagName, href: a.getAttribute("href"),
+ imLink: !!a.closest("a a") } : null;
+ });
+ ok(knopf?.tag === "A" && knopf.href === VIDEO,
+ `der TikTok-Knopf ist noch ein echter Link (${knopf?.tag})`);
+ ok(knopf?.imLink === false, "und steckt in keinem zweiten Link");
+
+ /* DAS BILD FUEHRT ZUM VIDEO, nicht mehr ins Vollbild -- das war
+ Filipes „foto“. Ohne Bilddatei gibt es keinen Bildstreifen;
+ dann ist hier nichts zu messen, und das muss man sehen. */
+ const bild = await seite.evaluate(() => {
+ const a = document.querySelector(".eintrag-karte[data-klickbar='ja'] .bildstreifen__feld");
+ return a ? { href: a.getAttribute("href"), label: a.getAttribute("aria-label") } : null;
+ });
+ if (bild) {
+ ok(bild.href === VIDEO, `das Bild fuehrt zum Video (${bild.href})`);
+ ok(/Video ansehen/.test(bild.label || ""),
+ `und sagt das auch vorlesbar („${bild.label}“)`);
+ } else {
+ /* KEIN STILLES UEBERSPRINGEN. Das Bild ist angelegt; fehlt der
+ Streifen trotzdem, ist etwas kaputt -- entweder lud das
+ Bild nicht (dann entfernt der Fehlerbehandler den Rahmen)
+ oder die Karte zeigt gar keine Anhaenge mehr. Beides ist
+ ein Befund, kein Grund zu schweigen. */
+ ok(false, "der Bildstreifen fehlt, obwohl ein Bild angehaengt ist");
+ }
+
+ ok(kaputt.length === 0,
+ `kein Skriptfehler auf dem Weg${kaputt.length ? ": " + kaputt.join(" | ") : ""}`);
+ await ctx.close();
+ }
+ } finally {
+ await browser.close();
+ }
+ }
+}
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index d7230d2b..7f002f2e 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -351,6 +351,20 @@ export const SEITEN = {
sondern eine Einladung zur Vergeltung. */
"/workspace/treff-moderation.html": ["modi", "hand", "admin"],
+ /* UNSERE SEITEN (19.09.2026). Die Website und VanVans Shop, mit dem
+ Rabattcode fuer die Community.
+
+ GENAU DIE VIER ROLLEN DES TREFFS -- also MIT "gast", anders als die
+ Moderationsseite eine Zeile darueber. Sie ist ausdruecklich FUER die
+ Community gebaut; ohne "gast" waere die Kachel bei jedem Mitglied
+ sichtbar und beim Antippen zu. Genau diese Sorte toter Weg hat die
+ Sichtpruefung am 18.09. zehnmal gefunden.
+
+ Die Agentur steht bewusst nicht dabei (siehe TREFF_ROLLEN in
+ workspace.js): Creator, Scouts und Spicy Media haben mit dem Treff
+ nichts zu tun. */
+ "/workspace/unsere-seiten.html": ["gast", "modi", "hand", "admin"],
+
/* WER SIEHT WAS. DogFather und die rechte Hand (Filipe, 11.09.2026:
"ich will die kategorie auch auf der team dogi seite fuer die
dogfather und rechte hand rollen").
diff --git a/server/workspace.js b/server/workspace.js
index 62fcedba..941115b0 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -938,6 +938,28 @@ const TREFF_BEREICHE = [
zeichen: "schutz", ton: 28, ziel: "bereich.html?b=regeln", szene: "studio" },
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
+ /* UNSERE SEITEN (19.09.2026) -- die einzige Kachel, die HINAUSFUEHRT.
+
+ Filipe: "ich brauch im community bereich noch eine kachel, die wenn
+ man drauf dr\u00fcckt dan kacheln hat die auf die website von dogfather
+ universe geht ... und die shop site von vanvan."
+
+ SIE IST KEIN BRETT, sondern eine Seite -- deshalb ein Dateiname als
+ Ziel und kein "bereich.html?b=...". Die Ableitung von TREFF_BRETTER
+ weiter unten nimmt sie dadurch von selbst nicht mit, genau wie die
+ Moderationskachel. Eine zweite Liste, die man pflegen muesste,
+ entsteht nicht.
+
+ UND SIE STEHT AM ENDE. Die sieben davor fuehren nach innen: lesen,
+ schreiben, mitmachen. Diese eine verlaesst den Treff -- das ist der
+ letzte Schritt, nicht der erste.
+
+ RASTERRECHNUNG (drei Spalten, "Der Treff" belegt zwei): Mit dieser
+ Kachel sind es fuer die Community 7 + 1 Kacheln = 9 Plaetze, also
+ genau drei volle Reihen. Vorher waren es acht Plaetze und damit
+ eine Luecke am Ende. Sie passt also nicht nur inhaltlich. */
+ { ...TREFF_GRUPPE, name: "Unsere Seiten", unter: "Website, Shop & dein Rabatt",
+ zeichen: "hinaus", ton: 38, ziel: "unsere-seiten.html", szene: "portal" },
];
/* MELDUNGEN & MASSNAHMEN -- FUER DAS TEAM, NICHT FUER DIE COMMUNITY
diff --git a/tools/turn-einrichten.mjs b/tools/turn-einrichten.mjs
index e4d52f86..fef16130 100644
--- a/tools/turn-einrichten.mjs
+++ b/tools/turn-einrichten.mjs
@@ -30,7 +30,8 @@ import { randomBytes } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
-import { turnGeheimnisLesen, TURN_GEHEIMNIS_DATEI } from "../server/workspace-turn.js";
+import { turnGeheimnisLesen, turnZugang, TURN_GEHEIMNIS_DATEI } from "../server/workspace-turn.js";
+import { zuteilungPruefen } from "./turn-sprache.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PFAD = process.env.WORKSPACE_DB
@@ -188,6 +189,61 @@ for (const a of adressen) {
db.close();
+/* ---- TRAEGT DIE VERMITTLUNG AUCH? (19.09.2026) ---------------------
+
+ BIS HIERHER HAT DIESES WERKZEUG NUR GEFRAGT: "bist du da?" Und
+ genau daran ist es gescheitert.
+
+ Vom 18. auf den 19.09. stand hier vier Tage lang gruen:
+ "Eingetragen, Geheimnis lesbar, Server antwortet." Jedes Wort wahr.
+ Und trotzdem hatte coturn in dieser ganzen Zeit KEINE EINZIGE
+ Zuteilung geschafft -- in /etc/turnserver.conf fehlte
+ `static-auth-secret`, also suchte er die Zugangsdaten in seiner
+ eigenen Datenbank und wies jeden ab. Ich habe Filipe auf Basis
+ dieser gruenen Zeile gesagt, Anrufe aus fremden Netzen seien
+ nachgewiesen. Sie waren nie moeglich.
+
+ Eine STUN-Bindung beantwortet "laeuft der Dienst". Eine Zuteilung
+ beantwortet "traegt er einen Anruf". Das sind zwei Fragen, und nur
+ die zweite ist die, wegen der coturn ueberhaupt aufgesetzt wurde. */
+let vermittlung = null;
+if (adressen.length && g.wert) {
+ const erste = adressen.find((a) => /^turns?:/.test(a.urls || ""));
+ const m = erste && /^turns?:([^:/?]+)(?::(\d+))?/.exec(erste.urls);
+ if (!m) {
+ vermittlung = { stand: "weiss_nicht",
+ grund: "keine turn:-Adresse eingetragen (nur stun: vermittelt nichts)" };
+ } else {
+ /* Die Zugangsdaten rechnen wir uns selbst aus -- mit derselben
+ Funktion, die der Server fuer den Browser benutzt. Waeren es
+ andere, wuerde die Pruefung etwas messen, das im Betrieb nie
+ vorkommt. Die Kennung 0 sagt im coturn-Protokoll: niemand
+ Bestimmtes. */
+ const z = turnZugang(0, g.wert);
+ console.log("");
+ console.log("Zuteilung wird versucht ...");
+ vermittlung = await zuteilungPruefen({
+ host: m[1], port: Number(m[2] || 3478),
+ user: z.username, pass: z.credential,
+ });
+ }
+
+ if (vermittlung.stand === "traegt") {
+ const p = vermittlung.zugeteilt;
+ console.log(` Zuteilung gelingt: ${p.ip}:${p.hafen} (Bereich „${vermittlung.bereich}")`);
+ if (vermittlung.paket === true) {
+ console.log(" Ein Testpaket kommt darueber an -- die Vermittlung traegt wirklich.");
+ } else if (vermittlung.paket === false) {
+ console.log(` ABER: ${vermittlung.paketGrund}`);
+ }
+ } else if (vermittlung.stand === "traegt_nicht") {
+ console.log(` ZUTEILUNG ABGELEHNT (Fehler ${vermittlung.code})`);
+ console.log(` ${vermittlung.grund}`);
+ } else {
+ console.log(` KONNTE NICHT NACHSEHEN: ${vermittlung.grund}`);
+ }
+}
+
console.log("");
if (!adressen.length) {
console.log("ERGEBNIS: Keine Adresse eingetragen -- Anrufe klappen nur im selben Netz.");
@@ -201,4 +257,24 @@ if (versucht && erreichbar < versucht) {
console.log(`ERGEBNIS: ${erreichbar} von ${versucht} Adressen antworten.`);
process.exit(1);
}
-console.log("ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet.");
+/* DREI AUSGAENGE, NICHT ZWEI. "Konnte nicht nachsehen" darf nicht wie
+ "in Ordnung" aussehen -- sonst entsteht wieder eine gruene Zeile,
+ die nichts weiss. Rueckgabewert 2 sagt das auch einem Skript. */
+if (vermittlung?.stand === "traegt_nicht") {
+ console.log("ERGEBNIS: Der Server antwortet, aber er VERMITTELT NICHT.");
+ console.log(" Anrufe aus Mobilfunk und strengen WLANs kommen nicht zustande.");
+ process.exit(1);
+}
+if (!vermittlung || vermittlung.stand === "weiss_nicht") {
+ console.log("ERGEBNIS: Ob die Vermittlung traegt, konnte nicht geprueft werden.");
+ console.log(` ${vermittlung?.grund || "kein Versuch moeglich"}`);
+ console.log(" Das ist KEIN Befund -- es heisst nur, dass gerade niemand nachsehen konnte.");
+ process.exit(2);
+}
+if (vermittlung.paket === false) {
+ console.log("ERGEBNIS: Die Zuteilung gelingt, aber kein Paket kommt durch.");
+ console.log(` ${vermittlung.paketGrund}`);
+ process.exit(1);
+}
+console.log("ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet");
+console.log(" UND die Vermittlung traegt (Zuteilung geprueft, Paket angekommen).");
diff --git a/tools/turn-sprache.mjs b/tools/turn-sprache.mjs
new file mode 100644
index 00000000..ae39a1dd
--- /dev/null
+++ b/tools/turn-sprache.mjs
@@ -0,0 +1,257 @@
+/* =====================================================================
+ DIE SPRACHE DES VERMITTLUNGSSERVERS (19.09.2026)
+
+ WARUM ES DIESE DATEI GIBT -- und warum sie einen Fehler behebt, der
+ mich Filipes Vertrauen gekostet hat:
+
+ Am 18.09. habe ich ihm gemeldet, coturn laufe und sei „von aussen
+ nachgewiesen". Am 19.09. stellte sich heraus: Der Vermittlungsserver
+ hatte NIE eine einzige Zuteilung geschafft. In turnserver.conf fehlte
+ `static-auth-secret`; coturn suchte die Zugangsdaten in seiner
+ eigenen Datenbank und wies jeden ab.
+
+ Und mein Einrichtungswerkzeug meldete die ganze Zeit:
+
+ "ERGEBNIS: Eingetragen, Geheimnis lesbar, Server antwortet."
+
+ Jedes Wort davon war wahr. Die Adresse war eingetragen, das
+ Geheimnis war lesbar, der Server antwortete auf eine STUN-Anfrage.
+ Nur beantwortet das zusammen NICHT die Frage, die zaehlt: Vermittelt
+ er auch? Eine gruene Meldung, die das Falsche prueft -- genau die
+ Sorte, vor der in CLAUDE.md seit dem 01.09. ein Absatz steht.
+
+ DER UNTERSCHIED IN EINEM SATZ: Eine STUN-Bindung fragt „bist du da?".
+ Eine Zuteilung fragt „traegst du meinen Anruf?". Das erste kann
+ gelingen, waehrend das zweite unmoeglich ist -- und genau so war es
+ vier Tage lang.
+
+ Deshalb liegt das Protokoll jetzt HIER und nicht zweimal daneben:
+ `turn-einrichten.mjs` und `turn-von-aussen.mjs` benutzen dieselbe
+ Rechnung. Eine zweite Abschrift waere die, die beim naechsten Umbau
+ auseinanderlaeuft.
+ ===================================================================== */
+import { createSocket } from "node:dgram";
+import { randomBytes, createHash, createHmac } from "node:crypto";
+
+/** Der feste Erkennungswert aus RFC 5389 -- er steht in jeder
+ * Nachricht und trennt STUN von allem anderen, was auf dem Port
+ * ankommt. */
+export const KOCHTOPF = 0x2112a442;
+
+/** Die Nachrichtenarten, die wir brauchen. */
+export const M = {
+ bindung: 0x0001, // "wie sehe ich von draussen aus?"
+ zuteilung: 0x0003, // "gib mir eine Adresse, ueber die man mich erreicht"
+ erlaubnis: 0x0008, // "von dieser Gegenstelle nehme ich Pakete an"
+};
+
+/** Die Felder in einer Nachricht. */
+export const A = {
+ ABBILD: 0x0001, BENUTZER: 0x0006, NACHRICHT_ECHT: 0x0008, FEHLER: 0x0009,
+ GEGENSTELLE: 0x0012, BEREICH: 0x0014, EINMALWERT: 0x0015,
+ WEITERLEITUNG: 0x0016, DATEN: 0x0013, TRANSPORT: 0x0019, XOR_ABBILD: 0x0020,
+};
+
+/** Antwortarten, die wir erkennen wollen. */
+export const ANTWORT = {
+ zuteilung_ok: 0x0103, zuteilung_nein: 0x0113,
+ erlaubnis_ok: 0x0108, daten: 0x0017,
+};
+
+export function attribut(art, inhalt) {
+ const fuell = (4 - (inhalt.length % 4)) % 4;
+ const b = Buffer.alloc(4 + inhalt.length + fuell);
+ b.writeUInt16BE(art, 0);
+ b.writeUInt16BE(inhalt.length, 2); // Laenge OHNE Fuellbytes -- das ist die Falle
+ inhalt.copy(b, 4);
+ return b;
+}
+
+export function nachricht(art, kennung, attribute, zugang) {
+ let koerper = Buffer.concat(attribute);
+ const kopf = Buffer.alloc(20);
+ kopf.writeUInt16BE(art, 0);
+ kopf.writeUInt32BE(KOCHTOPF, 4);
+ kennung.copy(kopf, 8);
+
+ if (zugang) {
+ /* Der Pruefwert wird ueber die Nachricht gerechnet, deren Laenge
+ ihn schon MITZAEHLT -- sonst rechnet die Gegenseite etwas
+ anderes aus als wir. Das ist der Punkt, an dem eigene
+ STUN-Versuche typischerweise scheitern. */
+ kopf.writeUInt16BE(koerper.length + 24, 2);
+ const schluessel = createHash("md5")
+ .update(`${zugang.user}:${zugang.bereich}:${zugang.pass}`, "utf8").digest();
+ const wert = createHmac("sha1", schluessel)
+ .update(Buffer.concat([kopf, koerper])).digest();
+ koerper = Buffer.concat([koerper, attribut(A.NACHRICHT_ECHT, wert)]);
+ }
+ kopf.writeUInt16BE(koerper.length, 2);
+ return Buffer.concat([kopf, koerper]);
+}
+
+export function lies(b) {
+ if (b.length < 20) return null;
+ const art = b.readUInt16BE(0);
+ const attribute = {};
+ let p = 20;
+ while (p + 4 <= b.length) {
+ const a = b.readUInt16BE(p), l = b.readUInt16BE(p + 2);
+ attribute[a] = b.subarray(p + 4, p + 4 + l);
+ p += 4 + l + ((4 - (l % 4)) % 4);
+ }
+ return { art, kennung: b.subarray(8, 20), attribute };
+}
+
+/** Adressen stehen verodert in der Nachricht -- sonst wuerden Geraete
+ * unterwegs daran herumbessern, weil sie meinen, sie muessten. */
+export function adresse(b, kennung) {
+ if (!b || b.length < 8) return null;
+ const hafen = b.readUInt16BE(2) ^ (KOCHTOPF >>> 16);
+ const schluessel = Buffer.concat([
+ Buffer.from([(KOCHTOPF >>> 24) & 255, (KOCHTOPF >>> 16) & 255,
+ (KOCHTOPF >>> 8) & 255, KOCHTOPF & 255]), kennung]);
+ const ip = [];
+ for (let i = 0; i < b.length - 4; i++) ip.push(b[4 + i] ^ schluessel[i]);
+ return { ip: ip.join("."), hafen };
+}
+
+/** Eine Adresse als Gegenstellen-Feld verpacken. */
+export function gegenstelle(ip) {
+ const teile = String(ip).split(".").map(Number);
+ const b = Buffer.alloc(8);
+ b[1] = 1; // IPv4
+ b.writeUInt16BE(1 ^ (KOCHTOPF >>> 16), 2);
+ const s = [(KOCHTOPF >>> 24) & 255, (KOCHTOPF >>> 16) & 255,
+ (KOCHTOPF >>> 8) & 255, KOCHTOPF & 255];
+ for (let i = 0; i < 4; i++) b[4 + i] = teile[i] ^ s[i];
+ return b;
+}
+
+export function schicke(s, daten, host, port, frist = 4000) {
+ return new Promise((fertig) => {
+ const uhr = setTimeout(() => { s.removeListener("message", zuhoerer); fertig(null); }, frist);
+ const zuhoerer = (a) => { clearTimeout(uhr); s.removeListener("message", zuhoerer); fertig(a); };
+ s.on("message", zuhoerer);
+ s.send(daten, port, host);
+ });
+}
+
+/* =====================================================================
+ DIE EIGENTLICHE FRAGE: TRAEGT DIE VERMITTLUNG?
+
+ Gibt IMMER dreierlei zurueck, nie nur ja/nein:
+
+ stand: "traegt" -- eine Zuteilung kam zustande
+ "traegt_nicht" -- der Server hat abgelehnt (mit Code)
+ "weiss_nicht" -- wir konnten gar nicht fragen
+
+ Der dritte Ausgang ist der wichtige. Ohne ihn sieht „keine Antwort"
+ genauso aus wie „abgelehnt", und ein Netzproblem wuerde als kaputte
+ Konfiguration gemeldet.
+ ===================================================================== */
+export async function zuteilungPruefen({ host, port = 3478, user, pass,
+ paketPruefen = true, frist = 4000 }) {
+ if (!user || !pass) {
+ return { stand: "weiss_nicht", grund: "keine Zugangsdaten uebergeben" };
+ }
+
+ const s = createSocket("udp4");
+ try {
+ await new Promise((f) => s.bind(f));
+
+ /* 1. Ohne Anmeldung fragen. Die Ablehnung liefert Bereich und
+ Einmalwert, die der zweite Versuch braucht. */
+ const k1 = randomBytes(12);
+ const a1 = lies(await schicke(s, nachricht(M.zuteilung, k1,
+ [attribut(A.TRANSPORT, Buffer.from([17, 0, 0, 0]))]), host, port, frist) || Buffer.alloc(0));
+
+ if (!a1) {
+ return { stand: "weiss_nicht",
+ grund: `keine Antwort von ${host}:${port} -- Dienst aus, Port zu oder Adresse falsch` };
+ }
+ const bereich = a1.attribute[A.BEREICH]?.toString("utf8") || "";
+ const einmal = a1.attribute[A.EINMALWERT];
+ if (a1.art !== ANTWORT.zuteilung_nein || !bereich || !einmal) {
+ return { stand: "weiss_nicht",
+ grund: "der Server verlangt gar keine Anmeldung -- das ist kein coturn im erwarteten Zustand" };
+ }
+
+ /* 2. Mit Anmeldung. DAS ist die Frage, die zaehlt. */
+ const zugang = { user, pass, bereich };
+ const k2 = randomBytes(12);
+ const a2 = lies(await schicke(s, nachricht(M.zuteilung, k2, [
+ attribut(A.TRANSPORT, Buffer.from([17, 0, 0, 0])),
+ attribut(A.BENUTZER, Buffer.from(user, "utf8")),
+ attribut(A.BEREICH, Buffer.from(bereich, "utf8")),
+ attribut(A.EINMALWERT, einmal),
+ ], zugang), host, port, frist) || Buffer.alloc(0));
+
+ if (!a2) return { stand: "weiss_nicht", bereich, grund: "keine Antwort auf die Zuteilung" };
+
+ if (a2.art !== ANTWORT.zuteilung_ok) {
+ const c = a2.attribute[A.FEHLER];
+ const code = c ? c[2] * 100 + c[3] : 0;
+ const erklaerung =
+ code === 401 ? "Die Zugangsdaten passen nicht zum Geheimnis auf dem Server. "
+ + "Meist fehlt `static-auth-secret` in /etc/turnserver.conf "
+ + "oder es steht ein anderer Wert darin als in der Geheimnisdatei."
+ : code === 486 || code === 508
+ ? "Die Grenzen in turnserver.conf sind erreicht (Kontingent oder Ports)."
+ : "Unerwartete Ablehnung.";
+ return { stand: "traegt_nicht", bereich, code, grund: erklaerung };
+ }
+
+ const weiter = adresse(a2.attribute[A.WEITERLEITUNG], k2);
+ const meins = adresse(a2.attribute[A.XOR_ABBILD], k2);
+ if (!weiter) {
+ return { stand: "traegt_nicht", bereich, code: 0,
+ grund: "Zuteilung ohne Adresse -- damit laesst sich nichts anfangen" };
+ }
+ if (!paketPruefen) {
+ return { stand: "traegt", bereich, zugeteilt: weiter, wirSind: meins, paket: null };
+ }
+
+ /* 3. Uns selbst als Gegenstelle eintragen und ein Paket schicken.
+ ERST DAS beantwortet, ob der Portbereich offen ist -- alles
+ davor beantwortet es NICHT. */
+ const k3 = randomBytes(12);
+ const a3 = lies(await schicke(s, nachricht(M.erlaubnis, k3, [
+ attribut(A.GEGENSTELLE, gegenstelle(meins?.ip || "0.0.0.0")),
+ attribut(A.BENUTZER, Buffer.from(user, "utf8")),
+ attribut(A.BEREICH, Buffer.from(bereich, "utf8")),
+ attribut(A.EINMALWERT, einmal),
+ ], zugang), host, port, frist) || Buffer.alloc(0));
+
+ if (a3?.art !== ANTWORT.erlaubnis_ok) {
+ return { stand: "traegt", bereich, zugeteilt: weiter, wirSind: meins,
+ paket: false, paketGrund: "die Gegenstelle liess sich nicht eintragen" };
+ }
+
+ const gegen = createSocket("udp4");
+ try {
+ await new Promise((f) => gegen.bind(0, f));
+ const losung = "hallo-vermittlung";
+ const kam = await new Promise((fertig) => {
+ const uhr = setTimeout(() => fertig(false), 5000);
+ s.on("message", (b) => {
+ const n = lies(b);
+ if (n?.art === ANTWORT.daten && n.attribute[A.DATEN]) {
+ clearTimeout(uhr);
+ fertig(n.attribute[A.DATEN].toString("utf8") === losung);
+ }
+ });
+ gegen.send(Buffer.from(losung), weiter.hafen, weiter.ip);
+ });
+ return { stand: "traegt", bereich, zugeteilt: weiter, wirSind: meins,
+ paket: kam,
+ paketGrund: kam ? "" : `nichts kam an Port ${weiter.hafen} an -- der Portbereich ist zu` };
+ } finally {
+ try { gegen.close(); } catch { /* egal */ }
+ }
+ } catch (f) {
+ return { stand: "weiss_nicht", grund: `Fehler beim Nachsehen: ${f?.message || f}` };
+ } finally {
+ try { s.close(); } catch { /* egal */ }
+ }
+}
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 0cb6eb64..5e2c28be 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -5,9 +5,9 @@
Anruf-Probe · Team Dogi
-
-
-
+
+
+