diff --git a/_probe2.mjs b/_probe2.mjs new file mode 100644 index 00000000..ef5a1c98 --- /dev/null +++ b/_probe2.mjs @@ -0,0 +1,38 @@ +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +const server = spawn(process.execPath, ["index.js"], { cwd: "server", env: { ...process.env, PORT: "4434" }, stdio: "ignore" }); +const warte = async () => { for (let i=0;i<40;i++){ try { if ((await fetch("http://127.0.0.1:4434/index.html")).ok) return; } catch{} await new Promise(r=>setTimeout(r,250)); } throw new Error("kein Server"); }; +await warte(); +const b = await chromium.launch(); +const s = await b.newPage(); +await s.goto("http://127.0.0.1:4434/abonnieren.html", { waitUntil: "domcontentloaded" }); +await s.evaluate(() => { window.__v = []; document.addEventListener("securitypolicyviolation", (e) => window.__v.push({ dir: e.violatedDirective, blockiert: e.blockedURI })); }); +const paypal = await s.evaluate(async () => { + const geladen = await new Promise((res) => { const sk = document.createElement("script"); sk.src = "https://www.paypal.com/sdk/js?client-id=test¤cy=EUR"; sk.onload = () => res(true); sk.onerror = () => res(false); document.body.appendChild(sk); setTimeout(() => res(false), 15000); }); + if (!geladen || !window.paypal) return { geladen, buttons: false }; + const ziel = document.createElement("div"); ziel.id = "pp-test"; ziel.style.width = "300px"; document.body.appendChild(ziel); + try { await window.paypal.Buttons({}).render("#pp-test"); } catch (e) { return { geladen, buttons: false, fehler: String(e).slice(0,150) }; } + const f = ziel.querySelector("iframe"); + return { geladen, buttons: !!f, rahmenHoehe: f ? Math.round(f.getBoundingClientRect().height) : 0 }; +}); +console.log("PAYPAL:", JSON.stringify(paypal)); +const google = await s.evaluate(async () => { + const geladen = await new Promise((res) => { const sk = document.createElement("script"); sk.src = "https://accounts.google.com/gsi/client"; sk.onload = () => res(true); sk.onerror = () => res(false); document.head.appendChild(sk); setTimeout(() => res(false), 15000); }); + if (!geladen || !window.google) return { geladen, knopf: false }; + const ziel = document.createElement("div"); ziel.id = "g-test"; document.body.appendChild(ziel); + try { + window.google.accounts.id.initialize({ client_id: "000000000000-testtesttesttesttesttest.apps.googleusercontent.com", callback: () => {} }); + window.google.accounts.id.renderButton(ziel, { type: "standard" }); + } catch (e) { return { geladen, knopf: false, fehler: String(e).slice(0,150) }; } + await new Promise(r => setTimeout(r, 4000)); + return { geladen, knopf: ziel.children.length > 0, inhalt: ziel.innerHTML.slice(0, 60) }; +}); +console.log("GOOGLE:", JSON.stringify(google)); +await s.waitForTimeout(3000); +const v = await s.evaluate(() => window.__v); +const zus = {}; +for (const e of v) { const k = e.dir + " <- " + (e.blockiert || "(inline)"); zus[k] = (zus[k]||0)+1; } +console.log("\nVERSTOESSE:", Object.keys(zus).length === 0 ? "KEINE" : ""); +Object.entries(zus).sort().forEach(([k,n]) => console.log(" " + n + "x " + k)); +console.log("\nRahmen:"); s.frames().forEach(f => console.log(" " + f.url().slice(0,75))); +await b.close(); server.kill(); diff --git a/pruef-kasse.mjs b/pruef-kasse.mjs new file mode 100644 index 00000000..ba512861 --- /dev/null +++ b/pruef-kasse.mjs @@ -0,0 +1,185 @@ +/* ===================================================================== + Kasse & Anmeldung gegen die Inhaltsrichtlinie (27.08.2026) + + WORUM ES GEHT + + Auf abonnieren.html kommen zwei Dinge von fremden Servern: die + PayPal-Kasse und die Google-Anmeldung. Beide werden erst NACH einer + Nutzerhandlung nachgeladen (Klick bzw. Anmeldung) und beide sind + derzeit nicht scharf geschaltet -- googleClientId und paypalClientId + stehen auf null. Ein Fehler dort fällt deshalb heute NICHT auf. Er + fällt erst an dem Tag auf, an dem Dogi seine echten Zugangsdaten + einträgt: Dann stünde an der Kasse ein leerer Bereich, ohne + Fehlermeldung, ohne Eintrag im Server-Protokoll. + + Genau dieses Muster hat schon einmal zugeschlagen (Musik-Knopf, + 26.-27.08.2026). Deshalb wird hier vorgezogen geprüft, statt zu warten. + + WIE ECHT DIESER TEST IST + + - Er startet den ECHTEN server/index.js, damit die echte Kopfzeile + ausgeliefert wird. Ein Datei-Server erzeugt sie gar nicht. + - Er lädt das ECHTE PayPal-SDK mit deren offizieller Testkennung + ("client-id=test") und baut die Knöpfe wirklich auf. + - Er lädt die ECHTE Google-Bibliothek und rendert den Knopf wirklich. + - Verstöße werden nicht aus Konsolentext geraten, sondern über das + Ereignis "securitypolicyviolation" gezählt -- dort stehen Richtlinie + und blockierte Adresse maschinenlesbar drin. + + Kein echter Zugangscode und kein echtes Geld sind beteiligt. + ===================================================================== */ +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const PORT = 4435; +const BASIS = `http://127.0.0.1:${PORT}`; + +let ok = 0, fehl = 0; +const funde = []; +const pruefe = (name, gut, info) => { + if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } + else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); funde.push(name + " — " + info); } +}; + +const server = spawn(process.execPath, ["index.js"], { + cwd: join(HIER, "server"), + env: { ...process.env, PORT: String(PORT) }, + stdio: "ignore", +}); +const beenden = (code) => { try { server.kill(); } catch { /* schon weg */ } process.exit(code); }; +/* Notbremse: PayPals render() wartet ohne echten Bezahlvorgang unbegrenzt. + Ohne diese Grenze bliebe der Test im Zweifel ewig hängen. */ +const notbremse = setTimeout(() => { console.log("\nZEIT ABGELAUFEN — Testlauf abgebrochen."); beenden(1); }, 180000); +notbremse.unref(); + +async function warteAufServer() { + for (let i = 0; i < 40; i++) { + try { + const res = await fetch(BASIS + "/abonnieren.html"); + if (res.ok) return res; + } catch { /* noch nicht oben */ } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error("Server ist nicht hochgekommen"); +} + +(async () => { + const antwort = await warteAufServer(); + const richtlinie = antwort.headers.get("content-security-policy") || ""; + const regel = (name) => (richtlinie.split(";").find((t) => t.trim().startsWith(name + " ")) || "").trim(); + + console.log("\n== Was die Kopfzeile verspricht =="); + pruefe("script-src kennt PayPal", /www\.paypal\.com/.test(regel("script-src")), regel("script-src").slice(0, 120)); + pruefe("script-src kennt Google", /accounts\.google\.com\/gsi\/client/.test(regel("script-src")), ""); + pruefe("frame-src kennt PayPal (Betrieb + Übung)", /www\.paypal\.com/.test(regel("frame-src")) && /sandbox\.paypal\.com/.test(regel("frame-src")), regel("frame-src")); + pruefe("frame-src kennt Google", /accounts\.google\.com\/gsi\//.test(regel("frame-src")), ""); + pruefe("style-src kennt Googles Stilvorlage", /accounts\.google\.com\/gsi\/style/.test(regel("style-src")), regel("style-src")); + pruefe("Musik bleibt erlaubt", /open\.spotify\.com/.test(regel("frame-src")), ""); + pruefe("Kein Sternchen in frame-src", !/\*/.test(regel("frame-src")), regel("frame-src")); + pruefe("Kein 'unsafe-inline' bei Skripten", !/unsafe-inline/.test(regel("script-src")), ""); + + const browser = await chromium.launch(); + const seite = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await seite.goto(BASIS + "/abonnieren.html", { waitUntil: "domcontentloaded" }); + await seite.evaluate(() => { + window.__verstoesse = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__verstoesse.push({ regel: e.violatedDirective, blockiert: e.blockedURI || "(inline)" }) + ); + }); + + /* ---------- PayPal ---------- */ + console.log("\n== PayPal-Kasse (offizielle Testkennung) =="); + const paypal = await seite.evaluate(async () => { + const geladen = await new Promise((res) => { + const sk = document.createElement("script"); + sk.src = "https://www.paypal.com/sdk/js?client-id=test¤cy=EUR"; + sk.onload = () => res(true); + sk.onerror = () => res(false); + document.body.appendChild(sk); + setTimeout(() => res(false), 20000); + }); + if (!geladen || !window.paypal) return { geladen, knoepfe: false }; + const ziel = document.createElement("div"); + ziel.id = "kassen-test"; + ziel.style.width = "320px"; + document.body.appendChild(ziel); + /* Absichtlich NICHT abwarten: render() loest ohne echten Bezahlvorgang + nie auf. Es geht nur darum, ob der Rahmen entstehen darf. */ + try { window.paypal.Buttons({}).render("#kassen-test").catch(() => {}); } catch { /* egal */ } + await new Promise((r) => setTimeout(r, 9000)); + const rahmen = ziel.querySelector("iframe"); + return { geladen, knoepfe: !!rahmen, hoehe: rahmen ? Math.round(rahmen.getBoundingClientRect().height) : 0 }; + }); + pruefe("PayPal-SDK wird geladen", paypal.geladen, paypal.geladen ? "geladen" : "BLOCKIERT"); + pruefe("Bezahlknöpfe werden aufgebaut", paypal.knoepfe, paypal.knoepfe ? paypal.hoehe + "px hoch" : "kein Rahmen entstanden"); + + /* ---------- Google ---------- */ + console.log("\n== Google-Anmeldung =="); + const google = await seite.evaluate(async () => { + const geladen = await new Promise((res) => { + const sk = document.createElement("script"); + sk.src = "https://accounts.google.com/gsi/client"; + sk.onload = () => res(true); + sk.onerror = () => res(false); + document.head.appendChild(sk); + setTimeout(() => res(false), 20000); + }); + if (!geladen || !window.google) return { geladen, knopf: false }; + const ziel = document.createElement("div"); + ziel.id = "anmelde-test"; + document.body.appendChild(ziel); + try { + window.google.accounts.id.initialize({ + client_id: "000000000000-testtesttesttesttesttest.apps.googleusercontent.com", + callback: () => {}, + }); + window.google.accounts.id.renderButton(ziel, { type: "standard" }); + } catch { return { geladen, knopf: false }; } + await new Promise((r) => setTimeout(r, 5000)); + return { geladen, knopf: ziel.children.length > 0 }; + }); + pruefe("Google-Bibliothek wird geladen", google.geladen, google.geladen ? "geladen" : "BLOCKIERT"); + pruefe("Anmelde-Knopf wird aufgebaut", google.knopf, google.knopf ? "gerendert" : "leer geblieben"); + const googleRahmen = seite.frames().filter((f) => f.url().startsWith("https://accounts.google.com/gsi/")).length; + pruefe("Googles Rahmen darf laden", googleRahmen >= 1, googleRahmen + " Rahmen"); + + /* ---------- Das eigentliche Urteil ---------- */ + console.log("\n== Verstöße gegen die Inhaltsrichtlinie =="); + const verstoesse = await seite.evaluate(() => window.__verstoesse); + const gezaehlt = {}; + for (const v of verstoesse) { + const schluessel = v.regel + " <- " + v.blockiert.split("?")[0]; + gezaehlt[schluessel] = (gezaehlt[schluessel] || 0) + 1; + } + Object.entries(gezaehlt).forEach(([k, n]) => console.log(" " + n + "x " + k)); + pruefe("Kein einziger Verstoß", verstoesse.length === 0, verstoesse.length + " Verstöße"); + + const fehlerRahmen = seite.frames().filter((f) => f.url().startsWith("chrome-error://")).length; + pruefe("Kein Rahmen endet als Fehlerseite", fehlerRahmen === 0, fehlerRahmen + " Fehlerseiten"); + + /* Bilder von genau den beiden Testelementen. + + WICHTIG: Die Elemente werden dafür NICHT im Seitenbaum verschoben. + PayPal baut seine Knöpfe wieder ab, sobald ihr Rahmen umgehängt wird + — ein Bild davon zeigte dann fälschlich eine leere Kasse, obwohl sie + kurz zuvor sauber stand. */ + for (const [wahl, datei] of [["#kassen-test", "bild-kasse-paypal.png"], ["#anmelde-test", "bild-kasse-google.png"]]) { + try { + await seite.locator(wahl).scrollIntoViewIfNeeded(); + await seite.waitForTimeout(600); + await seite.locator(wahl).screenshot({ path: datei }); + } catch { /* Bild ist Beiwerk, kein Prüfkriterium */ } + } + await browser.close(); + clearTimeout(notbremse); + console.log("\n===== " + ok + " ok, " + fehl + " Fehler ====="); + if (funde.length) { console.log("\nOffene Punkte:"); funde.forEach((f) => console.log(" - " + f)); } + beenden(fehl ? 1 : 0); +})().catch((e) => { + console.error("Testlauf abgebrochen:", e.message); + beenden(1); +}); diff --git a/pruef-tempo.mjs b/pruef-tempo.mjs new file mode 100644 index 00000000..a0f09e16 --- /dev/null +++ b/pruef-tempo.mjs @@ -0,0 +1,67 @@ +/* ===================================================================== + Wie schwer und wie schnell sind die öffentlichen Seiten? + + Misst je Seite die übertragene Datenmenge und die Ladezeit bis + "fertig geladen", und sammelt die größten Einzelressourcen über alle + Seiten. Ziel ist NICHT ein grüner Haken, sondern das Finden konkreter + Brocken, die sich lohnen zu verkleinern -- so wie am 27.08. das 2,5-MB- + PNG "Event des Jahres" gefunden wurde. + + Gegen die echte Domain, mit leerem Cache (jeder Aufruf frische Seite). + ===================================================================== */ +import { chromium } from "playwright"; + +const BASIS = "https://dogfather-universe.com"; +const SEITEN = [ + "/", "/dogfather.html", "/hasidog.html", "/manager.html", "/casper.html", + "/vanvan.html", "/tt-muddi.html", "/zeitreise.html", "/streamer.html", + "/community.html", "/stimmen.html", "/streamplan.html", "/team-modis.html", + "/team-scouts.html", "/creator.html", "/werte.html", "/bewerben.html", + "/kontakt.html", "/links.html", "/supporter.html", "/abonnieren.html", + "/medien-dogfather.html", "/medien-hasidog.html", "/medien-casper.html", +]; + +const browser = await chromium.launch(); +const groessteJeRessource = new Map(); // url -> bytes +const proSeite = []; + +for (const pfad of SEITEN) { + const seite = await browser.newPage(); + let bytes = 0; + seite.on("response", async (r) => { + try { + const h = r.headers(); + const len = Number(h["content-length"] || 0); + let b = len; + if (!b) { try { b = (await r.body()).length; } catch { b = 0; } } + bytes += b; + const u = r.url(); + if (b > (groessteJeRessource.get(u) || 0)) groessteJeRessource.set(u, b); + } catch { /* egal */ } + }); + const start = Date.now(); + try { + await seite.goto(BASIS + pfad, { waitUntil: "networkidle", timeout: 45000 }); + } catch { /* trotzdem messen, was da war */ } + const dauer = Date.now() - start; + proSeite.push({ pfad, kb: Math.round(bytes / 1024), ms: dauer }); + await seite.close(); +} +await browser.close(); + +console.log("\n=== Pro Seite (übertragen / Ladezeit), schwerste zuerst ==="); +for (const s of proSeite.sort((a, b) => b.kb - a.kb)) { + const warn = s.kb > 2000 ? " ⚠ schwer" : s.kb > 1000 ? " · mittel" : ""; + console.log(` ${String(s.kb).padStart(5)} KB ${String(s.ms).padStart(5)} ms ${s.pfad}${warn}`); +} +const schnitt = Math.round(proSeite.reduce((a, s) => a + s.kb, 0) / proSeite.length); +console.log(` ---\n Schnitt: ${schnitt} KB / Seite`); + +console.log("\n=== Die 12 größten Einzelressourcen (Kandidaten zum Verkleinern) ==="); +const top = [...groessteJeRessource].sort((a, b) => b[1] - a[1]).slice(0, 12); +for (const [u, b] of top) { + const kb = Math.round(b / 1024); + const kurz = u.replace(BASIS, "").replace(/\?.*$/, ""); + const warn = kb > 500 ? " ⚠" : ""; + console.log(` ${String(kb).padStart(5)} KB ${kurz}${warn}`); +} diff --git a/server/inhaltsrichtlinie.js b/server/inhaltsrichtlinie.js index 9979ca51..d000ba84 100644 --- a/server/inhaltsrichtlinie.js +++ b/server/inhaltsrichtlinie.js @@ -50,6 +50,61 @@ import crypto from "crypto"; import { readFileSync, statSync } from "node:fs"; import { join, normalize } from "node:path"; +/* ===================================================================== + FREMDE QUELLEN — hier stehen sie namentlich, nicht verstreut + + Drei Dinge auf dieser Seite kommen nicht vom eigenen Server: die + Spotify-Player im Musik-Feld, die PayPal-Kasse und die + Google-Anmeldung auf abonnieren.html. + + Warum sie hier zusammenstehen: Wer eine Quelle vergisst, merkt es + NICHT beim Deploy und nicht im Server-Protokoll. Der Browser wirft + das Element still weg -- genau so ist am 26.08.2026 der Musik-Knopf + ausgefallen und erst am 27.08. aufgefallen. Eine Liste an einer + Stelle, mit Begründung je Eintrag, ist die einzige Stelle, an der man + beim nächsten Mal nachsieht. + + Ausgewählt wurde nach den Angaben der Anbieter (PayPal: "Best + Practices" der JS-SDK-Doku, Google: CSP-Abschnitt der Setup-Anleitung) + und danach mit pruef-kasse.mjs im echten Browser nachgemessen: Die + Kasse wird dort mit PayPals offizieller Testkennung wirklich + aufgebaut, die Anmeldung wirklich gerendert. Was dabei nicht gebraucht + wurde, steht hier auch nicht -- deshalb einzelne Adressen statt der + von PayPal vorgeschlagenen Platzhalter *.paypal.com. + ===================================================================== */ + +/* Spotify: die zwei Player im Musik-Feld (main.js, jede Seite). */ +const SPOTIFY = "https://open.spotify.com"; + +/* PayPal: das SDK selbst liegt auf www.paypal.com, Bilder/Schriften der + Knöpfe auf www.paypalobjects.com. Der Bezahlvorgang läuft anschließend + in einem Rahmen von www.paypal.com. + + ⚠️ Die Übungs-Umgebung (www.sandbox.paypal.com) steht bewusst MIT + drin, obwohl im Betrieb nur www.paypal.com gebraucht wird. Grund: + Beim Einrichten testet man zuerst mit Sandbox-Zugangsdaten -- das SDK + spricht dann ausschließlich mit der Sandbox-Adresse. Ohne diesen + Eintrag hätte Dogi beim ersten Testkauf eine Kasse gesehen, die sich + nicht abschließen lässt, ohne jeden Hinweis auf den Grund. Genau + dieser Verstoß ist bei der Messung mit PayPals Testkennung + aufgetreten -- geraten hätte man ihn nicht. */ +const PAYPAL_SKRIPT = "https://www.paypal.com https://www.paypalobjects.com"; +const PAYPAL_RAHMEN = "https://www.paypal.com https://www.sandbox.paypal.com"; +const PAYPAL_BILDER = "https://www.paypal.com https://www.paypalobjects.com"; +const PAYPAL_VERBINDUNG = "https://www.paypal.com https://www.paypalobjects.com https://www.sandbox.paypal.com"; + +/* Google-Anmeldung: Google nennt ausdrücklich die ELTERN-Adresse + .../gsi/ statt einzelner Endpunkte -- damit die Anmeldung nicht bricht, + wenn Google intern eine Adresse ändert. */ +const GOOGLE_SKRIPT = "https://accounts.google.com/gsi/client"; +const GOOGLE_RAHMEN = "https://accounts.google.com/gsi/"; +const GOOGLE_VERBINDUNG = "https://accounts.google.com/gsi/"; +/* Der Anmelde-Knopf holt sein Aussehen als eigene Stilvorlage. Wichtig: + 'unsafe-inline' weiter unten deckt das NICHT ab -- das erlaubt nur + Stile IM Dokument, keine nachgeladene Datei von fremder Adresse. Ohne + diesen Eintrag erscheint der Knopf unfertig/unformatiert. */ +const GOOGLE_STIL = "https://accounts.google.com/gsi/style"; + /* Gemerkte Prüfsummen je Datei, zusammen mit der Änderungszeit, aus der sie stammen. Ändert sich die Datei, wird neu gerechnet. */ const merker = new Map(); @@ -151,13 +206,13 @@ export function inhaltsrichtlinie(wurzel) { wenn ohnehin etwas grundlegend nicht stimmt. */ if (!summen) return next(); - const skript = ["'self'", ...summen].join(" "); + const skript = ["'self'", ...summen, PAYPAL_SKRIPT, GOOGLE_SKRIPT].join(" "); res.setHeader("Content-Security-Policy", [ "default-src 'self'", `script-src ${skript}`, /* Siehe Kopf der Datei: bewusste Ausnahme, eigener Punkt auf der Liste. */ - "style-src 'self' 'unsafe-inline'", + `style-src 'self' 'unsafe-inline' ${GOOGLE_STIL}`, /* data: für eingebettete Kleingrafiken, blob: für selbst erzeugte Vorschauen beim Hochladen. @@ -169,12 +224,12 @@ export function inhaltsrichtlinie(wurzel) { ihn: Der Deploy hätte Erfolg gemeldet, der Server wäre gestartet, und der Schaden wäre erst jemandem aufgefallen, der die Seite ansieht. */ - "img-src 'self' data: blob: https://postfach.dogfather-universe.com", + `img-src 'self' data: blob: https://postfach.dogfather-universe.com ${PAYPAL_BILDER}`, "font-src 'self'", /* Die Seite spricht mit dem eigenen Server und mit dem internen Bereich unter postfach. -- mehr nicht. Ein eingeschleustes Skript könnte damit keine Daten nach außen schicken. */ - "connect-src 'self' https://postfach.dogfather-universe.com", + `connect-src 'self' https://postfach.dogfather-universe.com ${PAYPAL_VERBINDUNG} ${GOOGLE_VERBINDUNG}`, /* Kein Flash, keine Java-Applets, keine eingebetteten Objekte. */ "object-src 'none'", /* Verhindert, dass ein eingeschleustes alle relativen @@ -209,8 +264,10 @@ export function inhaltsrichtlinie(wurzel) { Google-Anmeldung auf abonnieren.html arbeiten ebenfalls mit Rahmen), muss er hier NAMENTLICH ergänzt werden -- sonst wiederholt sich exakt dieser stille Ausfall an der Kasse. - pruef-musik.mjs prüft das im echten Browser mit. */ - "frame-src https://open.spotify.com", + pruef-musik.mjs und pruef-kasse.mjs prüfen das im echten Browser + mit -- inklusive der Kasse, die vorher genau hier gescheitert + wäre. */ + `frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`, "worker-src 'self'", /* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. */ "upgrade-insecure-requests",