Fix des Fehlers des Foldings der Planner Vorschau

This commit is contained in:
Michael Stangl
2026-08-26 17:42:55 +02:00
parent 3154b42687
commit ac4f0f6744
10 changed files with 233 additions and 38 deletions
+2
View File
@@ -2,3 +2,5 @@ public/
resources/ resources/
.hugo_build.lock .hugo_build.lock
.env .env
.deploy_manifest.json
__pycache__/
+52 -16
View File
@@ -62,6 +62,24 @@ Link an den Kunden geht.
## 6. Zugriff beschränken: nur du + der Kunde ## 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 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 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 – 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. diesen Key kennt, kann selbst gültige Links erzeugen.
Ab jetzt liefert die Pull Zone **nichts mehr aus**, ohne gültigen Token in der 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 + Ein signierter Link besteht aus einem Hash über `Authentication Key + Pfad +
Ablaufzeitpunkt`. Das lässt sich einfach per kleinem Python-Snippet erzeugen, Ablaufzeitpunkt`. Da die Basic-Methode keine Verzeichnis-Tokens kennt, braucht
z. B. lokal ausführen, wenn ein neuer Vorschau-Link für den Kunden gebraucht jede Seite ihren eigenen Link. Lokal ausführen, wenn neue Vorschau-Links für
wird: den Kunden gebraucht werden:
```python ```python
import hashlib import hashlib
@@ -92,28 +112,44 @@ import base64
import time import time
security_key = "DEIN_AUTHENTICATION_KEY" 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 expires = int(time.time()) + 7 * 24 * 60 * 60 # 7 Tage gültig
hashable = f"{security_key}{url_path}{expires}" # Alle Pfade eintragen, die der Kunde ansehen soll:
token_hash = hashlib.sha256(hashable.encode()).digest() url_paths = [
token = base64.urlsafe_b64encode(token_hash).decode().rstrip("=") "/",
"/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 Die ausgegebenen Links schickst du dem Kunden (z. B. als Liste). Läuft die
ein neues Snippet mit neuem `expires`-Wert ausführen und den frischen Link Gültigkeit ab, einfach das Snippet mit neuem `expires`-Wert erneut ausführen.
verschicken.
### 6.3 Praktisch für Vorschau-Zwecke ### 6.3 Praktisch für Vorschau-Zwecke
- Länge der Gültigkeit (`expires`) nach Bedarf wählen – z. B. 7 Tage für eine - 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. kurze Abstimmungsrunde, oder wenige Stunden für einen einzelnen Termin.
- Ohne gültigen Token bekommt jeder andere Besucher einen Fehler von Bunny – - Ohne gültigen Token bekommt jeder andere Besucher einen Fehler von Bunny –
die Seite ist damit faktisch nur über den geteilten Link erreichbar. die Seite ist damit faktisch nur über die geteilten Links erreichbar.
- Sobald die Seite live gehen soll (echte Domain, öffentlich), wird Token - Da pro Seite ein eigener Token nötig ist, taugt dieser Weg nur für wenige,
Authentication in der Pull Zone einfach wieder deaktiviert. 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) ## 7. Domain-Anbindung (später, für den echten Umzug)
+1 -1
View File
@@ -29,6 +29,7 @@ umfeld:
- "Sonnenschirm" - "Sonnenschirm"
- titel: "Entfernungen" - titel: "Entfernungen"
punkte: punkte:
- "PKW-Stellplatz: 10 m"
- "Arzt: 200 m" - "Arzt: 200 m"
- "Bushaltestelle: 250 m" - "Bushaltestelle: 250 m"
- "Supermarkt: 0,8 km" - "Supermarkt: 0,8 km"
@@ -44,7 +45,6 @@ umfeld:
punkte: punkte:
- "Tischtennis" - "Tischtennis"
- "Spielplatz" - "Spielplatz"
- "PKW-Stellplatz"
gemeinsam: gemeinsam:
- titel: "Wohnen und Leben" - titel: "Wohnen und Leben"
punkte: punkte:
+4 -4
View File
@@ -7,15 +7,15 @@ description: "Unsere Preise und Buchungsbedingungen"
| Wohnung | Größe | Personen | Preis/Nacht | | Wohnung | Größe | Personen | Preis/Nacht |
|---|---|---|---| |---|---|---|---|
| Ferienwohnung 1 | 45 qm | bis 4 Pers. | ab 60 € | | [Ferienwohnung 1](/ferienwohnungen/fewo1/) | 45 qm | bis 4 Pers. | ab 60 € |
| Ferienwohnung 2 | 40 qm | bis 3 Pers. | ab 55 € | | [Ferienwohnung 2](/ferienwohnungen/fewo2/) | 40 qm | bis 3 Pers. | ab 55 € |
| Ferienwohnung 3 | 56 qm | bis 5 Pers. | ab 100 € | | [Ferienwohnung 3](/ferienwohnungen/fewo3/) | 56 qm | bis 5 Pers. | ab 100 € |
Jede weitere Person: **15 € / Nacht** · Kinder unter 12 Jahren: **12 € / Nacht** Jede weitere Person: **15 € / Nacht** · Kinder unter 12 Jahren: **12 € / Nacht**
## Im Preis enthalten ## 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 ## Buchungsbedingungen
+10
View File
@@ -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
+150
View File
@@ -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.")
+2 -2
View File
@@ -17,14 +17,14 @@
<img src="{{ . }}" alt="{{ $.Title }}" class="fewo-card-img"> <img src="{{ . }}" alt="{{ $.Title }}" class="fewo-card-img">
{{ end }} {{ end }}
<div class="fewo-card-body"> <div class="fewo-card-body">
<h2><a href="{{ .Permalink }}">{{ .Title }}</a></h2> <h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
<p class="fewo-meta"> <p class="fewo-meta">
{{ with .Params.groesse }}<span>{{ . }}</span>{{ end }} {{ with .Params.groesse }}<span>{{ . }}</span>{{ end }}
{{ with .Params.personen }}<span>bis {{ . }} Personen</span>{{ end }} {{ with .Params.personen }}<span>bis {{ . }} Personen</span>{{ end }}
{{ with .Params.preis }}<span>ab {{ . }} € / Nacht</span>{{ end }} {{ with .Params.preis }}<span>ab {{ . }} € / Nacht</span>{{ end }}
</p> </p>
{{ with .Params.teaser }}<p>{{ . }}</p>{{ end }} {{ with .Params.teaser }}<p>{{ . }}</p>{{ end }}
<a href="{{ .Permalink }}" class="btn btn-primary">Mehr erfahren</a> <a href="{{ .RelPermalink }}" class="btn btn-primary">Mehr erfahren</a>
</div> </div>
</div> </div>
{{ end }} {{ end }}
+11 -11
View File
@@ -12,7 +12,7 @@
<div class="welcome-text">{{ .Content }}</div> <div class="welcome-text">{{ .Content }}</div>
<p class="welcome-cta"> <p class="welcome-cta">
<a href="/ferienwohnungen/" class="btn btn-primary">Unsere Wohnungen</a> <a href="/ferienwohnungen/" class="btn btn-primary">Unsere Wohnungen</a>
<a href="/preise/" class="btn btn-outline">Preise &amp; Buchung</a> <a href="/preise/" class="btn btn-outline">Preise</a>
</p> </p>
</div> </div>
</section> </section>
@@ -51,23 +51,23 @@
<h2>Unsere Wohnungen</h2> <h2>Unsere Wohnungen</h2>
<div class="fewo-grid"> <div class="fewo-grid">
{{ $pages := where .Site.RegularPages "Section" "ferienwohnungen" }} {{ $pages := where .Site.RegularPages "Section" "ferienwohnungen" }}
{{ range $pages }} {{ range $page := $pages }}
<div class="fewo-card"> <div class="fewo-card">
{{ $vorschau := .Params.vorschau }} {{ $vorschau := $page.Params.vorschau }}
{{ if and (not $vorschau) .Params.bilder }}{{ $vorschau = index .Params.bilder 0 }}{{ end }} {{ if and (not $vorschau) $page.Params.bilder }}{{ $vorschau = index $page.Params.bilder 0 }}{{ end }}
{{ with $vorschau }} {{ with $vorschau }}
<a href="{{ $.Permalink }}" class="fewo-card-imglink"> <a href="{{ $page.RelPermalink }}" class="fewo-card-imglink">
<img src="{{ . }}" alt="{{ $.Title }}" class="fewo-card-img" loading="lazy"> <img src="{{ . }}" alt="{{ $page.Title }}" class="fewo-card-img" loading="lazy">
</a> </a>
{{ end }} {{ end }}
<div class="fewo-card-body"> <div class="fewo-card-body">
<h3><a href="{{ .Permalink }}">{{ .Title }}</a></h3> <h3><a href="{{ $page.RelPermalink }}">{{ $page.Title }}</a></h3>
<p class="fewo-meta"> <p class="fewo-meta">
{{ with .Params.groesse }}<span>{{ . }}</span>{{ end }} {{ with $page.Params.groesse }}<span>{{ . }}</span>{{ end }}
{{ with .Params.personen }}<span>bis {{ . }} Personen</span>{{ end }} {{ with $page.Params.personen }}<span>bis {{ . }} Personen</span>{{ end }}
{{ with .Params.preis }}<span>ab {{ . }} € / Nacht</span>{{ end }} {{ with $page.Params.preis }}<span>ab {{ . }} € / Nacht</span>{{ end }}
</p> </p>
<a href="{{ .Permalink }}" class="btn">Details &amp; Buchung</a> <a href="{{ $page.RelPermalink }}" class="btn btn-primary">Details &amp; Buchung</a>
</div> </div>
</div> </div>
{{ end }} {{ end }}
-3
View File
@@ -317,9 +317,6 @@ img { max-width: 100%; height: auto; display: block; }
gap: 1.25rem; gap: 1.25rem;
} }
@media (max-width: 760px) { @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-grid { grid-template-columns: 1fr; }
} }
.spec-block { .spec-block {
+1 -1
View File
@@ -36,11 +36,11 @@ if (belBtn && belFrame) {
const willShow = belFrame.hidden; const willShow = belFrame.hidden;
if (willShow && !loaded) { if (willShow && !loaded) {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = belFrame.dataset.src;
iframe.width = '100%'; iframe.width = '100%';
iframe.height = '225'; iframe.height = '225';
iframe.frameBorder = '0'; iframe.frameBorder = '0';
belFrame.appendChild(iframe); belFrame.appendChild(iframe);
iframe.src = belFrame.dataset.src;
loaded = true; loaded = true;
} }
belFrame.hidden = !willShow; belFrame.hidden = !willShow;