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 @@
{{ end }}
{{ . }}
{{ end }} - Mehr erfahren + Mehr erfahrenUnsere Wohnungen - Preise & Buchung + Preise
@@ -51,23 +51,23 @@