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