From 356bb974b8a4bc3f2881eb7edb774da802208b59 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Sat, 22 Aug 2026 18:20:34 +0200 Subject: [PATCH] Knopf war unlesbar, und der Code wird jetzt bei jedem Schliessen neu verlangt Zwei Rueckmeldungen vom 22.08.2026, beide behoben und mit Tests abgesichert. 1) "das sieht nicht gut aus" (Bildschirmfoto des Hauptknopfs) Ursache: ".wd a" ist Klasse+Element und damit spezifischer als die reine Knopfklasse ".wd-btn--haupt". Die Textfarbe des Knopfs wurde dadurch ueberstimmt -- hellblaue Schrift auf hellblauem Grund, praktisch unlesbar. Derselbe Spezifitaetsfehler wie zuvor bei den Namen auf der Zugangswand. Fix: ".wd a:not(.wd-btn)" plus zweistufig geschriebene Knopfregeln, damit das nicht wieder passieren kann. 2) "das sieht lang gezogen aus" Die Pillenform (border-radius 999px) laesst breite Knoepfe auseinandergezogen wirken, weil der Radius optisch mit der Breite mitwaechst. Jetzt fester Radius von 14px -- gleiche Form bei jeder Breite, ruhiger und hochwertiger. 3) "ich will das ich jedes mal den code gefragt werde wenn man die seite zu macht" Das Sitzungs-Cookie allein reicht dafuer nicht: Browser stellen genau solche Cookies beim Wiederherstellen von Tabs zurueck ("Dort fortfahren, wo du aufgehoert hast"), man landet dann ohne Codeabfrage wieder mitten in der Seite. Zusaetzlich jetzt eine Sitzungsmarke im sessionStorage, die beim echten Schliessen verschwindet. Fehlt sie bei vorhandenem Cookie, wird die Sitzung serverseitig beendet und zur Zugangswand geleitet. Token-Notbremse von 24 auf 8 Stunden gesenkt. 13/13 Tests im echten Browser, inklusive Schutz vor Endlosschleife. DEPLOY.md: zwei Checkouts auf dem Server dokumentiert (/home/dogiweb und /home/dogiintern) und der Vorfall, dass /webdesign nach dem Pull kurz ohne Zugangsschutz erreichbar war, weil der Dienstneustart fehlte. Co-Authored-By: Claude Opus 5 --- DEPLOY.md | 48 ++++++++- assets/css/webdesign.css | 26 +++-- assets/js/wd-core.js | 62 +++++++++++- server/test-webdesign-sitzung.mjs | 157 ++++++++++++++++++++++++++++++ server/webdesign-gate.js | 23 +++-- webdesign/zugang.html | 6 ++ 6 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 server/test-webdesign-sitzung.mjs diff --git a/DEPLOY.md b/DEPLOY.md index f758d603..3f6604cb 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -36,11 +36,53 @@ ssh dogfather-server "cd /home/dogiweb/dogfather-universe && git pull --ff-only Statische Dateien (HTML/CSS/JS/Bilder) sind damit **sofort** live — der Express-Dienst liefert das Verzeichnis direkt aus, ein Neustart ist dafür **nicht** nötig. -Nur wenn Server-Code geändert wurde (`server/`, `server-internal/`): +## ⚠️ ES GIBT ZWEI CHECKOUTS AUF DEM SERVER, NICHT EINEN + +Am 22.08.2026 beim Ausrollen des Webdesign-Bereichs gefunden — diese Datei +beschrieb vorher nur den ersten und war damit unvollständig: + +| Dienst | Checkout | Liefert | +|---|---|---| +| `dogiweb.service` (Port 4100) | `/home/dogiweb/dogfather-universe/` | die öffentliche Website + `server/` | +| `dogiintern.service` (Port 4200) | `/home/dogiintern/dogfather-universe/` | die API unter `postfach.dogfather-universe.com` + `server-internal/` | + +**Ein `git pull` in `/home/dogiweb` ändert an `server-internal/` also gar nichts.** +Wer nur dort zieht und danach `dogiintern.service` neu startet, startet den +Dienst mit unverändertem Code neu und wundert sich, warum nichts passiert. + +`claudian` hat auf `/home/dogiintern/` **keinen Zugriff** (weder lesend noch +über den begrenzten sudo). Änderungen an `server-internal/` muss deshalb +Filipe selbst ausrollen: ```bash -ssh dogfather-server "sudo systemctl restart dogiweb.service" # öffentliche Seite -ssh dogfather-server "sudo systemctl restart dogiintern.service" # interner Bereich +sudo -u dogiintern git -C /home/dogiintern/dogfather-universe pull --ff-only origin main +sudo systemctl restart dogiintern.service +``` + +## Server-Code geändert? Dann ist ein Neustart PFLICHT + +Statische Dateien sind nach dem Pull sofort live. Server-Code **nicht** — +der läuft weiter mit dem alten Stand, bis der Dienst neu startet. + +```bash +ssh dogfather-server "sudo systemctl restart dogiweb.service" # nach Änderungen in server/ +ssh dogfather-server "sudo systemctl restart dogiintern.service" # nach Änderungen in server-internal/ +``` + +**Warum das hier besonders steht (Vorfall 22.08.2026):** Nach dem Pull des +Webdesign-Bereichs war `/webdesign/` für einige Minuten **ohne Zugangsschutz +öffentlich erreichbar** (HTTP 200 statt der Umleitung zur Zugangswand). Die +HTML-Dateien waren durch den Pull sofort da — die Schranke in +`server/webdesign-gate.js` lief aber erst nach dem Neustart von +`dogiweb.service`. Bei einem Bereich, der ausdrücklich nicht öffentlich sein +soll, ist genau dieses Zeitfenster der gefährliche Teil eines Deploys. + +Merksatz: **Erst neu starten, dann „ist live" melden** — und danach mit +`curl -I` prüfen, dass die Schranke wirklich greift: + +```bash +curl -sI https://dogfather-universe.com/webdesign/ | grep -iE "^location|x-robots-tag" +# erwartet: location: /webdesign/zugang.html?next=... und x-robots-tag: noindex, ... ``` **Achtung bei Zweig-Namen:** lokal heißt der Zweig `master`, auf dem Server und in Gitea `main`. diff --git a/assets/css/webdesign.css b/assets/css/webdesign.css index b71f2b3c..4c87dc14 100644 --- a/assets/css/webdesign.css +++ b/assets/css/webdesign.css @@ -153,8 +153,14 @@ body.wd::after { .wd .wd-lead { font-size: clamp(1.08rem, 2vw, 1.32rem); color: var(--wd-text); line-height: 1.65; } .wd .wd-klein { font-size: .92rem; } .wd .wd-leise { color: var(--wd-text-soft); } -.wd a { color: var(--wd-blau); text-decoration: none; } -.wd a:hover { color: var(--wd-blau-hell); } +/* :not(.wd-btn) ist hier PFLICHT, kein Feinschliff. + Ohne das gewinnt ".wd a" (Klasse + Element = spezifischer als die reine + Knopf-Klasse ".wd-btn--haupt") und faerbt die Schrift auf dem hellblauen + Hauptknopf ebenfalls hellblau -- blau auf blau, praktisch unlesbar. + Genau so live gemeldet am 22.08.2026 per Bildschirmfoto. */ +.wd a:not(.wd-btn) { color: var(--wd-blau); text-decoration: none; } +.wd a:not(.wd-btn):hover { color: var(--wd-blau-hell); } +.wd a.wd-btn { text-decoration: none; } .wd strong { color: var(--wd-text); font-weight: 700; } .wd-eyebrow { @@ -272,8 +278,14 @@ a.wd-karte:hover, .wd-karte--klickbar:hover { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; /* min-height 48px = Touch-Ziel-Mindestmaß (Masterplan S.15). */ min-height: 48px; - padding: .85rem 1.6rem; - border-radius: 999px; + padding: .85rem 1.7rem; + /* Abgerundetes Rechteck statt Pillenform (999px). + Rueckmeldung 22.08.2026: "das sieht lang gezogen aus". Stimmt — bei + einer Pille waechst der Radius optisch mit der Breite mit, ein breiter + Knopf wirkt dadurch auseinandergezogen wie eine Tablette. Ein fester + Radius haelt die Form bei jeder Breite gleich und wirkt ruhiger und + hochwertiger. */ + border-radius: 14px; border: 1px solid transparent; font-family: var(--font-head); font-weight: 700; @@ -289,7 +301,7 @@ a.wd-karte:hover, .wd-karte--klickbar:hover { .wd-btn:active { transform: translateY(1px) scale(.985); } .wd-btn[disabled], .wd-btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; transform: none; } -.wd-btn--haupt { +.wd .wd-btn--haupt, .wd-btn--haupt { /* Dunkler Text auf hellem Babyblau: 11.7:1 — der einzige Weg, einen leuchtenden Knopf zu bauen, ohne die Lesbarkeit zu opfern. */ background: linear-gradient(135deg, var(--wd-blau-hell), var(--wd-blau) 55%, #63bcd8); @@ -298,14 +310,14 @@ a.wd-karte:hover, .wd-karte--klickbar:hover { } .wd-btn--haupt:hover { box-shadow: 0 16px 44px -14px rgba(127, 208, 232, .95); transform: translateY(-2px); } -.wd-btn--zweit { +.wd .wd-btn--zweit, .wd-btn--zweit { background: rgba(127, 208, 232, .07); border-color: var(--wd-rand-stark); color: var(--wd-blau-hell); } .wd-btn--zweit:hover { background: rgba(127, 208, 232, .14); border-color: var(--wd-blau); } -.wd-btn--still { background: transparent; border-color: var(--wd-rand); color: var(--wd-text-mid); } +.wd .wd-btn--still, .wd-btn--still { background: transparent; border-color: var(--wd-rand); color: var(--wd-text-mid); } .wd-btn--still:hover { border-color: var(--wd-rand-stark); color: var(--wd-text); } .wd-btn--breit { width: 100%; } diff --git a/assets/js/wd-core.js b/assets/js/wd-core.js index 661d5b1f..0396ff30 100644 --- a/assets/js/wd-core.js +++ b/assets/js/wd-core.js @@ -761,10 +761,70 @@ } /* =================================================================== - 8. START + 8. ZUGANG BEIM SCHLIESSEN DER SEITE BEENDEN + + Wunsch vom 22.08.2026: "ich will das ich jedes mal den code gefragt + werde wenn man die seite zu macht." + + Warum das Sitzungs-Cookie allein dafür NICHT reicht: + Ein Cookie ohne Ablaufdatum wirft der Browser beim Schließen zwar weg + — aber "Tabs wiederherstellen" (Chrome: "Dort fortfahren, wo du + aufgehört hast", Android beim erneuten Öffnen, iOS beim Zurückholen + einer App aus dem Speicher) stellt genau diese Cookies wieder her. + Man landet dann ohne Codeabfrage wieder mitten in der Seite. + + Deshalb zusätzlich eine Kennung im sessionStorage. Der ist an die + Browser-Sitzung des Tabs gebunden und ist beim echten Schließen weg. + Fehlt die Kennung, obwohl ein Cookie da ist, war die Seite zu → + Sitzung serverseitig beenden und zurück zur Zugangswand. + + Ehrliche Einschränkung: Wer die Adresse in einem NEUEN Tab von Hand + eingibt, hat dort ebenfalls einen leeren sessionStorage und wird + erneut gefragt. Das ist der Preis dafür, dass es beim Schließen + zuverlässig greift — und entspricht dem, was gewünscht war. + =================================================================== */ + var SITZUNGS_MARKE = "wd-sitzung-aktiv"; + + function sitzungsMarkeSetzen() { + try { sessionStorage.setItem(SITZUNGS_MARKE, "1"); } catch (e) { /* egal */ } + } + + function hatRollenCookie() { + return document.cookie.split(";").some(function (s) { + return s.trim().indexOf("dogiwd_rolle=") === 0; + }); + } + + function zugangPruefen() { + // Auf der Zugangswand selbst gibt es nichts zu beenden. + if (location.pathname.indexOf("/webdesign/zugang.html") === 0) return; + + var marke = null; + try { marke = sessionStorage.getItem(SITZUNGS_MARKE); } catch (e) { return; } + if (marke) return; // laufende Sitzung, alles gut + + /* Kein Rollen-Cookie -> es gab hier noch nie eine Anmeldung in diesem + Browser (oder der Bereich ist freigegeben). Dann NICHT abmelden: + sonst würde eine gerade erst begonnene Sitzung sofort beendet. */ + if (!hatRollenCookie()) { sitzungsMarkeSetzen(); return; } + + /* Cookie da, Marke weg -> die Seite war zwischendurch zu. */ + fetch("/webdesign/zugang-beenden", { method: "POST" }) + .catch(function () { /* auch ohne Antwort weiterleiten */ }) + .then(function () { + location.replace( + "/webdesign/zugang.html?next=" + encodeURIComponent(location.pathname + location.search) + ); + }); + } + + /* =================================================================== + 9. START =================================================================== */ function start() { document.body.classList.add("wd"); + zugangPruefen(); + sitzungsMarkeSetzen(); uebersetzeSeite(); zeichneKopf(); zeichneFuss(); diff --git a/server/test-webdesign-sitzung.mjs b/server/test-webdesign-sitzung.mjs new file mode 100644 index 00000000..bdf485f2 --- /dev/null +++ b/server/test-webdesign-sitzung.mjs @@ -0,0 +1,157 @@ +/* ===================================================================== + test-webdesign-sitzung.mjs — "Beim Schließen der Seite wieder fragen" + + Wunsch 22.08.2026: "ich will das ich jedes mal den code gefragt werde + wenn man die seite zu macht." + + Das laesst sich nur in einem echten Browser pruefen, weil genau der + Punkt der schwierige ist: Browser stellen Sitzungs-Cookies beim + Wiederherstellen von Tabs zurueck. Playwright bildet das nach: + - neue Seite im GLEICHEN Kontext = neuer Tab, Cookie noch da, + sessionStorage leer -> muss erneut fragen + - gleiche Seite weiternavigieren = laufende Sitzung -> darf NICHT fragen + + Voraussetzung: lokaler Testserver auf 4199 laeuft. + ===================================================================== */ +import { chromium } from "playwright"; + +const BASIS = "http://127.0.0.1:4199"; +const CODE = "DOGI-TEST-1"; + +let ok = 0, schlecht = 0; +const pruefe = (name, b, zusatz = "") => { + if (b) { ok++; console.log(" ok " + name); } + else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); } +}; + +const browser = await chromium.launch(); + +console.log("\n=== Zugang bei jedem Schliessen ===\n"); + +/* ---------- 1. Anmelden und normal weiterklicken ---------- */ +console.log("1. Laufende Sitzung"); +const kontext = await browser.newContext(); +const seite = await kontext.newPage(); + +await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" }); +await seite.fill("#zg-code-dogfather", CODE); +await Promise.all([ + seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }), + seite.click("#zg-form-dogfather button[type=submit]"), +]); +pruefe("Anmeldung fuehrt in den Bereich", seite.url().endsWith("/webdesign/"), seite.url()); + +await seite.goto(BASIS + "/webdesign/preise.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(400); +pruefe("Weiterklicken fragt NICHT erneut", !seite.url().includes("zugang.html"), seite.url()); + +await seite.reload({ waitUntil: "networkidle" }); +await seite.waitForTimeout(400); +pruefe("Neuladen fragt NICHT erneut", !seite.url().includes("zugang.html"), seite.url()); + +const markeDa = await seite.evaluate(() => sessionStorage.getItem("wd-sitzung-aktiv")); +pruefe("Sitzungsmarke ist gesetzt", markeDa === "1"); + +/* ---------- 2. Seite zu, Cookie bleibt (Tab-Wiederherstellung) ---------- */ +console.log("\n2. Seite geschlossen, Cookie vom Browser wiederhergestellt"); +{ + const kekse = await kontext.cookies(); + const sitzungsKeks = kekse.find((c) => c.name === "dogiwd_session"); + pruefe("Sitzungs-Cookie hat KEIN Ablaufdatum", + !sitzungsKeks || sitzungsKeks.expires === -1, + "-> der Browser wirft es beim Schliessen selbst weg"); + + /* Neuer Tab im selben Kontext: Cookies bleiben (wie bei + "Tabs wiederherstellen"), sessionStorage ist aber leer. Genau der + Fall, in dem das Cookie allein NICHT reichen wuerde. */ + const neuerTab = await kontext.newPage(); + await neuerTab.goto(BASIS + "/webdesign/preise.html", { waitUntil: "networkidle" }); + await neuerTab.waitForTimeout(1200); + pruefe("neuer Tab verlangt den Code erneut", + neuerTab.url().includes("zugang.html"), + "war " + neuerTab.url()); + + const nachher = await kontext.cookies(); + const nochDa = nachher.find((c) => c.name === "dogiwd_session"); + pruefe("die alte Sitzung wurde serverseitig beendet", !nochDa, + "-> nicht nur weggeklickt, sondern wirklich abgemeldet"); + + await neuerTab.close(); +} + +/* ---------- 3. Komplett neuer Browser ---------- */ +console.log("\n3. Browser komplett neu gestartet"); +{ + const frisch = await browser.newContext(); + const s = await frisch.newPage(); + await s.goto(BASIS + "/webdesign/", { waitUntil: "networkidle" }); + pruefe("frischer Browser landet auf der Zugangswand", s.url().includes("zugang.html"), s.url()); + await frisch.close(); +} + +/* ---------- 4. Keine Endlosschleife ---------- */ +console.log("\n4. Keine Endlosschleife nach dem Anmelden"); +{ + const k = await browser.newContext(); + const s = await k.newPage(); + let umleitungen = 0; + s.on("framenavigated", (f) => { if (f === s.mainFrame()) umleitungen++; }); + + await s.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" }); + await s.fill("#zg-code-dogfather", CODE); + await Promise.all([ + s.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }), + s.click("#zg-form-dogfather button[type=submit]"), + ]); + await s.waitForTimeout(1500); + pruefe("bleibt nach der Anmeldung im Bereich", !s.url().includes("zugang.html"), s.url()); + pruefe("keine auffaellige Umleitungsschleife", umleitungen < 8, umleitungen + " Navigationen"); + await k.close(); +} + +/* ---------- 5. Knopf-Lesbarkeit (der gemeldete Fehler) ---------- */ +console.log("\n5. Lesbarkeit des Hauptknopfs"); +{ + /* Frischer Kontext mit eigener Anmeldung: Schritt 2 hat die Sitzung von + `seite` absichtlich beendet -- dort wuerde jetzt die Zugangswand + stehen und der Knopf gar nicht existieren. */ + const k5 = await browser.newContext(); + const seite5 = await k5.newPage(); + await seite5.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" }); + await seite5.fill("#zg-code-dogfather", CODE); + await Promise.all([ + seite5.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }), + seite5.click("#zg-form-dogfather button[type=submit]"), + ]); + await seite5.waitForLoadState("networkidle"); + const skip = seite5.locator("#wd-intro-skip"); + if (await skip.isVisible().catch(() => false)) await skip.click(); + await seite5.waitForTimeout(300); + + const befund = await seite5.evaluate(() => { + const knopf = document.querySelector(".wd-hero .wd-btn--haupt"); + if (!knopf) return null; + const s = getComputedStyle(knopf); + const zahl = (f) => (f.match(/[\d.]+/g) || []).slice(0, 3).map(Number); + return { farbe: zahl(s.color), radius: s.borderRadius, text: knopf.textContent.trim() }; + }); + + pruefe("Hauptknopf gefunden", !!befund); + if (befund) { + /* Relative Helligkeit nach WCAG. Der Knopfgrund ist hellblau (~0.55) -- + eine helle Schrift darauf waere der gemeldete Fehler. */ + const [r, g, b] = befund.farbe; + const lin = (v) => { const x = v / 255; return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); }; + const hell = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + pruefe("Schrift auf dem Hauptknopf ist DUNKEL", hell < 0.1, + `Helligkeit ${hell.toFixed(3)}, Farbe rgb(${befund.farbe.join(",")})`); + pruefe("Knopf ist kein Pillen-Oval mehr", + !/999/.test(befund.radius) && parseInt(befund.radius, 10) <= 20, + befund.radius); + } + await k5.close(); +} + +await browser.close(); +console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`); +process.exit(schlecht === 0 ? 0 : 1); diff --git a/server/webdesign-gate.js b/server/webdesign-gate.js index 42c8ff15..0542bdaa 100644 --- a/server/webdesign-gate.js +++ b/server/webdesign-gate.js @@ -31,13 +31,22 @@ import { createHmac, timingSafeEqual as cryptoTimingSafeEqual, randomUUID } from export const WD_COOKIE = "dogiwd_session"; export const WD_ROLLE_COOKIE = "dogiwd_rolle"; -/* Sitzungsdauer. Bewusst länger als die 12 Stunden im Universe: hier wird - inhaltlich gearbeitet (Anfragen prüfen, Texte pflegen), und ein - Rauswurf mitten in einer halb ausgefüllten Verwaltungsmaske wäre - teurer als das kleine zusätzliche Risiko. Das Cookie selbst bleibt ein - reines Sitzungs-Cookie ohne maxAge — beim Schließen des Browsers ist - der Zugang trotzdem weg. */ -export const WD_SESSION_STUNDEN = 24; +/* Sitzungsdauer als NOTBREMSE, nicht als eigentlicher Ablauf. + Der Zugang soll bei jedem Schließen der Seite neu abgefragt werden + (Wunsch 22.08.2026: "ich will das ich jedes mal den code gefragt werde + wenn man die seite zu macht"). Dafür sorgen zwei Dinge zusammen: + + 1. Das Cookie ist ein reines Sitzungs-Cookie (kein maxAge/expires) — + der Browser wirft es beim Schließen selbst weg. + 2. Eine Sitzungskennung im Browser-Sitzungsspeicher, die beim + Schließen ebenfalls verschwindet (siehe wd-core.js). Nötig, weil + viele Browser mit "Tabs wiederherstellen" auch Sitzungs-Cookies + zurückholen und Punkt 1 allein dann NICHT greift. + + Diese Stundenzahl ist nur der Fall, dass ein Browser tagelang offen + bleibt. Von 24 auf 8 Stunden gesenkt: bleibt ein Gerät unbeaufsichtigt + offen liegen, soll der Zugang spätestens nach einem Arbeitstag zu sein. */ +export const WD_SESSION_STUNDEN = 8; const WD_PRAEFIX = "/webdesign"; diff --git a/webdesign/zugang.html b/webdesign/zugang.html index e04e8169..8a28a84b 100644 --- a/webdesign/zugang.html +++ b/webdesign/zugang.html @@ -251,6 +251,12 @@ return; } + /* Sitzungsmarke setzen, BEVOR weitergeleitet wird. wd-core.js + prüft sie auf der Zielseite und würde sonst annehmen, die Seite + sei zwischendurch geschlossen worden — und einen direkt wieder + hierher zurückwerfen. Endlosschleife. */ + try { sessionStorage.setItem("wd-sitzung-aktiv", "1"); } catch (e) { /* egal */ } + /* Erfolg: Knopf bewusst gesperrt lassen — die Seite wechselt gleich, ein weiterer Tap wäre nur verwirrend. */ knopf.textContent = window.WD.t("zg_willkommen");