diff --git a/.gitignore b/.gitignore index fe504e0..3e2c5e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ public/ resources/ .hugo_build.lock .env +.deploy_manifest.json +__pycache__/ diff --git a/Umzug.md b/Umzug.md index c890cd1..9dd3aba 100644 --- a/Umzug.md +++ b/Umzug.md @@ -62,6 +62,24 @@ Link an den Kunden geht. ## 6. Zugriff beschränken: nur du + der Kunde +> **Wichtige Einschränkung (Stand: getestet):** Das Bunny-Dashboard zeigt in der +> Security-Seite der Pull Zone nur die **Basic** Token Authentication (Felder: +> "Token authentication", "Token IP validation", "Url token authentication Key"). +> Diese Basic-Methode signiert immer einen **exakten Pfad** – es gibt dabei +> **kein** Path-Prefix-/Wildcard-/Verzeichnis-Token. Ein Token für `/` ist damit +> nur für `/` gültig, nicht für `/ferienwohnungen/` oder andere Unterseiten +> (führt zu 403 auf allen anderen Seiten). Verzeichnis-weite Tokens gibt es bei +> Bunny nur über die separate "Advanced" Token-Methode (HS256, i. d. R. anderer +> Tarif/Reiter) – die in diesem Projekt bisher nicht verfügbar/eingerichtet ist. +> +> **Für die aktuelle Vorschau-Phase deshalb:** Token Authentication ist +> deaktiviert. Die `b-cdn.net`-Test-URL ist damit frei erreichbar (kein +> Passwortschutz, aber auch nicht in Suchmaschinen o. ä. auffindbar/verlinkt) – +> ausreichend für eine kurze, informelle Abstimmung mit dem Kunden. Die +> folgenden Abschnitte 6.1–6.3 bleiben als Referenz stehen, falls später doch +> ein Advanced-Token-Setup eingerichtet wird oder Bunny die Basic-Methode um +> Prefix-Matching erweitert. + Bunny.net Pull Zones bieten dafür **Token Authentication**: Requests an die Pull Zone werden nur ausgeliefert, wenn die URL einen gültigen, signierten Token enthält. Es gibt keinen klassischen Basic-Auth-Login-Dialog im Browser – @@ -77,14 +95,16 @@ festgelegten Ablaufzeitpunkt gültig ist. diesen Key kennt, kann selbst gültige Links erzeugen. Ab jetzt liefert die Pull Zone **nichts mehr aus**, ohne gültigen Token in der -URL – das ist der eigentliche Zugriffsschutz. +URL – das ist der eigentliche Zugriffsschutz. Mit der Basic-Methode gilt das +aber **pro exaktem Pfad** (siehe Hinweis oben) – für mehrere Seiten braucht es +entsprechend mehrere Links, siehe 6.2. -### 6.2 Signierten Link erzeugen +### 6.2 Signierten Link je Seite erzeugen Ein signierter Link besteht aus einem Hash über `Authentication Key + Pfad + -Ablaufzeitpunkt`. Das lässt sich einfach per kleinem Python-Snippet erzeugen, -z. B. lokal ausführen, wenn ein neuer Vorschau-Link für den Kunden gebraucht -wird: +Ablaufzeitpunkt`. Da die Basic-Methode keine Verzeichnis-Tokens kennt, braucht +jede Seite ihren eigenen Link. Lokal ausführen, wenn neue Vorschau-Links für +den Kunden gebraucht werden: ```python import hashlib @@ -92,28 +112,44 @@ import base64 import time security_key = "DEIN_AUTHENTICATION_KEY" -url_path = "/index.html" # z.B. Startseite, oder "/" je nach Setup expires = int(time.time()) + 7 * 24 * 60 * 60 # 7 Tage gültig -hashable = f"{security_key}{url_path}{expires}" -token_hash = hashlib.sha256(hashable.encode()).digest() -token = base64.urlsafe_b64encode(token_hash).decode().rstrip("=") +# Alle Pfade eintragen, die der Kunde ansehen soll: +url_paths = [ + "/", + "/ferienwohnungen/", + "/ferienwohnungen/fewo1/", + "/ferienwohnungen/fewo2/", + "/ferienwohnungen/fewo3/", + "/preise/", + "/ausflugsziele/", + "/kontakt/", + "/impressum/", + "/datenschutz/", +] -print(f"https://iwz-ferienwohnungen.b-cdn.net{url_path}?token={token}&expires={expires}") +for url_path in url_paths: + hashable = f"{security_key}{url_path}{expires}" + token_hash = hashlib.sha256(hashable.encode()).digest() + token = base64.urlsafe_b64encode(token_hash).decode().rstrip("=") + print(f"https://iwz-ferienwohnungen.b-cdn.net{url_path}?token={token}&expires={expires}") ``` -Den ausgegebenen Link schickst du dem Kunden. Läuft die Gültigkeit ab, einfach -ein neues Snippet mit neuem `expires`-Wert ausführen und den frischen Link -verschicken. +Die ausgegebenen Links schickst du dem Kunden (z. B. als Liste). Läuft die +Gültigkeit ab, einfach das Snippet mit neuem `expires`-Wert erneut ausführen. ### 6.3 Praktisch für Vorschau-Zwecke - Länge der Gültigkeit (`expires`) nach Bedarf wählen – z. B. 7 Tage für eine kurze Abstimmungsrunde, oder wenige Stunden für einen einzelnen Termin. - Ohne gültigen Token bekommt jeder andere Besucher einen Fehler von Bunny – - die Seite ist damit faktisch nur über den geteilten Link erreichbar. -- Sobald die Seite live gehen soll (echte Domain, öffentlich), wird Token - Authentication in der Pull Zone einfach wieder deaktiviert. + die Seite ist damit faktisch nur über die geteilten Links erreichbar. +- Da pro Seite ein eigener Token nötig ist, taugt dieser Weg nur für wenige, + fest bekannte Seiten – nicht für eine frei navigierbare Vorschau. Für eine + frei klickbare Vorschau (aktueller Stand) bleibt Token Authentication + vorerst deaktiviert (siehe Hinweis oben). +- Sobald die Seite live gehen soll (echte Domain, öffentlich), bleibt Token + Authentication in der Pull Zone ohnehin deaktiviert. ## 7. Domain-Anbindung (später, für den echten Umzug) diff --git a/content/ferienwohnungen/_index.md b/content/ferienwohnungen/_index.md index 5e28778..05af2e2 100644 --- a/content/ferienwohnungen/_index.md +++ b/content/ferienwohnungen/_index.md @@ -29,6 +29,7 @@ umfeld: - "Sonnenschirm" - titel: "Entfernungen" punkte: + - "PKW-Stellplatz: 10 m" - "Arzt: 200 m" - "Bushaltestelle: 250 m" - "Supermarkt: 0,8 km" @@ -44,7 +45,6 @@ umfeld: punkte: - "Tischtennis" - "Spielplatz" - - "PKW-Stellplatz" gemeinsam: - titel: "Wohnen und Leben" punkte: diff --git a/content/preise.md b/content/preise.md index 4a7ec7f..d7c1345 100644 --- a/content/preise.md +++ b/content/preise.md @@ -7,15 +7,15 @@ description: "Unsere Preise und Buchungsbedingungen" | Wohnung | Größe | Personen | Preis/Nacht | |---|---|---|---| -| Ferienwohnung 1 | 45 qm | bis 4 Pers. | ab 60 € | -| Ferienwohnung 2 | 40 qm | bis 3 Pers. | ab 55 € | -| Ferienwohnung 3 | 56 qm | bis 5 Pers. | ab 100 € | +| [Ferienwohnung 1](/ferienwohnungen/fewo1/) | 45 qm | bis 4 Pers. | ab 60 € | +| [Ferienwohnung 2](/ferienwohnungen/fewo2/) | 40 qm | bis 3 Pers. | ab 55 € | +| [Ferienwohnung 3](/ferienwohnungen/fewo3/) | 56 qm | bis 5 Pers. | ab 100 € | Jede weitere Person: **15 € / Nacht** · Kinder unter 12 Jahren: **12 € / Nacht** ## Im Preis enthalten -Endreinigung, Bettwäsche und Handtücher sind im Preis inbegriffen. WLAN ist kostenlos. +**Endreinigung, Bettwäsche** und **Handtücher** sind _im Preis inbegriffen_. WLAN ist _kostenlos_. ## Buchungsbedingungen diff --git a/deploy-lastdiff.bat b/deploy-lastdiff.bat new file mode 100644 index 0000000..b9282a2 --- /dev/null +++ b/deploy-lastdiff.bat @@ -0,0 +1,10 @@ +@echo off +echo. +echo === IWZ Ferienwohnungen - Deploy (nur Änderungen) === +echo. +python deploy_lastdiff.py +if %errorlevel% neq 0 ( + pause + exit /b 1 +) +pause diff --git a/deploy_lastdiff.py b/deploy_lastdiff.py new file mode 100644 index 0000000..643deb3 --- /dev/null +++ b/deploy_lastdiff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +IWZ Ferienwohnungen - Deploy nur geaenderter Dateien +Baut die Hugo-Seite und laedt per FTP zur Bunny.net Storage Zone nur die +Dateien hoch, die sich seit dem letzten Deploy mit diesem Skript geaendert +haben (Vergleich per Datei-Hash gegen ein lokales Manifest). +""" + +import ftplib +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent +PUBLIC_DIR = PROJECT_ROOT / "public" +MANIFEST_PATH = PROJECT_ROOT / ".deploy_manifest.json" +FTP_PORT = 21 + + +def load_env(path): + env = {} + if not path.exists(): + return env + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + return env + + +def hugo_build(): + print("\n-> Hugo Build...") + result = subprocess.run("hugo --gc --minify", shell=True) + if result.returncode != 0: + print("\nFEHLER: Hugo Build fehlgeschlagen!") + sys.exit(1) + + +def hash_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def scan_public(): + manifest = {} + for entry in PUBLIC_DIR.rglob("*"): + if entry.is_file(): + rel = entry.relative_to(PUBLIC_DIR).as_posix() + manifest[rel] = hash_file(entry) + return manifest + + +def load_manifest(): + if not MANIFEST_PATH.exists(): + return {} + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def save_manifest(manifest): + MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + + +def diff_manifest(old, new): + changed = [rel for rel, digest in new.items() if old.get(rel) != digest] + deleted = [rel for rel in old if rel not in new] + return sorted(changed), sorted(deleted) + + +def ftp_mkdirs(ftp, remote_dir): + parts = remote_dir.split("/") + path = "" + for part in parts: + if not part: + continue + path = f"{path}/{part}" if path else part + try: + ftp.mkd(path) + except ftplib.error_perm: + pass # existiert bereits + + +def ftp_upload_files(ftp, rel_paths): + for rel in rel_paths: + local_path = PUBLIC_DIR / rel + remote_dir = "/".join(rel.split("/")[:-1]) + if remote_dir: + ftp_mkdirs(ftp, remote_dir) + with open(local_path, "rb") as f: + ftp.storbinary(f"STOR {rel}", f) + print(f" {rel}") + + +def ftp_delete_files(ftp, rel_paths): + for rel in rel_paths: + try: + ftp.delete(rel) + print(f" gelöscht: {rel}") + except ftplib.error_perm as e: + print(f" konnte nicht löschen: {rel} ({e})") + + +def deploy_to_bunny(host, zone, password, changed, deleted): + print(f"\n-> Deploy zu Bunny.net Storage Zone '{zone}'...") + with ftplib.FTP() as ftp: + ftp.connect(host, FTP_PORT) + ftp.login(zone, password) + ftp.set_pasv(True) + if changed: + print(f"\n-> Lade {len(changed)} geänderte Datei(en) hoch:") + ftp_upload_files(ftp, changed) + if deleted: + print(f"\n-> Entferne {len(deleted)} gelöschte Datei(en):") + ftp_delete_files(ftp, deleted) + + +if __name__ == "__main__": + print("=== IWZ Deploy (nur Änderungen) ===") + + env = {**load_env(PROJECT_ROOT / ".env"), **os.environ} + zone = env.get("BUNNY_STORAGE_ZONE") + host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com") + password = env.get("BUNNY_STORAGE_PASSWORD") + + if not zone or not password: + print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.") + print("Siehe .env.example -> .env kopieren und befuellen.") + sys.exit(1) + + hugo_build() + + old_manifest = load_manifest() + new_manifest = scan_public() + changed, deleted = diff_manifest(old_manifest, new_manifest) + + if not changed and not deleted: + print("\nKeine Änderungen seit dem letzten Deploy. Nichts hochzuladen.") + sys.exit(0) + + deploy_to_bunny(host, zone, password, changed, deleted) + + save_manifest(new_manifest) + print("\nFertig! Änderungen sind live.") diff --git a/layouts/_default/list.html b/layouts/_default/list.html index 84cdcef..5a6f53f 100644 --- a/layouts/_default/list.html +++ b/layouts/_default/list.html @@ -17,14 +17,14 @@ {{ $.Title }} {{ end }}
-

