feat: ICS-Kalendereinträge, Rate-Limiting und erweiterte E-Mail-Validierung
- ICS-Dateianhänge in Bestätigungsmails mit Europe/Berlin Zeitzone - Rate-Limiting: IP-basiert (5/10min) und E-Mail-basiert (3/1h) - Mehrschichtige E-Mail-Validierung mit Rapid Email Validator API - Disposable Email Detection (blockiert Wegwerf-Adressen) - MX Record Verification - Domain Verification - Typo-Erkennung mit Vorschlägen - Zod-Schema-Validierung für Name, E-Mail und Telefonnummer - Dokumentation für Rate-Limiting und E-Mail-Validierung - README mit neuen Features aktualisiert - Backlog aktualisiert
This commit is contained in:
464
README.md
464
README.md
@@ -1,225 +1,239 @@
|
|||||||
# Stargirlnails Kiel - Nail Salon Booking System
|
# Stargirlnails Kiel - Nail Salon Booking System
|
||||||
|
|
||||||
Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender und E-Mail-Benachrichtigungen.
|
Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender und E-Mail-Benachrichtigungen.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- [TypeScript](https://www.typescriptlang.org/)
|
- [TypeScript](https://www.typescriptlang.org/)
|
||||||
- [React](https://react.dev/)
|
- [React](https://react.dev/)
|
||||||
- [Vite](https://vite.dev/)
|
- [Vite](https://vite.dev/)
|
||||||
- [Tailwind CSS V4](https://tailwindcss.com/)
|
- [Tailwind CSS V4](https://tailwindcss.com/)
|
||||||
- [oRPC](https://orpc.unnoq.com/)
|
- [oRPC](https://orpc.unnoq.com/)
|
||||||
- [Hono](https://hono.dev/)
|
- [Hono](https://hono.dev/)
|
||||||
- [Zod](https://zod.dev/)
|
- [Zod](https://zod.dev/)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1. Umgebungsvariablen konfigurieren
|
### 1. Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvariablen:
|
Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvariablen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Admin-Passwort Hash generieren
|
### 2. Admin-Passwort Hash generieren
|
||||||
|
|
||||||
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
|
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
|
||||||
|
|
||||||
#### PowerShell (Windows)
|
#### PowerShell (Windows)
|
||||||
```powershell
|
```powershell
|
||||||
# Einfache Methode mit Base64-Encoding
|
# Einfache Methode mit Base64-Encoding
|
||||||
$password = "dein_sicheres_passwort"
|
$password = "dein_sicheres_passwort"
|
||||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
|
||||||
$hash = [System.Convert]::ToBase64String($bytes)
|
$hash = [System.Convert]::ToBase64String($bytes)
|
||||||
Write-Host "Password Hash: $hash"
|
Write-Host "Password Hash: $hash"
|
||||||
|
|
||||||
# Alternative mit PowerShell 7+ (kürzer)
|
# Alternative mit PowerShell 7+ (kürzer)
|
||||||
$password = "dein_sicheres_passwort"
|
$password = "dein_sicheres_passwort"
|
||||||
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
|
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Node.js (falls verfügbar)
|
#### Node.js (falls verfügbar)
|
||||||
```javascript
|
```javascript
|
||||||
// In der Node.js Konsole oder als separates Script
|
// In der Node.js Konsole oder als separates Script
|
||||||
const password = "dein_sicheres_passwort";
|
const password = "dein_sicheres_passwort";
|
||||||
const hash = Buffer.from(password).toString('base64');
|
const hash = Buffer.from(password).toString('base64');
|
||||||
console.log("Password Hash:", hash);
|
console.log("Password Hash:", hash);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Online-Tools (nur für Entwicklung)
|
#### Online-Tools (nur für Entwicklung)
|
||||||
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
|
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
|
||||||
|
|
||||||
### 3. .env Datei konfigurieren
|
### 3. .env Datei konfigurieren
|
||||||
|
|
||||||
Bearbeite deine `.env` Datei und setze die generierten Werte:
|
Bearbeite deine `.env` Datei und setze die generierten Werte:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Admin Account Configuration
|
# Admin Account Configuration
|
||||||
ADMIN_USERNAME=owner
|
ADMIN_USERNAME=owner
|
||||||
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
|
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
|
||||||
|
|
||||||
# Domain Configuration
|
# Domain Configuration
|
||||||
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
|
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
RESEND_API_KEY=your_resend_api_key_here
|
RESEND_API_KEY=your_resend_api_key_here
|
||||||
EMAIL_FROM=noreply@yourdomain.com
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
ADMIN_EMAIL=admin@yourdomain.com
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
|
||||||
# Stornierungsfrist (in Stunden)
|
# Stornierungsfrist (in Stunden)
|
||||||
MIN_STORNO_TIMESPAN=24
|
MIN_STORNO_TIMESPAN=24
|
||||||
|
|
||||||
# Legal Information (Impressum/Datenschutz)
|
# Legal Information (Impressum/Datenschutz)
|
||||||
COMPANY_NAME=Stargirlnails Kiel
|
COMPANY_NAME=Stargirlnails Kiel
|
||||||
OWNER_NAME=Inhaber Name
|
OWNER_NAME=Inhaber Name
|
||||||
ADDRESS_STREET=Liebigstr. 15
|
ADDRESS_STREET=Liebigstr. 15
|
||||||
ADDRESS_CITY=Kiel
|
ADDRESS_CITY=Kiel
|
||||||
ADDRESS_POSTAL_CODE=24145
|
ADDRESS_POSTAL_CODE=24145
|
||||||
ADDRESS_COUNTRY=Deutschland
|
ADDRESS_COUNTRY=Deutschland
|
||||||
ADDRESS_LATITUDE=54.3233 # Optional: GPS-Koordinaten für Karte
|
ADDRESS_LATITUDE=54.3233 # Optional: GPS-Koordinaten für Karte
|
||||||
ADDRESS_LONGITUDE=10.1228 # Optional: GPS-Koordinaten für Karte
|
ADDRESS_LONGITUDE=10.1228 # Optional: GPS-Koordinaten für Karte
|
||||||
CONTACT_PHONE=+49 431 123456
|
CONTACT_PHONE=+49 431 123456
|
||||||
CONTACT_EMAIL=info@stargirlnails.de
|
CONTACT_EMAIL=info@stargirlnails.de
|
||||||
TAX_ID=12/345/67890 # Optional
|
TAX_ID=12/345/67890 # Optional
|
||||||
VAT_ID=DE123456789 # Optional
|
VAT_ID=DE123456789 # Optional
|
||||||
COMMERCIAL_REGISTER=HRB 12345 # Optional
|
COMMERCIAL_REGISTER=HRB 12345 # Optional
|
||||||
RESPONSIBLE_FOR_CONTENT=Inhaber Name
|
RESPONSIBLE_FOR_CONTENT=Inhaber Name
|
||||||
DATA_PROTECTION_RESPONSIBLE=Inhaber Name
|
DATA_PROTECTION_RESPONSIBLE=Inhaber Name
|
||||||
DATA_PROTECTION_EMAIL=datenschutz@stargirlnails.de
|
DATA_PROTECTION_EMAIL=datenschutz@stargirlnails.de
|
||||||
THIRD_PARTY_SERVICES=Resend (E-Mail-Versand),Google Analytics # Komma-getrennt
|
THIRD_PARTY_SERVICES=Resend (E-Mail-Versand),Google Analytics # Komma-getrennt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Anwendung starten
|
### 4. Anwendung starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dependencies installieren
|
# Dependencies installieren
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Entwicklungsserver starten
|
# Entwicklungsserver starten
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
### Docker Build
|
### Docker Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker Image erstellen
|
# Docker Image erstellen
|
||||||
docker build -t stargirlnails-booking .
|
docker build -t stargirlnails-booking .
|
||||||
|
|
||||||
# Container starten
|
# Container starten
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name stargirlnails-app \
|
--name stargirlnails-app \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
stargirlnails-booking
|
stargirlnails-booking
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Compose (empfohlen)
|
### Docker Compose (empfohlen)
|
||||||
|
|
||||||
Erstelle eine `docker-compose.yml` Datei:
|
Erstelle eine `docker-compose.yml` Datei:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
stargirlnails:
|
stargirlnails:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./.storage:/app/.storage
|
- ./.storage:/app/.storage
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
```
|
```
|
||||||
|
|
||||||
Starten mit Docker Compose:
|
Starten mit Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Container starten
|
# Container starten
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# Logs anzeigen
|
# Logs anzeigen
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
|
|
||||||
# Container stoppen
|
# Container stoppen
|
||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Produktions-Deployment
|
### Produktions-Deployment
|
||||||
|
|
||||||
Für den produktiven Einsatz:
|
Für den produktiven Einsatz:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Mit Docker Compose
|
# Mit Docker Compose
|
||||||
docker-compose -f docker-compose.yml up -d
|
docker-compose -f docker-compose.yml up -d
|
||||||
|
|
||||||
# Oder direkt mit Docker
|
# Oder direkt mit Docker
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name stargirlnails-prod \
|
--name stargirlnails-prod \
|
||||||
-p 80:3000 \
|
-p 80:3000 \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--env-file .env.production \
|
--env-file .env.production \
|
||||||
stargirlnails-booking
|
stargirlnails-booking
|
||||||
```
|
```
|
||||||
|
|
||||||
**Wichtige Produktions-Hinweise:**
|
**Wichtige Produktions-Hinweise:**
|
||||||
- Verwende eine `.env.production` Datei mit Produktions-Konfiguration
|
- Verwende eine `.env.production` Datei mit Produktions-Konfiguration
|
||||||
- Setze `NODE_ENV=production` in der Umgebungsdatei
|
- Setze `NODE_ENV=production` in der Umgebungsdatei
|
||||||
- Verwende einen Reverse Proxy (nginx, Traefik) für HTTPS
|
- Verwende einen Reverse Proxy (nginx, Traefik) für HTTPS
|
||||||
- Überwache Container mit Health Checks
|
- Überwache Container mit Health Checks
|
||||||
- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten
|
- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📅 **Terminbuchung**: Kunden können online Termine buchen
|
### Buchungssystem
|
||||||
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
|
- 📅 **Terminbuchung**: Kunden können online Termine buchen
|
||||||
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
|
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
|
||||||
- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
|
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
|
||||||
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
|
- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
|
||||||
- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren
|
- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren
|
||||||
- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN)
|
- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN)
|
||||||
- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten
|
|
||||||
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
|
### E-Mail & Benachrichtigungen
|
||||||
- 🛡️ **Security.txt**: RFC 9116 konformer Endpoint für Sicherheitsmeldungen
|
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
|
||||||
|
- 📅 **ICS-Kalendereinträge**: Termin-Bestätigungen mit ICS-Datei zum Importieren in Kalender-Apps
|
||||||
## Admin-Zugang
|
- ⏰ **Kalender-Erinnerungen**: 24h-Erinnerung im ICS-Kalendereintrag
|
||||||
|
- 📎 **AGB-Anhänge**: Automatischer PDF-Anhang der Allgemeinen Geschäftsbedingungen
|
||||||
Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden:
|
|
||||||
- **Benutzername**: Wert aus `ADMIN_USERNAME`
|
### Sicherheit
|
||||||
- **Passwort**: Das ursprüngliche Passwort (nicht der Hash)
|
- 🛡️ **Rate-Limiting**: IP- und E-Mail-basierter Schutz gegen Spam (3 Anfragen/E-Mail pro Stunde, 5 Anfragen/IP pro 10 Min)
|
||||||
|
- ✉️ **E-Mail-Validierung**: Mehrschichtige Validierung inkl. Disposable-Email-Detection und MX-Record-Prüfung
|
||||||
## Sicherheit
|
- 🚫 **Wegwerf-Email-Schutz**: Blockierung von temporären E-Mail-Adressen
|
||||||
|
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
|
||||||
⚠️ **Wichtige Hinweise:**
|
- 🛡️ **Security.txt**: RFC 9116 konformer Endpoint für Sicherheitsmeldungen
|
||||||
- Ändere das Standard-Passwort vor dem Produktionseinsatz
|
|
||||||
- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert
|
### Rechtliches
|
||||||
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash
|
- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten
|
||||||
- Die `.env` Datei sollte niemals in das Repository committet werden
|
- ⚖️ **GDPR-konform**: Datenschutzfreundliche Implementierung
|
||||||
|
|
||||||
### Security.txt Endpoint
|
## Admin-Zugang
|
||||||
|
|
||||||
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
|
Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden:
|
||||||
|
- **Benutzername**: Wert aus `ADMIN_USERNAME`
|
||||||
- **Kontakt**: Konfigurierbar über `SECURITY_CONTACT` Umgebungsvariable
|
- **Passwort**: Das ursprüngliche Passwort (nicht der Hash)
|
||||||
- **Ablauf**: Automatisch gesetzt auf Ende des aktuellen Jahres
|
|
||||||
- **Sprachen**: Deutsch und Englisch bevorzugt
|
## Sicherheit
|
||||||
- **Caching**: 24 Stunden Cache-Header für bessere Performance
|
|
||||||
|
⚠️ **Wichtige Hinweise:**
|
||||||
**Beispiel-Konfiguration:**
|
- Ändere das Standard-Passwort vor dem Produktionseinsatz
|
||||||
```env
|
- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert
|
||||||
SECURITY_CONTACT=security@stargirlnails.de
|
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash
|
||||||
```
|
- Die `.env` Datei sollte niemals in das Repository committet werden
|
||||||
|
|
||||||
**Zugriff:**
|
### Security.txt Endpoint
|
||||||
```bash
|
|
||||||
curl https://your-domain.com/.well-known/security.txt
|
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
|
||||||
```
|
|
||||||
|
- **Kontakt**: Konfigurierbar über `SECURITY_CONTACT` Umgebungsvariable
|
||||||
Dies ermöglicht Sicherheitsforschern, Sicherheitslücken verantwortungsvoll zu melden.
|
- **Ablauf**: Automatisch gesetzt auf Ende des aktuellen Jahres
|
||||||
|
- **Sprachen**: Deutsch und Englisch bevorzugt
|
||||||
|
- **Caching**: 24 Stunden Cache-Header für bessere Performance
|
||||||
|
|
||||||
|
**Beispiel-Konfiguration:**
|
||||||
|
```env
|
||||||
|
SECURITY_CONTACT=security@stargirlnails.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugriff:**
|
||||||
|
```bash
|
||||||
|
curl https://your-domain.com/.well-known/security.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Dies ermöglicht Sicherheitsforschern, Sicherheitslücken verantwortungsvoll zu melden.
|
||||||
|
@@ -1,15 +1,14 @@
|
|||||||
## Backlog – Terminplanung & Infrastruktur
|
## Backlog – Terminplanung & Infrastruktur
|
||||||
|
|
||||||
### Kalender & Workflow
|
### Kalender & Workflow
|
||||||
- ICS-Anhang/Link in E‑Mails (Kalendereintrag)
|
- ~~ICS-Anhang/Link in E‑Mails (Kalendereintrag)~~
|
||||||
- Erinnerungsmails (24h/3h vor Termin)
|
- Erinnerungsmails (24h/3h vor Termin)
|
||||||
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
|
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
|
||||||
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar
|
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar
|
||||||
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
|
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
|
||||||
|
|
||||||
### Sicherheit & Qualität
|
### Sicherheit & Qualität
|
||||||
- Rate‑Limiting (IP/E‑Mail) für Formularspam
|
- ~~Rate‑Limiting (IP/E‑Mail) für Formularspam~~
|
||||||
- CAPTCHA im Buchungsformular
|
|
||||||
- E‑Mail‑Verifizierung (Double‑Opt‑In) optional
|
- E‑Mail‑Verifizierung (Double‑Opt‑In) optional
|
||||||
- Audit‑Log (wer/was/wann)
|
- Audit‑Log (wer/was/wann)
|
||||||
- DSGVO: Einwilligungstexte, Löschkonzept
|
- DSGVO: Einwilligungstexte, Löschkonzept
|
||||||
|
93
docs/rate-limiting.md
Normal file
93
docs/rate-limiting.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Rate Limiting & E-Mail-Validierung
|
||||||
|
|
||||||
|
Das System verwendet ein mehrstufiges Sicherheitssystem, um Spam und Missbrauch des Buchungsformulars zu verhindern.
|
||||||
|
|
||||||
|
## E-Mail-Validierung
|
||||||
|
|
||||||
|
Neben der Zod-Schema-Validierung wird jede E-Mail-Adresse durch die [Rapid Email Validator API](https://rapid-email-verifier.fly.dev/) geprüft:
|
||||||
|
|
||||||
|
### Geprüfte Kriterien
|
||||||
|
- ✅ **Syntax-Validierung:** Korrekte E-Mail-Format-Prüfung
|
||||||
|
- ✅ **Disposable Email Detection:** Wegwerf-E-Mail-Adressen werden blockiert
|
||||||
|
- ✅ **MX Record Verification:** Prüft, ob die Domain E-Mails empfangen kann
|
||||||
|
- ✅ **Domain Verification:** Validiert die Existenz der Domain
|
||||||
|
- ✅ **Typo-Erkennung:** Schlägt Korrekturen bei häufigen Tippfehlern vor
|
||||||
|
|
||||||
|
### Datenschutz
|
||||||
|
Die verwendete API speichert **keine Daten** und ist GDPR/CCPA/PIPEDA-konform. Alle E-Mail-Adressen werden nur im Speicher verarbeitet und sofort verworfen.
|
||||||
|
|
||||||
|
### Fallback-Verhalten
|
||||||
|
Falls die Validierungs-API nicht erreichbar ist, fällt das System auf die Zod-Validierung zurück, um die Funktionalität zu gewährleisten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformulars zu verhindern.
|
||||||
|
|
||||||
|
## Aktuelle Konfiguration
|
||||||
|
|
||||||
|
### Buchungen (E-Mail-basiert)
|
||||||
|
- **Limit:** 3 Buchungsanfragen pro E-Mail-Adresse
|
||||||
|
- **Zeitfenster:** 1 Stunde
|
||||||
|
- **Verhalten:** Nach 3 Anfragen muss der Nutzer 1 Stunde warten
|
||||||
|
|
||||||
|
### Buchungen (IP-basiert)
|
||||||
|
- **Limit:** 5 Buchungsanfragen pro IP-Adresse
|
||||||
|
- **Zeitfenster:** 10 Minuten
|
||||||
|
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
|
||||||
|
|
||||||
|
## Wie es funktioniert
|
||||||
|
|
||||||
|
Das Rate-Limiting prüft **beide** Kriterien:
|
||||||
|
1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt
|
||||||
|
2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt
|
||||||
|
|
||||||
|
Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
|
||||||
|
|
||||||
|
## IP-Erkennung
|
||||||
|
|
||||||
|
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers:
|
||||||
|
- `x-forwarded-for`
|
||||||
|
- `x-real-ip`
|
||||||
|
- `cf-connecting-ip` (Cloudflare)
|
||||||
|
|
||||||
|
## Implementierung
|
||||||
|
|
||||||
|
- **Speicherung:** In-Memory (Map)
|
||||||
|
- **Cleanup:** Automatisches Aufräumen alter Einträge alle 10 Minuten
|
||||||
|
- **Skalierung:** Für Produktionsumgebungen mit mehreren Server-Instanzen sollte Redis o.ä. verwendet werden
|
||||||
|
|
||||||
|
## Anpassung
|
||||||
|
|
||||||
|
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// E-Mail-Limit anpassen
|
||||||
|
const emailConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 3, // Anzahl der Anfragen
|
||||||
|
windowMs: 60 * 60 * 1000, // Zeitfenster in Millisekunden
|
||||||
|
};
|
||||||
|
|
||||||
|
// IP-Limit anpassen
|
||||||
|
const ipConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 5, // Anzahl der Anfragen
|
||||||
|
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fehlermeldungen
|
||||||
|
|
||||||
|
Bei Überschreitung des Limits erhält der Nutzer folgende Meldung:
|
||||||
|
```
|
||||||
|
Zu viele Buchungsanfragen. Bitte versuche es in X Minuten erneut.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Produktions-Empfehlungen
|
||||||
|
|
||||||
|
Für Produktionsumgebungen empfehlen sich:
|
||||||
|
- ✅ Redis als verteilter Speicher für Rate-Limit-Daten
|
||||||
|
- ✅ Überwachung der Rate-Limit-Trigger (Logging/Monitoring)
|
||||||
|
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
|
||||||
|
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten
|
||||||
|
|
114
src/server/lib/email-validator.ts
Normal file
114
src/server/lib/email-validator.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Email validation using Rapid Email Validator API
|
||||||
|
// API: https://rapid-email-verifier.fly.dev/
|
||||||
|
// Privacy-focused, no data storage, completely free
|
||||||
|
|
||||||
|
type EmailValidationResult = {
|
||||||
|
valid: boolean;
|
||||||
|
email: string;
|
||||||
|
domain?: string;
|
||||||
|
disposable?: boolean;
|
||||||
|
role?: boolean;
|
||||||
|
typo?: boolean;
|
||||||
|
suggestion?: string;
|
||||||
|
mx?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email address using Rapid Email Validator API
|
||||||
|
* Returns true if email is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function validateEmail(email: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Call Rapid Email Validator API
|
||||||
|
const response = await fetch(`https://rapid-email-verifier.fly.dev/verify/${encodeURIComponent(email)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Email validation API error: ${response.status}`);
|
||||||
|
// If API is down, allow the email (fallback to Zod validation only)
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: EmailValidationResult = await response.json();
|
||||||
|
|
||||||
|
// Check if email is disposable/temporary
|
||||||
|
if (data.disposable) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Temporäre oder Wegwerf-E-Mail-Adressen sind nicht erlaubt. Bitte verwende eine echte E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MX records exist (deliverable)
|
||||||
|
if (data.mx === false) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'Diese E-Mail-Adresse kann keine E-Mails empfangen. Bitte überprüfe deine E-Mail-Adresse.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is generally valid
|
||||||
|
if (!data.valid) {
|
||||||
|
const suggestion = data.suggestion ? ` Meintest du vielleicht: ${data.suggestion}?` : '';
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Ungültige E-Mail-Adresse.${suggestion}`,
|
||||||
|
suggestion: data.suggestion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email is valid
|
||||||
|
return { valid: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email validation error:', error);
|
||||||
|
// If validation fails, allow the email (fallback to Zod validation only)
|
||||||
|
// This ensures the booking system continues to work even if the API is down
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch validate multiple emails
|
||||||
|
* @param emails Array of email addresses to validate
|
||||||
|
* @returns Array of validation results
|
||||||
|
*/
|
||||||
|
export async function validateEmailBatch(emails: string[]): Promise<Map<string, {
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}>> {
|
||||||
|
const results = new Map<string, { valid: boolean; reason?: string; suggestion?: string }>();
|
||||||
|
|
||||||
|
// Validate up to 100 emails at once (API limit)
|
||||||
|
const batchSize = 100;
|
||||||
|
for (let i = 0; i < emails.length; i += batchSize) {
|
||||||
|
const batch = emails.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
// Call each validation in parallel for better performance
|
||||||
|
const validations = await Promise.all(
|
||||||
|
batch.map(async (email) => {
|
||||||
|
const result = await validateEmail(email);
|
||||||
|
return { email, result };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store results
|
||||||
|
validations.forEach(({ email, result }) => {
|
||||||
|
results.set(email, result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@@ -20,6 +20,90 @@ import { dirname, resolve } from "node:path";
|
|||||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
|
||||||
|
|
||||||
|
// Helper function to format dates for ICS files (YYYYMMDDTHHMMSS)
|
||||||
|
function formatDateForICS(date: string, time: string): string {
|
||||||
|
// date is in YYYY-MM-DD format, time is in HH:MM format
|
||||||
|
const [year, month, day] = date.split('-');
|
||||||
|
const [hours, minutes] = time.split(':');
|
||||||
|
return `${year}${month}${day}T${hours}${minutes}00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create ICS (iCalendar) file content
|
||||||
|
function createICSFile(params: {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
time: string; // HH:MM
|
||||||
|
durationMinutes: number;
|
||||||
|
customerName: string;
|
||||||
|
treatmentName: string;
|
||||||
|
}): string {
|
||||||
|
const { date, time, durationMinutes, customerName, treatmentName } = params;
|
||||||
|
|
||||||
|
// Calculate start and end times in Europe/Berlin timezone
|
||||||
|
const dtStart = formatDateForICS(date, time);
|
||||||
|
|
||||||
|
// Calculate end time
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const startDate = new Date(`${date}T${time}:00`);
|
||||||
|
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||||
|
const endHours = String(endDate.getHours()).padStart(2, '0');
|
||||||
|
const endMinutes = String(endDate.getMinutes()).padStart(2, '0');
|
||||||
|
const dtEnd = formatDateForICS(date, `${endHours}:${endMinutes}`);
|
||||||
|
|
||||||
|
// Create unique ID for this event
|
||||||
|
const uid = `booking-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@stargirlnails.de`;
|
||||||
|
|
||||||
|
// Current timestamp for DTSTAMP
|
||||||
|
const now = new Date();
|
||||||
|
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
|
||||||
|
// ICS content
|
||||||
|
const icsContent = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Stargirlnails Kiel//Booking System//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:REQUEST',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${uid}`,
|
||||||
|
`DTSTAMP:${dtstamp}`,
|
||||||
|
`DTSTART;TZID=Europe/Berlin:${dtStart}`,
|
||||||
|
`DTEND;TZID=Europe/Berlin:${dtEnd}`,
|
||||||
|
`SUMMARY:${treatmentName} - Stargirlnails Kiel`,
|
||||||
|
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
|
||||||
|
'LOCATION:Stargirlnails Kiel',
|
||||||
|
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
|
||||||
|
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
|
||||||
|
'STATUS:CONFIRMED',
|
||||||
|
'SEQUENCE:0',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'TRIGGER:-PT24H',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Erinnerung: Termin morgen bei Stargirlnails Kiel',
|
||||||
|
'END:VALARM',
|
||||||
|
'END:VEVENT',
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/Berlin',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'TZOFFSETFROM:+0100',
|
||||||
|
'TZOFFSETTO:+0200',
|
||||||
|
'TZNAME:CEST',
|
||||||
|
'DTSTART:19700329T020000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'TZOFFSETFROM:+0200',
|
||||||
|
'TZOFFSETTO:+0100',
|
||||||
|
'TZNAME:CET',
|
||||||
|
'DTSTART:19701025T030000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
|
'END:VCALENDAR'
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
return icsContent;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache for AGB PDF to avoid reading it multiple times
|
// Cache for AGB PDF to avoid reading it multiple times
|
||||||
let cachedAGBPDF: string | null = null;
|
let cachedAGBPDF: string | null = null;
|
||||||
|
|
||||||
@@ -89,6 +173,42 @@ export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ succe
|
|||||||
return sendEmail(params);
|
return sendEmail(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendEmailWithAGBAndCalendar(
|
||||||
|
params: SendEmailParams,
|
||||||
|
calendarParams: {
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
customerName: string;
|
||||||
|
treatmentName: string;
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const agbBase64 = await getAGBPDFBase64();
|
||||||
|
|
||||||
|
// Create ICS file content
|
||||||
|
const icsContent = createICSFile(calendarParams);
|
||||||
|
const icsBase64 = Buffer.from(icsContent, 'utf-8').toString('base64');
|
||||||
|
|
||||||
|
// Attach both AGB and ICS file
|
||||||
|
params.attachments = [...(params.attachments || [])];
|
||||||
|
|
||||||
|
if (agbBase64) {
|
||||||
|
params.attachments.push({
|
||||||
|
filename: "AGB_Stargirlnails_Kiel.pdf",
|
||||||
|
content: agbBase64,
|
||||||
|
type: "application/pdf"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
params.attachments.push({
|
||||||
|
filename: "Termin_Stargirlnails.ics",
|
||||||
|
content: icsBase64,
|
||||||
|
type: "text/calendar"
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendEmail(params);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmailWithInspirationPhoto(
|
export async function sendEmailWithInspirationPhoto(
|
||||||
params: SendEmailParams,
|
params: SendEmailParams,
|
||||||
photoData: string,
|
photoData: string,
|
||||||
|
162
src/server/lib/rate-limiter.ts
Normal file
162
src/server/lib/rate-limiter.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// Simple in-memory rate limiter for IP and email-based requests
|
||||||
|
// For production with multiple instances, consider using Redis
|
||||||
|
|
||||||
|
type RateLimitEntry = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number; // Unix timestamp in ms
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Cleanup old entries every 10 minutes to prevent memory leaks
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of rateLimitStore.entries()) {
|
||||||
|
if (entry.resetAt < now) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
|
||||||
|
export type RateLimitConfig = {
|
||||||
|
maxRequests: number;
|
||||||
|
windowMs: number; // Time window in milliseconds
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: number;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request is allowed based on rate limiting
|
||||||
|
* @param key - Unique identifier (IP, email, or combination)
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns RateLimitResult with allow status and metadata
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
config: RateLimitConfig
|
||||||
|
): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
// No existing entry or window expired - allow and create new entry
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
rateLimitStore.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing entry within window
|
||||||
|
if (entry.count >= config.maxRequests) {
|
||||||
|
// Rate limit exceeded
|
||||||
|
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
retryAfterSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count and allow
|
||||||
|
entry.count++;
|
||||||
|
rateLimitStore.set(key, entry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - entry.count,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit for booking creation
|
||||||
|
* Applies multiple checks: per IP and per email
|
||||||
|
*/
|
||||||
|
export function checkBookingRateLimit(params: {
|
||||||
|
ip?: string;
|
||||||
|
email: string;
|
||||||
|
}): RateLimitResult {
|
||||||
|
const { ip, email } = params;
|
||||||
|
|
||||||
|
// Config: max 3 bookings per email per hour
|
||||||
|
const emailConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 3,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config: max 5 bookings per IP per 10 minutes
|
||||||
|
const ipConfig: RateLimitConfig = {
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMs: 10 * 60 * 1000, // 10 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check email rate limit
|
||||||
|
const emailKey = `booking:email:${email.toLowerCase()}`;
|
||||||
|
const emailResult = checkRateLimit(emailKey, emailConfig);
|
||||||
|
|
||||||
|
if (!emailResult.allowed) {
|
||||||
|
return {
|
||||||
|
...emailResult,
|
||||||
|
allowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP rate limit (if IP is available)
|
||||||
|
if (ip) {
|
||||||
|
const ipKey = `booking:ip:${ip}`;
|
||||||
|
const ipResult = checkRateLimit(ipKey, ipConfig);
|
||||||
|
|
||||||
|
if (!ipResult.allowed) {
|
||||||
|
return {
|
||||||
|
...ipResult,
|
||||||
|
allowed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both checks passed
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
|
||||||
|
resetAt: emailResult.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP from various headers (for proxy/load balancer support)
|
||||||
|
*/
|
||||||
|
export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
|
||||||
|
// Check common proxy headers
|
||||||
|
const forwardedFor = headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// x-forwarded-for can contain multiple IPs, take the first one
|
||||||
|
return forwardedFor.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIP = headers['x-real-ip'];
|
||||||
|
if (realIP) {
|
||||||
|
return realIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||||
|
if (cfConnectingIP) {
|
||||||
|
return cfConnectingIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No IP found
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@@ -9,8 +9,8 @@ config();
|
|||||||
|
|
||||||
const UserSchema = z.object({
|
const UserSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
username: z.string(),
|
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"),
|
||||||
email: z.string(),
|
email: z.string().email("Ungültige E-Mail-Adresse"),
|
||||||
passwordHash: z.string(),
|
passwordHash: z.string(),
|
||||||
role: z.enum(["customer", "owner"]),
|
role: z.enum(["customer", "owner"]),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
@@ -3,11 +3,13 @@ import { z } from "zod";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "@/server/lib/create-kv";
|
import { createKV } from "@/server/lib/create-kv";
|
||||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||||
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
||||||
import { router } from "@/server/rpc";
|
import { router } from "@/server/rpc";
|
||||||
import { createORPCClient } from "@orpc/client";
|
import { createORPCClient } from "@orpc/client";
|
||||||
import { RPCLink } from "@orpc/client/fetch";
|
import { RPCLink } from "@orpc/client/fetch";
|
||||||
|
import { checkBookingRateLimit, getClientIP } from "@/server/lib/rate-limiter";
|
||||||
|
import { validateEmail } from "@/server/lib/email-validator";
|
||||||
|
|
||||||
// Create a server-side client to call other RPC endpoints
|
// Create a server-side client to call other RPC endpoints
|
||||||
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
|
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
|
||||||
@@ -29,9 +31,9 @@ function generateUrl(path: string = ''): string {
|
|||||||
const BookingSchema = z.object({
|
const BookingSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
treatmentId: z.string(),
|
treatmentId: z.string(),
|
||||||
customerName: z.string(),
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||||
customerEmail: z.string(),
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||||
customerPhone: z.string(),
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"),
|
||||||
appointmentDate: z.string(), // ISO date string
|
appointmentDate: z.string(), // ISO date string
|
||||||
appointmentTime: z.string(), // HH:MM format
|
appointmentTime: z.string(), // HH:MM format
|
||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||||
@@ -70,13 +72,44 @@ const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
|||||||
|
|
||||||
const create = os
|
const create = os
|
||||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
// console.log("Booking create called with input:", {
|
// console.log("Booking create called with input:", {
|
||||||
// ...input,
|
// ...input,
|
||||||
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
||||||
// });
|
// });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Rate limiting check
|
||||||
|
const headers = context.request?.headers || {};
|
||||||
|
const headersObj: Record<string, string | undefined> = {};
|
||||||
|
if (headers) {
|
||||||
|
// Convert Headers object to plain object
|
||||||
|
headers.forEach((value: string, key: string) => {
|
||||||
|
headersObj[key.toLowerCase()] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const clientIP = getClientIP(headersObj);
|
||||||
|
|
||||||
|
const rateLimitResult = checkBookingRateLimit({
|
||||||
|
ip: clientIP,
|
||||||
|
email: input.customerEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
const retryMinutes = rateLimitResult.retryAfterSeconds
|
||||||
|
? Math.ceil(rateLimitResult.retryAfterSeconds / 60)
|
||||||
|
: 10;
|
||||||
|
throw new Error(
|
||||||
|
`Zu viele Buchungsanfragen. Bitte versuche es in ${retryMinutes} Minute${retryMinutes > 1 ? 'n' : ''} erneut.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep email validation using Rapid Email Validator API
|
||||||
|
const emailValidation = await validateEmail(input.customerEmail);
|
||||||
|
if (!emailValidation.valid) {
|
||||||
|
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate that the booking is not in the past
|
// Validate that the booking is not in the past
|
||||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
if (input.appointmentDate < today) {
|
if (input.appointmentDate < today) {
|
||||||
@@ -273,12 +306,24 @@ const updateStatus = os
|
|||||||
cancellationUrl
|
cancellationUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmailWithAGB({
|
// Get treatment information for ICS file
|
||||||
|
const allTreatments = await treatmentsKV.getAllItems();
|
||||||
|
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
|
||||||
|
const treatmentName = treatment?.name || "Behandlung";
|
||||||
|
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
|
||||||
|
|
||||||
|
await sendEmailWithAGBAndCalendar({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
|
}, {
|
||||||
|
date: booking.appointmentDate,
|
||||||
|
time: booking.appointmentTime,
|
||||||
|
durationMinutes: treatmentDuration,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
treatmentName: treatmentName
|
||||||
});
|
});
|
||||||
} else if (input.status === "cancelled") {
|
} else if (input.status === "cancelled") {
|
||||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
|
Reference in New Issue
Block a user