From 8ee2a2b3b6bbc11e30762a58865df3b8eb592023 Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 1 Oct 2025 11:43:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ICS-Kalendereintr=C3=A4ge,=20Rate-Limit?= =?UTF-8?q?ing=20und=20erweiterte=20E-Mail-Validierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 464 +++++++++++++++--------------- docs/backlog.md | 5 +- docs/rate-limiting.md | 93 ++++++ src/server/lib/email-validator.ts | 114 ++++++++ src/server/lib/email.ts | 120 ++++++++ src/server/lib/rate-limiter.ts | 162 +++++++++++ src/server/rpc/auth.ts | 4 +- src/server/rpc/bookings.ts | 57 +++- 8 files changed, 783 insertions(+), 236 deletions(-) create mode 100644 docs/rate-limiting.md create mode 100644 src/server/lib/email-validator.ts create mode 100644 src/server/lib/rate-limiter.ts diff --git a/README.md b/README.md index 0b1ca9c..e4a0341 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,239 @@ -# Stargirlnails Kiel - Nail Salon Booking System - -Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender und E-Mail-Benachrichtigungen. - -## Dependencies - -- [TypeScript](https://www.typescriptlang.org/) -- [React](https://react.dev/) -- [Vite](https://vite.dev/) -- [Tailwind CSS V4](https://tailwindcss.com/) -- [oRPC](https://orpc.unnoq.com/) -- [Hono](https://hono.dev/) -- [Zod](https://zod.dev/) - -## Setup - -### 1. Umgebungsvariablen konfigurieren - -Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvariablen: - -```bash -cp .env.example .env -``` - -### 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: - -#### PowerShell (Windows) -```powershell -# Einfache Methode mit Base64-Encoding -$password = "dein_sicheres_passwort" -$bytes = [System.Text.Encoding]::UTF8.GetBytes($password) -$hash = [System.Convert]::ToBase64String($bytes) -Write-Host "Password Hash: $hash" - -# Alternative mit PowerShell 7+ (kürzer) -$password = "dein_sicheres_passwort" -[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password)) -``` - -#### Node.js (falls verfügbar) -```javascript -// In der Node.js Konsole oder als separates Script -const password = "dein_sicheres_passwort"; -const hash = Buffer.from(password).toString('base64'); -console.log("Password Hash:", hash); -``` - -#### Online-Tools (nur für Entwicklung) -- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/) - -### 3. .env Datei konfigurieren - -Bearbeite deine `.env` Datei und setze die generierten Werte: - -```env -# Admin Account Configuration -ADMIN_USERNAME=owner -ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash - -# Domain Configuration -DOMAIN=localhost:5173 # Für Produktion: deine-domain.de - -# Email Configuration -RESEND_API_KEY=your_resend_api_key_here -EMAIL_FROM=noreply@yourdomain.com -ADMIN_EMAIL=admin@yourdomain.com - -# Stornierungsfrist (in Stunden) -MIN_STORNO_TIMESPAN=24 - -# Legal Information (Impressum/Datenschutz) -COMPANY_NAME=Stargirlnails Kiel -OWNER_NAME=Inhaber Name -ADDRESS_STREET=Liebigstr. 15 -ADDRESS_CITY=Kiel -ADDRESS_POSTAL_CODE=24145 -ADDRESS_COUNTRY=Deutschland -ADDRESS_LATITUDE=54.3233 # Optional: GPS-Koordinaten für Karte -ADDRESS_LONGITUDE=10.1228 # Optional: GPS-Koordinaten für Karte -CONTACT_PHONE=+49 431 123456 -CONTACT_EMAIL=info@stargirlnails.de -TAX_ID=12/345/67890 # Optional -VAT_ID=DE123456789 # Optional -COMMERCIAL_REGISTER=HRB 12345 # Optional -RESPONSIBLE_FOR_CONTENT=Inhaber Name -DATA_PROTECTION_RESPONSIBLE=Inhaber Name -DATA_PROTECTION_EMAIL=datenschutz@stargirlnails.de -THIRD_PARTY_SERVICES=Resend (E-Mail-Versand),Google Analytics # Komma-getrennt -``` - -### 4. Anwendung starten - -```bash -# Dependencies installieren -pnpm install - -# Entwicklungsserver starten -pnpm dev -``` - -## Docker Deployment - -### Docker Build - -```bash -# Docker Image erstellen -docker build -t stargirlnails-booking . - -# Container starten -docker run -d \ - --name stargirlnails-app \ - -p 3000:3000 \ - --env-file .env \ - stargirlnails-booking -``` - -### Docker Compose (empfohlen) - -Erstelle eine `docker-compose.yml` Datei: - -```yaml -services: - stargirlnails: - build: . - ports: - - "3000:3000" - env_file: - - .env - restart: unless-stopped - volumes: - - ./.storage:/app/.storage - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s -``` - -Starten mit Docker Compose: - -```bash -# Container starten -docker-compose up -d - -# Logs anzeigen -docker-compose logs -f - -# Container stoppen -docker-compose down -``` - -### Produktions-Deployment - -Für den produktiven Einsatz: - -```bash -# Mit Docker Compose -docker-compose -f docker-compose.yml up -d - -# Oder direkt mit Docker -docker run -d \ - --name stargirlnails-prod \ - -p 80:3000 \ - --restart unless-stopped \ - --env-file .env.production \ - stargirlnails-booking -``` - -**Wichtige Produktions-Hinweise:** -- Verwende eine `.env.production` Datei mit Produktions-Konfiguration -- Setze `NODE_ENV=production` in der Umgebungsdatei -- Verwende einen Reverse Proxy (nginx, Traefik) für HTTPS -- Überwache Container mit Health Checks -- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten - -## Features - -- 📅 **Terminbuchung**: Kunden können online Termine buchen -- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten -- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine -- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern -- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen -- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren -- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN) -- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten -- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber -- 🛡️ **Security.txt**: RFC 9116 konformer Endpoint für Sicherheitsmeldungen - -## Admin-Zugang - -Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden: -- **Benutzername**: Wert aus `ADMIN_USERNAME` -- **Passwort**: Das ursprüngliche Passwort (nicht der Hash) - -## Sicherheit - -⚠️ **Wichtige Hinweise:** -- Ändere das Standard-Passwort vor dem Produktionseinsatz -- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert -- Verwende ein sicheres Passwort und generiere den entsprechenden Hash -- Die `.env` Datei sollte niemals in das Repository committet werden - -### Security.txt Endpoint - -Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`: - -- **Kontakt**: Konfigurierbar über `SECURITY_CONTACT` Umgebungsvariable -- **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. +# Stargirlnails Kiel - Nail Salon Booking System + +Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender und E-Mail-Benachrichtigungen. + +## Dependencies + +- [TypeScript](https://www.typescriptlang.org/) +- [React](https://react.dev/) +- [Vite](https://vite.dev/) +- [Tailwind CSS V4](https://tailwindcss.com/) +- [oRPC](https://orpc.unnoq.com/) +- [Hono](https://hono.dev/) +- [Zod](https://zod.dev/) + +## Setup + +### 1. Umgebungsvariablen konfigurieren + +Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvariablen: + +```bash +cp .env.example .env +``` + +### 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: + +#### PowerShell (Windows) +```powershell +# Einfache Methode mit Base64-Encoding +$password = "dein_sicheres_passwort" +$bytes = [System.Text.Encoding]::UTF8.GetBytes($password) +$hash = [System.Convert]::ToBase64String($bytes) +Write-Host "Password Hash: $hash" + +# Alternative mit PowerShell 7+ (kürzer) +$password = "dein_sicheres_passwort" +[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password)) +``` + +#### Node.js (falls verfügbar) +```javascript +// In der Node.js Konsole oder als separates Script +const password = "dein_sicheres_passwort"; +const hash = Buffer.from(password).toString('base64'); +console.log("Password Hash:", hash); +``` + +#### Online-Tools (nur für Entwicklung) +- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/) + +### 3. .env Datei konfigurieren + +Bearbeite deine `.env` Datei und setze die generierten Werte: + +```env +# Admin Account Configuration +ADMIN_USERNAME=owner +ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash + +# Domain Configuration +DOMAIN=localhost:5173 # Für Produktion: deine-domain.de + +# Email Configuration +RESEND_API_KEY=your_resend_api_key_here +EMAIL_FROM=noreply@yourdomain.com +ADMIN_EMAIL=admin@yourdomain.com + +# Stornierungsfrist (in Stunden) +MIN_STORNO_TIMESPAN=24 + +# Legal Information (Impressum/Datenschutz) +COMPANY_NAME=Stargirlnails Kiel +OWNER_NAME=Inhaber Name +ADDRESS_STREET=Liebigstr. 15 +ADDRESS_CITY=Kiel +ADDRESS_POSTAL_CODE=24145 +ADDRESS_COUNTRY=Deutschland +ADDRESS_LATITUDE=54.3233 # Optional: GPS-Koordinaten für Karte +ADDRESS_LONGITUDE=10.1228 # Optional: GPS-Koordinaten für Karte +CONTACT_PHONE=+49 431 123456 +CONTACT_EMAIL=info@stargirlnails.de +TAX_ID=12/345/67890 # Optional +VAT_ID=DE123456789 # Optional +COMMERCIAL_REGISTER=HRB 12345 # Optional +RESPONSIBLE_FOR_CONTENT=Inhaber Name +DATA_PROTECTION_RESPONSIBLE=Inhaber Name +DATA_PROTECTION_EMAIL=datenschutz@stargirlnails.de +THIRD_PARTY_SERVICES=Resend (E-Mail-Versand),Google Analytics # Komma-getrennt +``` + +### 4. Anwendung starten + +```bash +# Dependencies installieren +pnpm install + +# Entwicklungsserver starten +pnpm dev +``` + +## Docker Deployment + +### Docker Build + +```bash +# Docker Image erstellen +docker build -t stargirlnails-booking . + +# Container starten +docker run -d \ + --name stargirlnails-app \ + -p 3000:3000 \ + --env-file .env \ + stargirlnails-booking +``` + +### Docker Compose (empfohlen) + +Erstelle eine `docker-compose.yml` Datei: + +```yaml +services: + stargirlnails: + build: . + ports: + - "3000:3000" + env_file: + - .env + restart: unless-stopped + volumes: + - ./.storage:/app/.storage + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s +``` + +Starten mit Docker Compose: + +```bash +# Container starten +docker-compose up -d + +# Logs anzeigen +docker-compose logs -f + +# Container stoppen +docker-compose down +``` + +### Produktions-Deployment + +Für den produktiven Einsatz: + +```bash +# Mit Docker Compose +docker-compose -f docker-compose.yml up -d + +# Oder direkt mit Docker +docker run -d \ + --name stargirlnails-prod \ + -p 80:3000 \ + --restart unless-stopped \ + --env-file .env.production \ + stargirlnails-booking +``` + +**Wichtige Produktions-Hinweise:** +- Verwende eine `.env.production` Datei mit Produktions-Konfiguration +- Setze `NODE_ENV=production` in der Umgebungsdatei +- Verwende einen Reverse Proxy (nginx, Traefik) für HTTPS +- Überwache Container mit Health Checks +- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten + +## Features + +### Buchungssystem +- 📅 **Terminbuchung**: Kunden können online Termine buchen +- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten +- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine +- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern +- ❌ **Termin-Stornierung**: Kunden können Termine über sichere Links stornieren +- ⏰ **Stornierungsfrist**: Konfigurierbare Mindestfrist vor dem Termin (MIN_STORNO_TIMESPAN) + +### E-Mail & Benachrichtigungen +- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen +- 📅 **ICS-Kalendereinträge**: Termin-Bestätigungen mit ICS-Datei zum Importieren in Kalender-Apps +- ⏰ **Kalender-Erinnerungen**: 24h-Erinnerung im ICS-Kalendereintrag +- 📎 **AGB-Anhänge**: Automatischer PDF-Anhang der Allgemeinen Geschäftsbedingungen + +### Sicherheit +- 🛡️ **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 +- 🚫 **Wegwerf-Email-Schutz**: Blockierung von temporären E-Mail-Adressen +- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber +- 🛡️ **Security.txt**: RFC 9116 konformer Endpoint für Sicherheitsmeldungen + +### Rechtliches +- 📋 **Impressum/Datenschutz**: Rechtliche Seiten mit konfigurierbaren Daten +- ⚖️ **GDPR-konform**: Datenschutzfreundliche Implementierung + +## Admin-Zugang + +Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden: +- **Benutzername**: Wert aus `ADMIN_USERNAME` +- **Passwort**: Das ursprüngliche Passwort (nicht der Hash) + +## Sicherheit + +⚠️ **Wichtige Hinweise:** +- Ändere das Standard-Passwort vor dem Produktionseinsatz +- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert +- Verwende ein sicheres Passwort und generiere den entsprechenden Hash +- Die `.env` Datei sollte niemals in das Repository committet werden + +### Security.txt Endpoint + +Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`: + +- **Kontakt**: Konfigurierbar über `SECURITY_CONTACT` Umgebungsvariable +- **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. diff --git a/docs/backlog.md b/docs/backlog.md index 36e116a..55a4204 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -1,15 +1,14 @@ ## Backlog – Terminplanung & Infrastruktur ### Kalender & Workflow -- ICS-Anhang/Link in E‑Mails (Kalendereintrag) +- ~~ICS-Anhang/Link in E‑Mails (Kalendereintrag)~~ - Erinnerungsmails (24h/3h vor Termin) - ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~ - Pufferzeiten und Sperrtage/Feiertage konfigurierbar - ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~ ### Sicherheit & Qualität -- Rate‑Limiting (IP/E‑Mail) für Formularspam -- CAPTCHA im Buchungsformular +- ~~Rate‑Limiting (IP/E‑Mail) für Formularspam~~ - E‑Mail‑Verifizierung (Double‑Opt‑In) optional - Audit‑Log (wer/was/wann) - DSGVO: Einwilligungstexte, Löschkonzept diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md new file mode 100644 index 0000000..47cfe04 --- /dev/null +++ b/docs/rate-limiting.md @@ -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 + diff --git a/src/server/lib/email-validator.ts b/src/server/lib/email-validator.ts new file mode 100644 index 0000000..30196cb --- /dev/null +++ b/src/server/lib/email-validator.ts @@ -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> { + const results = new Map(); + + // 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; +} + + diff --git a/src/server/lib/email.ts b/src/server/lib/email.ts index 45f9061..bedd7a7 100644 --- a/src/server/lib/email.ts +++ b/src/server/lib/email.ts @@ -20,6 +20,90 @@ import { dirname, resolve } from "node:path"; const RESEND_API_KEY = process.env.RESEND_API_KEY; const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails "; +// 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 let cachedAGBPDF: string | null = null; @@ -89,6 +173,42 @@ export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ succe 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( params: SendEmailParams, photoData: string, diff --git a/src/server/lib/rate-limiter.ts b/src/server/lib/rate-limiter.ts new file mode 100644 index 0000000..11717ef --- /dev/null +++ b/src/server/lib/rate-limiter.ts @@ -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(); + +// 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 | 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; +} + + diff --git a/src/server/rpc/auth.ts b/src/server/rpc/auth.ts index ca416ed..3c37ae8 100644 --- a/src/server/rpc/auth.ts +++ b/src/server/rpc/auth.ts @@ -9,8 +9,8 @@ config(); const UserSchema = z.object({ id: z.string(), - username: z.string(), - email: z.string(), + username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"), + email: z.string().email("Ungültige E-Mail-Adresse"), passwordHash: z.string(), role: z.enum(["customer", "owner"]), createdAt: z.string(), diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts index b2925a9..fe6745c 100644 --- a/src/server/rpc/bookings.ts +++ b/src/server/rpc/bookings.ts @@ -3,11 +3,13 @@ import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } 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 { router } from "@/server/rpc"; import { createORPCClient } from "@orpc/client"; 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 const link = new RPCLink({ url: "http://localhost:5173/rpc" }); @@ -29,9 +31,9 @@ function generateUrl(path: string = ''): string { const BookingSchema = z.object({ id: z.string(), treatmentId: z.string(), - customerName: z.string(), - customerEmail: z.string(), - customerPhone: z.string(), + customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"), + customerEmail: z.string().email("Ungültige E-Mail-Adresse"), + customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"), appointmentDate: z.string(), // ISO date string appointmentTime: z.string(), // HH:MM format status: z.enum(["pending", "confirmed", "cancelled", "completed"]), @@ -70,13 +72,44 @@ const treatmentsKV = createTreatmentsKV("treatments"); const create = os .input(BookingSchema.omit({ id: true, createdAt: true, status: true })) - .handler(async ({ input }) => { + .handler(async ({ input, context }) => { // console.log("Booking create called with input:", { // ...input, // inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null // }); try { + // Rate limiting check + const headers = context.request?.headers || {}; + const headersObj: Record = {}; + 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 const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD if (input.appointmentDate < today) { @@ -273,12 +306,24 @@ const updateStatus = os 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, 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`, html, 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") { const formattedDate = formatDateGerman(booking.appointmentDate);