{{ .Title }}

+

{{ .Title }}

{{ with .Params.groesse }}{{ . }}{{ end }} {{ with .Params.personen }}bis {{ . }} Personen{{ end }} {{ with .Params.preis }}ab {{ . }} € / Nacht{{ end }}

{{ with .Params.teaser }}

{{ . }}

{{ end }} - Mehr erfahren + Mehr erfahren
{{ end }} diff --git a/layouts/index.html b/layouts/index.html index 95a7028..f5eb269 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -12,7 +12,7 @@
{{ .Content }}

Unsere Wohnungen - Preise & Buchung + Preise

@@ -51,23 +51,23 @@

Unsere Wohnungen

{{ $pages := where .Site.RegularPages "Section" "ferienwohnungen" }} - {{ range $pages }} + {{ range $page := $pages }}
- {{ $vorschau := .Params.vorschau }} - {{ if and (not $vorschau) .Params.bilder }}{{ $vorschau = index .Params.bilder 0 }}{{ end }} + {{ $vorschau := $page.Params.vorschau }} + {{ if and (not $vorschau) $page.Params.bilder }}{{ $vorschau = index $page.Params.bilder 0 }}{{ end }} {{ with $vorschau }} - - {{ $.Title }} + + {{ $page.Title }} {{ end }}
-

