Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2943021fe9 | ||
|
|
12675f381a |
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
||||
|
||||
> **Neu einrichten?** Alle Secrets, die Microsoft-Anmeldung, den Mailversand über Microsoft 365, Stripe und das Ausrollen beschreibt Schritt für Schritt **[docs/EINRICHTUNG.md](docs/EINRICHTUNG.md)**.
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
| Bereich | Technologie |
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# Wabenhain einrichten — von null bis live
|
||||
|
||||
Diese Anleitung führt durch alles, was außerhalb des Codes eingerichtet werden muss, damit der Shop in Kubernetes vollständig läuft: Geheimnisse (Secrets), Microsoft-Anmeldung, Mailversand über Microsoft 365, Stripe und die optionalen Dienste. Sie setzt nichts voraus, was nicht hier steht.
|
||||
|
||||
**Zeitbedarf:** etwa 2 Stunden für Pflicht und Mailversand, plus Wartezeiten bei Microsoft (bis zu 30 Minuten) und Stripe.
|
||||
|
||||
**Reihenfolge:**
|
||||
|
||||
1. [Werkzeuge und Zugänge bereitlegen](#1-werkzeuge-und-zugänge)
|
||||
2. [Grundwerte erzeugen](#2-grundwerte-erzeugen) (Datenbank, Sitzungen, Admin)
|
||||
3. [Microsoft Entra ID: App-Registrierung](#3-microsoft-entra-id-app-registrierung) (Anmeldung **und** Mailversand)
|
||||
4. [Microsoft 365: Absender-Postfach freischalten](#4-microsoft-365-absender-postfach-freischalten)
|
||||
5. [Stripe](#5-stripe)
|
||||
6. [Optionale Dienste](#6-optionale-dienste)
|
||||
7. [Secret und ConfigMap ausfüllen](#7-secret-und-configmap-ausfüllen)
|
||||
8. [Ausrollen](#8-ausrollen)
|
||||
9. [Prüfen](#9-prüfen)
|
||||
10. [Wartung: was abläuft und was zu tun ist](#10-wartung)
|
||||
11. [Fehlerbehebung](#11-fehlerbehebung)
|
||||
|
||||
> **Sicherheitsregel für das ganze Dokument:** Echte Werte gehören ausschließlich in `k8s/02-secret.yaml` (per `.gitignore` ausgeschlossen) bzw. direkt ins Cluster. Nie committen, nie in Chats, Tickets oder Screenshots kopieren. Die ConfigMap (`k8s/01-configmap.yaml`) ist unverschlüsselt und im Cluster für jeden mit Lesezugriff im Klartext sichtbar — dort steht nichts Geheimes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Werkzeuge und Zugänge
|
||||
|
||||
**Auf deinem Rechner:**
|
||||
|
||||
| Werkzeug | Wofür | Prüfen mit |
|
||||
|---|---|---|
|
||||
| `kubectl` mit Zugriff auf das Cluster | Secrets und Manifeste anwenden | `kubectl get nodes` |
|
||||
| `openssl` | Zufallswerte erzeugen | `openssl version` |
|
||||
| Node.js | Datenbank-Passwort URL-kodieren | `node --version` |
|
||||
| PowerShell mit Modul `ExchangeOnlineManagement` | Postfach für den Mailversand freischalten | `Get-Module -ListAvailable ExchangeOnlineManagement` |
|
||||
|
||||
Modul einmalig installieren (PowerShell):
|
||||
|
||||
```powershell
|
||||
Install-Module ExchangeOnlineManagement -Scope CurrentUser
|
||||
```
|
||||
|
||||
**Zugänge, die du brauchst:**
|
||||
|
||||
| Zugang | Rolle | Wofür |
|
||||
|---|---|---|
|
||||
| Microsoft Entra Admin Center (entra.microsoft.com) | Anwendungsadministrator oder Globaler Administrator | App-Registrierung, Administratorzustimmung |
|
||||
| Microsoft 365 Admin Center (admin.microsoft.com) | Exchange-Administrator | Freigegebenes Postfach anlegen und freischalten |
|
||||
| Stripe-Dashboard (dashboard.stripe.com) | Administrator | API-Schlüssel, Webhook |
|
||||
| Gitea (`git.encry.de`) | Schreibrecht am Repository | Image-Build (siehe `k8s/README.md`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Grundwerte erzeugen
|
||||
|
||||
Diese Werte denkst du dir nicht aus, sondern lässt sie erzeugen. Notiere sie vorübergehend in einem Passwort-Manager, nicht in einer Textdatei.
|
||||
|
||||
### Datenbank-Passwort (`DB_PASSWORD` und `DATABASE_URL`)
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32 | tr -d '=+/' | head -c 32
|
||||
```
|
||||
|
||||
Das Passwort steht an **zwei** Stellen im Secret:
|
||||
|
||||
- `DB_PASSWORD`: **roh**, genau wie erzeugt.
|
||||
- `DATABASE_URL`: **URL-kodiert**. Sonderzeichen würden sonst als Trennzeichen der URL gelesen.
|
||||
```bash
|
||||
node -e "console.log(encodeURIComponent('HIER-DAS-PASSWORT'))"
|
||||
```
|
||||
Ergebnis einsetzen:
|
||||
`postgresql://wabenhain:<KODIERTES-PASSWORT>@wabenhain-postgres:5432/wabenhain?schema=public`
|
||||
|
||||
Bei MySQL statt Postgres: `mysql://wabenhain:<KODIERTES-PASSWORT>@wabenhain-mysql:3306/wabenhain` und in der ConfigMap `DATABASE_PROVIDER: "mysql"`.
|
||||
|
||||
### Sitzungsschlüssel (`SESSION_SECRET`)
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Signiert alle Login-Cookies. Wird er bekannt, lassen sich Sitzungen fälschen — auch die eines Admins. Ihn zu ändern meldet alle nur ab.
|
||||
|
||||
### Erstes Admin-Konto (`ADMIN_EMAIL`, `ADMIN_PASSWORD`)
|
||||
|
||||
- `ADMIN_EMAIL`: eine **echte, nicht erratbare** Adresse, z. B. `n.rosenloecher-admin@wabenhain.com` — **nicht** `admin@…` oder `info@…`. Sonst könnte jemand diese Adresse vorher als Kundenkonto registrieren. An diese Adresse gehen außerdem die Benachrichtigungen über eingegangene Widerrufe.
|
||||
- `ADMIN_PASSWORD`:
|
||||
```bash
|
||||
openssl rand -base64 24 | tr -d '=+/' | head -c 28
|
||||
```
|
||||
|
||||
Das Konto wird beim ersten Start angelegt. Beim ersten Login verlangt der Admin-Bereich die Einrichtung der Zwei-Faktor-Authentifizierung (Authenticator-App bereithalten).
|
||||
|
||||
---
|
||||
|
||||
## 3. Microsoft Entra ID: App-Registrierung
|
||||
|
||||
**Eine** App-Registrierung erledigt zwei Aufgaben:
|
||||
|
||||
- **„Mit Microsoft anmelden“** (Single Sign-on im Kundenkonto)
|
||||
- **Mailversand** über Microsoft 365 per OAuth 2.0 — ohne Passwort
|
||||
|
||||
Eine zweite Unternehmensanwendung ist nicht nötig.
|
||||
|
||||
### 3.1 App registrieren
|
||||
|
||||
1. **Entra Admin Center** → Identität → Anwendungen → **App-Registrierungen** → **Neue Registrierung**.
|
||||
2. **Name:** z. B. `Wabenhain Shop`.
|
||||
3. **Unterstützte Kontotypen:** **„Nur Konten in diesem Organisationsverzeichnis“** (Single Tenant).
|
||||
4. **Umleitungs-URI:** Plattform **Web**, Adresse **`https://wabenhain.com/api/auth/entra/callback`** — zeichengenau, mit `https`, ohne Schrägstrich am Ende.
|
||||
5. **Registrieren**.
|
||||
|
||||
Auf der Seite **Übersicht** notieren:
|
||||
|
||||
| Angezeigt als | Gehört in |
|
||||
|---|---|
|
||||
| Anwendungs-ID (Client-ID) | `ENTRA_CLIENT_ID` |
|
||||
| Verzeichnis-ID (Mandanten-ID) | `ENTRA_TENANT_ID` |
|
||||
|
||||
`ENTRA_REDIRECT_URI` ist die Adresse aus Schritt 4: `https://wabenhain.com/api/auth/entra/callback`.
|
||||
|
||||
### 3.2 Geheimen Clientschlüssel anlegen
|
||||
|
||||
1. In der App → **Zertifikate & Geheimnisse** → **Geheime Clientschlüssel** → **Neuer geheimer Clientschlüssel**.
|
||||
2. Beschreibung z. B. `Shop 2026`, Ablauf **24 Monate** (Maximum).
|
||||
3. **Sofort** die Spalte **„Wert“** kopieren → `ENTRA_CLIENT_SECRET`.
|
||||
|
||||
> ⚠ **Nicht die „Geheimnis-ID“** kopieren — das ist der häufigste Fehler (Microsoft meldet dann `AADSTS7000215`). Der Wert wird nur dieses eine Mal angezeigt; ist er weg, einen neuen Schlüssel anlegen.
|
||||
>
|
||||
> **Ablaufdatum sofort in den Kalender eintragen**, mit Erinnerung vier Wochen vorher. Läuft der Schlüssel ab, fallen Microsoft-Anmeldung **und** Mailversand gleichzeitig aus (siehe [Wartung](#10-wartung)).
|
||||
|
||||
### 3.3 API-Berechtigungen
|
||||
|
||||
In der App → **API-Berechtigungen**. Es braucht zwei Arten:
|
||||
|
||||
| Berechtigung | API | Typ | Wofür |
|
||||
|---|---|---|---|
|
||||
| `openid`, `email`, `profile`, `User.Read` | Microsoft Graph | **Delegiert** | Anmeldung („Mit Microsoft anmelden“) |
|
||||
| `SMTP.SendAsApp` | **Office 365 Exchange Online** | **Anwendung** | Mailversand |
|
||||
|
||||
`User.Read` ist meist schon vorhanden. Die übrigen hinzufügen:
|
||||
|
||||
**Für die Anmeldung** (falls noch nicht vorhanden):
|
||||
1. **Berechtigung hinzufügen** → **Microsoft Graph** → **Delegierte Berechtigungen**.
|
||||
2. `openid`, `email`, `profile` anhaken → **Berechtigungen hinzufügen**.
|
||||
|
||||
**Für den Mailversand:**
|
||||
1. **Berechtigung hinzufügen** → Reiter **„Von meiner Organisation verwendete APIs“**.
|
||||
2. Nach **Office 365 Exchange Online** suchen und auswählen. (Die Berechtigung liegt **nicht** unter Microsoft Graph.)
|
||||
3. **Anwendungsberechtigungen** (nicht „Delegierte“) → **`SMTP.SendAsApp`** anhaken → **Berechtigungen hinzufügen**.
|
||||
|
||||
**Danach:** **„Administratorzustimmung für <Organisation> erteilen“** klicken und bestätigen. In der Liste muss bei jeder Berechtigung ein grünes **„Erteilt für …“** stehen.
|
||||
|
||||
> **Was `SMTP.SendAsApp` erlaubt:** Die App darf per SMTP senden — aber **nur** mit Postfächern, die ihr in Exchange ausdrücklich freigegeben werden (Abschnitt 4). Ohne diese Freigabe kann sie nichts senden.
|
||||
|
||||
### 3.4 Objekt-ID der Unternehmensanwendung notieren
|
||||
|
||||
1. Entra → Identität → Anwendungen → **Unternehmensanwendungen**.
|
||||
2. Filter „Anwendungstyp“ auf **Alle Anwendungen** stellen, nach `Wabenhain Shop` suchen, öffnen.
|
||||
3. Auf der **Übersicht** die **Objekt-ID** kopieren.
|
||||
|
||||
> ⚠ Das ist **eine andere Objekt-ID** als die auf der Seite der App-Registrierung. Für Exchange zählt nur die der **Unternehmensanwendung**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Microsoft 365: Absender-Postfach freischalten
|
||||
|
||||
Alle Shop-Mails (Bestellbestätigung, Versandmeldung, Passwort-Reset, Widerrufsbestätigung …) gehen von **einem** Postfach aus, z. B. `hallo@wabenhain.com`.
|
||||
|
||||
**Empfohlen: ein freigegebenes Postfach (Shared Mailbox).** Es braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. Die App sendet ohne Anmeldung einer Person.
|
||||
|
||||
### 4.1 Freigegebenes Postfach anlegen (falls noch nicht vorhanden)
|
||||
|
||||
1. **Microsoft 365 Admin Center** → **Teams und Gruppen** → **Freigegebene Postfächer** → **Freigegebenes Postfach hinzufügen**.
|
||||
2. Name `Wabenhain`, E-Mail `hallo@wabenhain.com` → **Änderungen speichern**.
|
||||
3. Optional: Mitglieder hinzufügen, die Antworten der Kundschaft lesen sollen.
|
||||
|
||||
### 4.2 App für genau dieses Postfach freischalten
|
||||
|
||||
In **PowerShell** (als Exchange-Administrator). Platzhalter ersetzen:
|
||||
|
||||
- `<CLIENT-ID>` = `ENTRA_CLIENT_ID` aus 3.1
|
||||
- `<OBJEKT-ID>` = Objekt-ID der **Unternehmensanwendung** aus 3.4
|
||||
|
||||
```powershell
|
||||
Connect-ExchangeOnline
|
||||
|
||||
# 1. App in Exchange bekannt machen
|
||||
New-ServicePrincipal -AppId <CLIENT-ID> -ObjectId <OBJEKT-ID> -DisplayName "Wabenhain Shop"
|
||||
|
||||
# 2. Zugriff NUR auf das Absender-Postfach
|
||||
Add-MailboxPermission -Identity "[email protected]" -User <OBJEKT-ID> -AccessRights FullAccess
|
||||
|
||||
# 3. Authentifiziertes SMTP für dieses Postfach erlauben (auch OAuth läuft darüber)
|
||||
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
|
||||
```
|
||||
|
||||
**Empfohlene Absicherung:** Authentifiziertes SMTP für alle **anderen** Postfächer abschalten. Die Einstellung am Absender-Postfach aus Schritt 3 hat Vorrang, der Shop sendet weiter.
|
||||
|
||||
```powershell
|
||||
Set-TransportConfig -SmtpClientAuthenticationDisabled $true
|
||||
```
|
||||
|
||||
### 4.3 Kontrolle
|
||||
|
||||
```powershell
|
||||
Get-ServicePrincipal | Where-Object DisplayName -eq "Wabenhain Shop"
|
||||
Get-MailboxPermission -Identity "[email protected]" | Where-Object { $_.User -like "*Wabenhain*" }
|
||||
Get-CASMailbox -Identity "[email protected]" | Format-List SmtpClientAuthenticationDisabled # muss False sein
|
||||
Disconnect-ExchangeOnline -Confirm:$false
|
||||
```
|
||||
|
||||
Änderungen in Exchange brauchen **bis zu 30 Minuten**, bis sie greifen.
|
||||
|
||||
**Ergebnis für die Konfiguration:**
|
||||
|
||||
| Wert | Wo | Inhalt |
|
||||
|---|---|---|
|
||||
| `SMTP_USER` | Secret | `hallo@wabenhain.com` |
|
||||
| `SMTP_FROM` | ConfigMap | `Wabenhain <hallo@wabenhain.com>` — **dieselbe Adresse** wie `SMTP_USER` |
|
||||
|
||||
Server (`smtp.office365.com`), Port (587, STARTTLS) und Anmeldeverfahren (OAuth 2.0) sind fest im Code — dafür gibt es keine Einstellungen. Ein SMTP-Passwort gibt es nicht.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stripe
|
||||
|
||||
Im **Stripe-Dashboard**, **Live-Modus** (Schalter oben rechts, nicht „Testmodus“).
|
||||
|
||||
### 5.1 API-Schlüssel
|
||||
|
||||
**Entwickler** → **API-Schlüssel**:
|
||||
|
||||
| Angezeigt als | Gehört in | Wo |
|
||||
|---|---|---|
|
||||
| Veröffentlichbarer Schlüssel (`pk_live_…`) | `STRIPE_PUBLISHABLE_KEY` | ConfigMap (nicht geheim) |
|
||||
| Geheimer Schlüssel (`sk_live_…`) | `STRIPE_SECRET_KEY` | Secret |
|
||||
|
||||
Sicherer als der volle geheime Schlüssel ist ein **eingeschränkter Schlüssel** (`rk_live_…`) mit Schreibrecht auf Checkout Sessions, Refunds und Leserecht auf Charges/Payment Intents.
|
||||
|
||||
> Beide Schlüssel müssen aus **demselben Modus** stammen. Mit `sk_test` sehen Bestellungen erfolgreich aus, ohne dass Geld fließt.
|
||||
|
||||
### 5.2 Webhook
|
||||
|
||||
**Entwickler** → **Webhooks** → **Endpunkt hinzufügen**:
|
||||
|
||||
- **URL:** `https://wabenhain.com/api/checkout/webhook`
|
||||
- **Ereignisse:**
|
||||
- `checkout.session.completed`
|
||||
- `checkout.session.async_payment_succeeded`
|
||||
- `checkout.session.async_payment_failed`
|
||||
- `checkout.session.expired`
|
||||
- `charge.refunded`
|
||||
- `charge.dispute.created`
|
||||
|
||||
Nach dem Anlegen **Signaturgeheimnis** anzeigen (`whsec_…`) → `STRIPE_WEBHOOK_SECRET` im Secret.
|
||||
|
||||
> Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „Neu“, es gibt weder Rechnung noch Bestätigungsmail.
|
||||
|
||||
### 5.3 Zahlungsarten
|
||||
|
||||
**Einstellungen** → **Zahlungsmethoden**: gewünschte Arten aktivieren (Karte, SEPA-Lastschrift, PayPal, Klarna, Apple Pay, Google Pay). Der Shop zeigt automatisch an, was dort aktiv ist. Für Apple Pay die Domain `wabenhain.com` unter **Apple Pay** registrieren.
|
||||
|
||||
---
|
||||
|
||||
## 6. Optionale Dienste
|
||||
|
||||
Alle folgenden Werte dürfen leer bleiben. Die Funktion ruht dann, der Shop läuft weiter. Ausführliche Anleitungen stehen in `.env.example` im jeweiligen Abschnitt.
|
||||
|
||||
| Dienst | Werte | Ohne sie | Einrichtung |
|
||||
|---|---|---|---|
|
||||
| **Cloudflare Turnstile** (CAPTCHA) — *dringend empfohlen* | `TURNSTILE_SECRET_KEY` (Secret), `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (ConfigMap) | Keine Bot-Abwehr an Formularen | dash.cloudflare.com → Turnstile → Widget für `wabenhain.com` anlegen. Beide setzen oder beide leer. |
|
||||
| **Google-Rezensionen** | `GOOGLE_PLACES_API_KEY` (Secret), `GOOGLE_PLACE_ID` (ConfigMap) | Statische Beispiel-Stimmen | Google Cloud: „Places API (New)“ aktivieren, Abrechnung an, API-Schlüssel ohne Referrer-Beschränkung |
|
||||
| **DHL** (Versandmarken, Packstation, Sendungsverfolgung) | `DHL_API_KEY`, `DHL_API_SECRET`, `DHL_GKP_USERNAME`, `DHL_GKP_PASSWORD`, `DHL_ACCOUNT_NUMBER`, `DHL_TRACKING_API_KEY` (Secret), `DHL_ENVIRONMENT` (ConfigMap) | Kein Etikettendruck, keine Packstation im Checkout | developer.dhl.com, Geschäftskundenvertrag — Schritt für Schritt in `.env.example` |
|
||||
| **ApiCore-Synchronisierung** | `APICORE_SYNC_EMAIL`, `APICORE_SYNC_PASSWORD` (Secret), `APICORE_API_URL` (ConfigMap) | Sync-Knopf meldet Konfigurationsfehler | Eigenes, MFA-freies Service-Konto mit nur lesenden Rechten, siehe `.env.example` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Secret und ConfigMap ausfüllen
|
||||
|
||||
### 7.1 Secret (`k8s/02-secret.yaml`)
|
||||
|
||||
```bash
|
||||
cp k8s/02-secret.example.yaml k8s/02-secret.yaml
|
||||
```
|
||||
|
||||
`k8s/02-secret.yaml` öffnen und jeden Wert aus der Tabelle eintragen. Werte in **doppelten Anführungszeichen** lassen (`stringData` — Kubernetes kodiert selbst, **kein** base64 von Hand).
|
||||
|
||||
| Schlüssel | Pflicht | Inhalt | Quelle |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URL` | ja | Verbindungs-URL mit **URL-kodiertem** Passwort | Abschnitt 2 |
|
||||
| `DB_PASSWORD` | ja | Datenbank-Passwort **roh** | Abschnitt 2 |
|
||||
| `SESSION_SECRET` | ja | ≥ 32 zufällige Zeichen | Abschnitt 2 |
|
||||
| `ADMIN_EMAIL` | ja | nicht erratbare Admin-Adresse | Abschnitt 2 |
|
||||
| `ADMIN_PASSWORD` | ja | ≥ 12 Zeichen, zufällig | Abschnitt 2 |
|
||||
| `STRIPE_SECRET_KEY` | ja | `sk_live_…` bzw. `rk_live_…` | 5.1 |
|
||||
| `STRIPE_WEBHOOK_SECRET` | ja | `whsec_…` | 5.2 |
|
||||
| `SMTP_USER` | ja | `hallo@wabenhain.com` | 4 |
|
||||
| `ENTRA_TENANT_ID` | ja* | Verzeichnis-ID | 3.1 |
|
||||
| `ENTRA_CLIENT_ID` | ja* | Anwendungs-ID | 3.1 |
|
||||
| `ENTRA_CLIENT_SECRET` | ja* | **Wert** des Clientschlüssels | 3.2 |
|
||||
| `ENTRA_REDIRECT_URI` | ja* | `https://wabenhain.com/api/auth/entra/callback` | 3.1 |
|
||||
| `TURNSTILE_SECRET_KEY` | empfohlen | Turnstile-Secret | 6 |
|
||||
| `GOOGLE_PLACES_API_KEY` | nein | Google-API-Schlüssel | 6 |
|
||||
| `APICORE_SYNC_EMAIL`, `APICORE_SYNC_PASSWORD` | nein | Service-Konto | 6 |
|
||||
| `DHL_*` (6 Werte) | nein | DHL-Zugangsdaten | 6 |
|
||||
|
||||
\* Ohne die `ENTRA_*`-Werte gibt es weder „Mit Microsoft anmelden“ **noch Mailversand** — keine Bestellbestätigungen, keine Passwort-Resets. Für den Live-Betrieb daher Pflicht.
|
||||
|
||||
**Nicht mehr verwendet** (aus älteren Secrets entfernen): `SMTP_HOST`, `SMTP_PORT`, `SMTP_PASSWORD`, `SMTP_OAUTH_TENANT_ID`, `SMTP_OAUTH_CLIENT_ID`, `SMTP_OAUTH_CLIENT_SECRET`.
|
||||
|
||||
### 7.2 ConfigMap (`k8s/01-configmap.yaml`)
|
||||
|
||||
Nur diese Werte anpassen, der Rest sind sinnvolle Vorgaben:
|
||||
|
||||
| Schlüssel | Inhalt |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_SITE_URL` | `https://wabenhain.com` — muss zu `host` in `k8s/06-ingress.yaml` passen |
|
||||
| `SMTP_FROM` | `Wabenhain <hallo@wabenhain.com>` — dieselbe Adresse wie `SMTP_USER` |
|
||||
| `DATABASE_PROVIDER` | `postgresql` (oder `mysql`, passend zu `DATABASE_URL`) |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | `pk_live_…` |
|
||||
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Turnstile-Site-Key (empfohlen) |
|
||||
| `VAT_RATE_PERCENT*` | Umsatzsteuersätze prüfen (Honig 7 %, Kerzen 19 %, Propolis je nach Einordnung) |
|
||||
| `DHL_ENVIRONMENT` | `sandbox`, bis DHL getestet ist, dann `production` |
|
||||
| `GOOGLE_PLACE_ID`, `APICORE_API_URL` | nur bei Nutzung |
|
||||
|
||||
---
|
||||
|
||||
## 8. Ausrollen
|
||||
|
||||
Erstmalig, in dieser Reihenfolge (Details und Hintergründe: `k8s/README.md`):
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/00-namespace.yaml
|
||||
kubectl apply -f k8s/01-configmap.yaml
|
||||
kubectl apply -f k8s/02-secret.yaml
|
||||
|
||||
# Nur EINE Datenbank, passend zu DATABASE_PROVIDER:
|
||||
kubectl apply -f k8s/03-postgres.yaml
|
||||
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
|
||||
|
||||
kubectl apply -f k8s/04-uploads-pvc.yaml
|
||||
kubectl apply -f k8s/05-app-deployment.yaml
|
||||
kubectl apply -f k8s/06-ingress.yaml
|
||||
kubectl apply -f k8s/07-backup-cronjob.yaml
|
||||
|
||||
kubectl rollout status deployment/wabenhain-app -n wabenhain
|
||||
```
|
||||
|
||||
Datenbank-Migrationen und das Anlegen des Admin-Kontos laufen beim Start des Pods automatisch.
|
||||
|
||||
**Später einen Wert ändern:** Datei anpassen, erneut anwenden und den Pod neu starten — die App liest Secrets und ConfigMap nur beim Start:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/02-secret.yaml # bzw. 01-configmap.yaml
|
||||
kubectl rollout restart deployment/wabenhain-app -n wabenhain
|
||||
```
|
||||
|
||||
Einzelnen Wert ohne Datei ändern bzw. entfernen:
|
||||
|
||||
```bash
|
||||
kubectl -n wabenhain patch secret wabenhain-secrets --type=merge -p '{"stringData":{"SMTP_USER":"[email protected]"}}'
|
||||
kubectl -n wabenhain patch secret wabenhain-secrets --type=json -p '[{"op":"remove","path":"/data/SMTP_PASSWORD"}]'
|
||||
```
|
||||
|
||||
> Wer `k8s/02-secret.yaml` lokal pflegt, trägt jede Änderung auch dort ein — sonst setzt das nächste `kubectl apply` den alten Stand zurück.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prüfen
|
||||
|
||||
Der Reihe nach:
|
||||
|
||||
1. **Vollständigkeit der Konfiguration** im laufenden Pod:
|
||||
```bash
|
||||
kubectl -n wabenhain exec deploy/wabenhain-app -- node_modules/.bin/tsx scripts/preflight.ts
|
||||
```
|
||||
Jeder fehlende Pflichtwert erscheint als Blocker mit Begründung.
|
||||
2. **Shop erreichbar:** `https://wabenhain.com` öffnen.
|
||||
3. **Admin-Login:** `https://wabenhain.com/konto` mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` anmelden → Zwei-Faktor-Authentifizierung einrichten → Admin-Bereich öffnet sich.
|
||||
4. **Mailversand:** **Admin → E-Mail-Test** → „Test-Mail senden“. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
|
||||
5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen.
|
||||
6. **Firmendaten:** **Admin → Inhalte → Firmendaten** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
|
||||
7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten.
|
||||
8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**.
|
||||
|
||||
---
|
||||
|
||||
## 10. Wartung
|
||||
|
||||
| Was | Läuft ab / ändert sich | Folge, wenn verpasst | Was tun |
|
||||
|---|---|---|---|
|
||||
| **Entra-Clientschlüssel** (`ENTRA_CLIENT_SECRET`) | nach höchstens **24 Monaten** | Microsoft-Anmeldung **und** Mailversand fallen gleichzeitig aus | Vier Wochen vorher neuen Schlüssel anlegen (3.2), Secret ersetzen, Pod neu starten, **E-Mail-Test**, danach alten Schlüssel in Entra löschen |
|
||||
| **Stripe-Schlüssel** | bei Verdacht auf Abfluss | Fremde könnten Erstattungen auslösen | Im Stripe-Dashboard „Schlüssel rollen“, Secret ersetzen, Pod neu starten |
|
||||
| **`SESSION_SECRET`** | bei Verdacht auf Abfluss | Sitzungen fälschbar | Neuen Wert erzeugen, ersetzen, neu starten (meldet alle ab) |
|
||||
| **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen |
|
||||
| **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen |
|
||||
|
||||
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → E-Mail-Test**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Fehlerbehebung
|
||||
|
||||
### Mailversand (Anzeige in **Admin → E-Mail-Test**)
|
||||
|
||||
| Meldung / Ursache | Lösung |
|
||||
|---|---|
|
||||
| „nicht vollständig eingerichtet“ + Liste fehlender Werte | Genannte Werte ins Secret, Pod neu starten |
|
||||
| `AADSTS7000215` — Client-Secret falsch | In `ENTRA_CLIENT_SECRET` steht die Geheimnis-ID statt des **Werts** → neuen Schlüssel anlegen (3.2) |
|
||||
| `AADSTS7000222` — Client-Secret abgelaufen | Neuen Schlüssel anlegen (3.2), ersetzen, neu starten |
|
||||
| `AADSTS700016` — App nicht gefunden | `ENTRA_CLIENT_ID` und `ENTRA_TENANT_ID` gehören nicht zusammen |
|
||||
| `AADSTS90002` / `900023` — Tenant ungültig | `ENTRA_TENANT_ID` prüfen (Verzeichnis-ID) |
|
||||
| „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? |
|
||||
| „Authentifiziertes SMTP ausgeschaltet“ (`SmtpClientAuthentication is disabled`) | `Set-CASMailbox -Identity "hallo@wabenhain.com" -SmtpClientAuthenticationDisabled $false` |
|
||||
| „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen |
|
||||
| „nicht erreichbar“ (Netzwerk) | Cluster muss ausgehend `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall, NetworkPolicy) |
|
||||
|
||||
Ausführliche Fehler stehen außerdem im Pod-Log:
|
||||
|
||||
```bash
|
||||
kubectl logs deploy/wabenhain-app -n wabenhain | grep "\[mail\]"
|
||||
```
|
||||
|
||||
### Microsoft-Anmeldung
|
||||
|
||||
| Symptom | Lösung |
|
||||
|---|---|
|
||||
| Knopf „Mit Microsoft anmelden“ fehlt | Einer der vier `ENTRA_*`-Werte fehlt |
|
||||
| Microsoft meldet „redirect_uri mismatch“ (`AADSTS50011`) | `ENTRA_REDIRECT_URI` und die Umleitungs-URI in der App-Registrierung (3.1) müssen zeichengenau gleich sein |
|
||||
|
||||
### Sonstiges
|
||||
|
||||
| Symptom | Lösung |
|
||||
|---|---|
|
||||
| Pod startet nicht, Log nennt Datenbankfehler | `DATABASE_URL`: Passwort URL-kodiert? Host passend zum Datenbank-Manifest? |
|
||||
| Bestellungen bleiben auf „Neu“ | Stripe-Webhook (5.2): URL, Ereignisse und `STRIPE_WEBHOOK_SECRET` prüfen; im Stripe-Dashboard unter Webhooks die Zustellversuche ansehen |
|
||||
| Bilder weg nach Neustart | `k8s/README.md`, Abschnitt „Bilder/Videos verschwinden nach jedem Redeploy“ |
|
||||
@@ -3,6 +3,10 @@
|
||||
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
|
||||
anwendbar. Nummerierung = empfohlene Reihenfolge.
|
||||
|
||||
Woher jeder Wert in `02-secret.yaml` und `01-configmap.yaml` kommt (Entra ID,
|
||||
Microsoft 365, Stripe, optionale Dienste) und wie man danach prueft, dass
|
||||
alles laeuft: [../docs/EINRICHTUNG.md](../docs/EINRICHTUNG.md).
|
||||
|
||||
## Einmalige Vorbereitung
|
||||
|
||||
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` baut bei jedem
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { z } from "zod";
|
||||
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||
|
||||
const COMPANY_EMAIL_KEY = "company.email";
|
||||
|
||||
@@ -51,13 +51,17 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
|
||||
continue;
|
||||
}
|
||||
// Die Firmen-E-Mail steht im Impressum (§ 5 Abs. 1 Nr. 2 DDG verlangt
|
||||
// eine direkt nutzbare Adresse), in der Widerrufsbelehrung und in
|
||||
// mailto-Links. Eine verschleierte Schreibweise ("info [at] …") waere
|
||||
// dort rechtlich angreifbar und technisch unbrauchbar — daher wird der
|
||||
// ganze Speichervorgang abgelehnt statt still uebernommen.
|
||||
if (def.key === COMPANY_EMAIL_KEY && !z.string().trim().email().safeParse(valueDe).success) {
|
||||
return fail("/admin/inhalte", "Bitte gib als Firmen-E-Mail eine vollständige, gültige Adresse an (z. B. [email protected]). Sie steht im Impressum und muss direkt nutzbar sein.");
|
||||
// Die Firmen-E-Mail darf so eingetragen werden, wie sie gemeint ist —
|
||||
// auch verschleiert ("info [at] …", "info [á] …"). Gespeichert wird der
|
||||
// Eintrag unveraendert; die Website rechnet ihn in die echte Adresse um
|
||||
// und zeigt sie geschuetzt an (lib/contact-email.ts). Abgelehnt wird nur,
|
||||
// was sich in keine gueltige Adresse umrechnen laesst — sonst stuende im
|
||||
// Impressum ein Kontaktweg, der ins Leere fuehrt.
|
||||
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
|
||||
return fail(
|
||||
"/admin/inhalte",
|
||||
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
|
||||
);
|
||||
}
|
||||
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
|
||||
}
|
||||
|
||||
+20
-16
@@ -3,6 +3,7 @@ import { Container, SectionHeading } from "@/components/ui";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { ContactEmail } from "@/components/contact-email";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
@@ -32,22 +33,25 @@ export default async function KontaktPage() {
|
||||
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
|
||||
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
|
||||
<div className="relative">
|
||||
<a
|
||||
href={`mailto:${company.email}`}
|
||||
className="group inline-flex items-center gap-3 font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
|
||||
>
|
||||
<span className="link-draw pb-1">{company.email}</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
{/* Vor Adress-Sammlern geschuetzt: im HTML steht nur
|
||||
"info [at] …", den mailto-Link baut erst der Browser. */}
|
||||
<ContactEmail
|
||||
email={company.email}
|
||||
linkClassName="group inline-flex max-w-full items-center gap-3 break-all font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
|
||||
textClassName="link-draw pb-1"
|
||||
trailing={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 shrink-0 text-honey-light transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
{/* Ohne gepflegte Nummer entfaellt die Zeile ganz. Eine
|
||||
Platzhalternummer waere schlimmer als keine: sie sieht nach
|
||||
einem Kontaktweg aus, an dessen Ende niemand abnimmt. */}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { protectEmail } from "@/lib/contact-email";
|
||||
import { ProtectedEmail } from "@/components/protected-email";
|
||||
|
||||
/**
|
||||
* Serverseitiger Einstieg fuer jede oeffentlich angezeigte Kontakt-E-Mail:
|
||||
* verschluesselt die Adresse und uebergibt nur Ersatzform + Schluesseltext an
|
||||
* den Browser — die Klartext-Adresse landet weder im HTML noch in den an den
|
||||
* Client uebertragenen Daten. Siehe lib/contact-email.ts.
|
||||
*/
|
||||
export function ContactEmail({
|
||||
email,
|
||||
linkClassName,
|
||||
textClassName,
|
||||
trailing,
|
||||
}: {
|
||||
email: string;
|
||||
linkClassName?: string;
|
||||
textClassName?: string;
|
||||
trailing?: ReactNode;
|
||||
}) {
|
||||
const { payload, fallback } = protectEmail(email);
|
||||
return <ProtectedEmail payload={payload} fallback={fallback} linkClassName={linkClassName} textClassName={textClassName} trailing={trailing} />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Container } from "@/components/ui";
|
||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
import { LegalToc } from "@/components/legal-toc";
|
||||
import { ReadingProgress } from "@/components/reading-progress";
|
||||
import { ContactEmail } from "@/components/contact-email";
|
||||
import { extractRevision, parseLegalParagraph, sectionAnchors, tokenizeInline, type LegalBlock } from "@/lib/legal-blocks";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -192,14 +193,20 @@ function Entry({ entry }: { entry: Extract<LegalBlock, { kind: "entry" }> }) {
|
||||
);
|
||||
}
|
||||
|
||||
const LINK_CLASS = "text-heading underline decoration-honey/60 decoration-1 underline-offset-[0.22em] transition-colors hover:decoration-honey";
|
||||
|
||||
function Inline({ text }: { text: string }) {
|
||||
return tokenizeInline(text).map((token, i) =>
|
||||
token.kind === "link" ? (
|
||||
// E-Mail-Adressen (Impressum, Datenschutz, Widerruf) nie im Klartext ins
|
||||
// HTML — Schutz vor Adress-Sammlern, siehe lib/contact-email.ts.
|
||||
token.kind === "link" && token.href.startsWith("mailto:") ? (
|
||||
<ContactEmail key={i} email={token.value} linkClassName={LINK_CLASS} />
|
||||
) : token.kind === "link" ? (
|
||||
<a
|
||||
key={i}
|
||||
href={token.href}
|
||||
{...(token.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="text-heading underline decoration-honey/60 decoration-1 underline-offset-[0.22em] transition-colors hover:decoration-honey"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{token.value}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { decodeEmailPayload } from "@/lib/contact-email";
|
||||
|
||||
export interface ProtectedEmailProps {
|
||||
/** Verschluesselte Adresse aus protectEmail() (lib/contact-email.ts). */
|
||||
payload: string;
|
||||
/** Lesbare Ersatzform ("info [at] wabenhain.com") fuer HTML und Besucher ohne JavaScript. */
|
||||
fallback: string;
|
||||
linkClassName?: string;
|
||||
textClassName?: string;
|
||||
/** Zusatz hinter dem Text im Link, z. B. ein Pfeil-Icon. */
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt eine E-Mail-Adresse, die im ausgelieferten HTML nicht im Klartext
|
||||
* steht: Server-HTML und erster Render zeigen nur die Ersatzform, erst nach
|
||||
* dem Laden im Browser entsteht der echte mailto-Link.
|
||||
*/
|
||||
export function ProtectedEmail({ payload, fallback, linkClassName, textClassName, trailing }: ProtectedEmailProps) {
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Bewusst erst nach dem Mount: im Server-HTML (das Crawler lesen) soll die
|
||||
// Adresse gerade NICHT stehen, und ein abweichender erster Render wuerde
|
||||
// einen Hydration-Fehler ausloesen.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Entschluesseln ist ein reiner Browser-Schritt nach dem Mount
|
||||
setEmail(decodeEmailPayload(payload));
|
||||
}, [payload]);
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<span className={linkClassName}>
|
||||
<span className={textClassName}>{fallback}</span>
|
||||
{trailing}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={`mailto:${email}`} className={linkClassName}>
|
||||
<span className={textClassName}>{email}</span>
|
||||
{trailing}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import type { ReactNode } from "react";
|
||||
import { Container, LinkButton } from "@/components/ui";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
import { ContactEmail } from "@/components/contact-email";
|
||||
import { footerNav } from "@/lib/site-config";
|
||||
import type { SocialLinks } from "@/lib/social-links";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -137,7 +138,7 @@ export async function Footer({
|
||||
<br />
|
||||
{companyInfo.address.zip} {companyInfo.address.city}
|
||||
<br />
|
||||
{companyInfo.email}
|
||||
<ContactEmail email={companyInfo.email} linkClassName="transition-colors hover:text-cream" />
|
||||
</p>
|
||||
{/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite
|
||||
erreichbar und hervorgehoben — deshalb ein eigener Knopf statt
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cache } from "react";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { getContentValues } from "@/lib/content";
|
||||
|
||||
/** Bewusst schlicht: nur "[email protected]" ohne Leerzeichen — faengt Verschleierungen wie "info [at] x.de" ab. */
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||
|
||||
const COMPANY_KEYS = [
|
||||
"company.name",
|
||||
@@ -49,11 +47,12 @@ export const getCompanyInfo = cache(async () => {
|
||||
claimAlt: v["company.claimAlt"],
|
||||
description: v["company.description"],
|
||||
location: v["company.location"],
|
||||
// Altbestand kann eine verschleierte Adresse enthalten ("info [at] …"),
|
||||
// die das Admin inzwischen ablehnt. Im Impressum und in der
|
||||
// Widerrufsbelehrung muss sie direkt nutzbar sein — dann lieber die
|
||||
// konfigurierte Standardadresse als eine unbrauchbare.
|
||||
email: EMAIL_PATTERN.test(v["company.email"].trim()) ? v["company.email"].trim() : siteConfig.email,
|
||||
// Im Admin darf die Adresse verschleiert gepflegt werden ("info [at] …").
|
||||
// Hier wird sie in die echte Adresse umgerechnet — fuer mailto-Links,
|
||||
// DHL-Etiketten und die geschuetzte Anzeige (components/contact-email.tsx).
|
||||
// Die Standardadresse greift nur, wenn der Eintrag gar keine Adresse
|
||||
// ergibt; das Admin laesst solche Eintraege nicht mehr speichern.
|
||||
email: normalizeContactEmail(v["company.email"]) ?? siteConfig.email,
|
||||
phone: v["company.phone"],
|
||||
address: {
|
||||
...siteConfig.address,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeEmailPayload, displayEmail, encodeEmailPayload, normalizeContactEmail, protectEmail } from "./contact-email";
|
||||
|
||||
describe("normalizeContactEmail", () => {
|
||||
it.each([
|
||||
["[email protected]", "[email protected]"],
|
||||
[" [email protected] ", "[email protected]"],
|
||||
["info [á] wabenhain.com", "[email protected]"],
|
||||
["info [at] wabenhain.com", "[email protected]"],
|
||||
["info(at)wabenhain(dot)com", "[email protected]"],
|
||||
["info {ät} wabenhain [punkt] com", "[email protected]"],
|
||||
["info [AT] wabenhain [DOT] com", "[email protected]"],
|
||||
["hallo<@>wabenhain.com", "[email protected]"],
|
||||
])("turns %j into a usable address", (input, expected) => {
|
||||
expect(normalizeContactEmail(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["", "hallo", "info [á] ", "info @ wabenhain", "a@[email protected]", "info [á] wabenhain.com <script>"])("rejects %j", (input) => {
|
||||
expect(normalizeContactEmail(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayEmail", () => {
|
||||
it("shows a readable, not directly harvestable form", () => {
|
||||
expect(displayEmail("[email protected]")).toBe("info [at] wabenhain.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("email payload", () => {
|
||||
it("round-trips and never contains the address in clear text", () => {
|
||||
const payload = encodeEmailPayload("[email protected]", new Uint8Array([7, 42, 199, 3]));
|
||||
expect(payload).not.toContain("@");
|
||||
expect(payload).not.toContain("wabenhain");
|
||||
expect(payload).not.toContain("info");
|
||||
expect(decodeEmailPayload(payload)).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("supports non-ASCII addresses", () => {
|
||||
const payload = encodeEmailPayload("jü[email protected]", new Uint8Array([1, 2, 3]));
|
||||
expect(decodeEmailPayload(payload)).toBe("jü[email protected]");
|
||||
});
|
||||
|
||||
it("returns null for damaged payloads", () => {
|
||||
expect(decodeEmailPayload("kaputt")).toBeNull();
|
||||
expect(decodeEmailPayload("0102.zz")).toBeNull();
|
||||
expect(decodeEmailPayload("")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a fresh key per call", () => {
|
||||
const a = protectEmail("[email protected]");
|
||||
const b = protectEmail("[email protected]");
|
||||
expect(a.payload).not.toBe(b.payload);
|
||||
expect(decodeEmailPayload(a.payload)).toBe("[email protected]");
|
||||
expect(a.fallback).toBe("info [at] wabenhain.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Kontakt-E-Mail: im Admin frei pflegbar (auch in verschleierter Form wie
|
||||
* "info [at] wabenhain.com") und auf der Website vor Adress-Sammlern
|
||||
* geschuetzt.
|
||||
*
|
||||
* Schutzprinzip: Im ausgelieferten HTML steht die Adresse nie im Klartext —
|
||||
* nur die lesbare Ersatzform ("info [at] wabenhain.com") und eine
|
||||
* XOR-verschluesselte Zeichenkette mit zufaelligem Schluessel je Aufruf. Erst
|
||||
* der Browser setzt daraus den mailto-Link zusammen
|
||||
* (components/protected-email.tsx). Einfache Crawler, die nur HTML lesen,
|
||||
* finden damit keine Adresse; wer JavaScript ausfuehrt, kann sie — wie jeder
|
||||
* Besucher — trotzdem lesen. Vollstaendigen Schutz gibt es fuer eine
|
||||
* oeffentlich angezeigte Adresse nicht.
|
||||
*
|
||||
* Bewusst ohne node:crypto — das Modul wird auch im Browser (Entschluesseln)
|
||||
* geladen.
|
||||
*/
|
||||
|
||||
const AT_TOKEN = /\s*[[({<]\s*(?:at|á|ä|ät|@)\s*[\])}>]\s*/gi;
|
||||
const DOT_TOKEN = /\s*[[({<]\s*(?:dot|punkt|\.)\s*[\])}>]\s*/gi;
|
||||
const EMAIL = /^[^\s@<>"'()[\]{}]+@[^\s@<>"'()[\]{}]+\.[^\s@<>"'()[\]{}]+$/;
|
||||
const KEY_BYTES = 8;
|
||||
|
||||
/**
|
||||
* Macht aus dem Admin-Eintrag eine verwendbare Adresse. Versteht die
|
||||
* ueblichen Verschleierungen ([at], (at), {ät}, [á], <@>, [dot], [punkt]).
|
||||
* null, wenn danach keine gueltige Adresse uebrig bleibt.
|
||||
*/
|
||||
export function normalizeContactEmail(raw: string): string | null {
|
||||
const email = raw.trim().replace(AT_TOKEN, "@").replace(DOT_TOKEN, ".");
|
||||
return EMAIL.test(email) ? email : null;
|
||||
}
|
||||
|
||||
/** Lesbare Ersatzform fuer die Anzeige ohne JavaScript: "info [at] wabenhain.com". */
|
||||
export function displayEmail(email: string): string {
|
||||
return email.replace("@", " [at] ");
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array | null {
|
||||
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/.test(hex)) return null;
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function xor(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||
return data.map((byte, i) => byte ^ key[i % key.length]);
|
||||
}
|
||||
|
||||
export function encodeEmailPayload(email: string, key: Uint8Array): string {
|
||||
return `${toHex(key)}.${toHex(xor(new TextEncoder().encode(email), key))}`;
|
||||
}
|
||||
|
||||
export function decodeEmailPayload(payload: string): string | null {
|
||||
const [keyHex, dataHex] = payload.split(".");
|
||||
const key = fromHex(keyHex ?? "");
|
||||
const data = fromHex(dataHex ?? "");
|
||||
if (!key || !data) return null;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(xor(data, key));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Serverseitig: Ersatzanzeige + verschluesselte Adresse fuer components/protected-email.tsx. */
|
||||
export function protectEmail(email: string): { payload: string; fallback: string } {
|
||||
const key = globalThis.crypto.getRandomValues(new Uint8Array(KEY_BYTES));
|
||||
return { payload: encodeEmailPayload(email, key), fallback: displayEmail(email) };
|
||||
}
|
||||
Reference in new issue
Block a user