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");