{{ .Title }}

+

{{ $page.Title }}

- {{ with .Params.groesse }}{{ . }}{{ end }} - {{ with .Params.personen }}bis {{ . }} Personen{{ end }} - {{ with .Params.preis }}ab {{ . }} € / Nacht{{ end }} + {{ with $page.Params.groesse }}{{ . }}{{ end }} + {{ with $page.Params.personen }}bis {{ . }} Personen{{ end }} + {{ with $page.Params.preis }}ab {{ . }} € / Nacht{{ end }}

- Details & Buchung + Details & Buchung
{{ end }} diff --git a/static/css/style.css b/static/css/style.css index aa36839..75ce65d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -317,9 +317,6 @@ img { max-width: 100%; height: auto; display: block; } gap: 1.25rem; } @media (max-width: 760px) { - .spec-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} -@media (max-width: 480px) { .spec-grid { grid-template-columns: 1fr; } } .spec-block { diff --git a/static/js/main.js b/static/js/main.js index 1dfaf83..2ea1d19 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -36,11 +36,11 @@ if (belBtn && belFrame) { const willShow = belFrame.hidden; if (willShow && !loaded) { const iframe = document.createElement('iframe'); - iframe.src = belFrame.dataset.src; iframe.width = '100%'; iframe.height = '225'; iframe.frameBorder = '0'; belFrame.appendChild(iframe); + iframe.src = belFrame.dataset.src; loaded = true; } belFrame.hidden = !willShow;