15 Commits

Author SHA1 Message Date
ca20516080 v0.1.3 2025-10-07 14:15:17 +02:00
f2963ca951 Android PWA-Installationshinweis mit direktem Install-Button hinzugefügt 2025-10-07 13:57:28 +02:00
8aea5bb400 Verbesserungen für PWA-Installations-Prompt: Mobile-Menü-Überlappung behoben, iOS safe-area Unterstützung, localStorage-Fehlerbehandlung und erweiterte standalone-Erkennung 2025-10-07 13:41:03 +02:00
14d0c2f9c3 Add PWA manifest and apple-touch-icon meta tag 2025-10-07 13:12:31 +02:00
eb9ddc535f Add static serving for /icons/* and manifest.json 2025-10-07 12:58:36 +02:00
8fa17f58c9 Add PWA icons (192x192, 512x512, apple-touch-icon) 2025-10-07 12:46:17 +02:00
92ed7a2c93 feat: Firmenname aus .env in E-Mail-Titeln anzeigen
- COMPANY_NAME wird aus .env gelesen
- Wird in separater Zeile über dem eigentlichen Titel angezeigt
- Format: Firmenname (grau, kleiner) + Titel (pink, größer)
- Fallback auf 'Stargirlnails Kiel' wenn nicht gesetzt
2025-10-07 10:50:50 +02:00
ce644c31e1 feat: Offizielle Social-Media-Icons in E-Mails
- Ersetze Emojis (📷 🎵) durch offizielle SVG-Icons
- Gleiche Icons wie auf der Startseite (Instagram & TikTok)
- Inline SVG für bessere Darstellung in E-Mail-Clients
- Icons sind 16x16px mit margin-right für besseren Abstand
2025-10-07 10:44:30 +02:00
3b67c26216 feat: Altersbestätigung (16+) im Buchungsformular
- Neue Pflicht-Checkbox: Mindestalter 16 Jahre
- Direkt unter der AGB-Checkbox platziert
- Validierung beim Absenden des Formulars
- Wird nach erfolgreicher Buchung zurückgesetzt
2025-10-07 10:19:43 +02:00
f2fed22ea1 fix: AGB.pdf Download funktioniert jetzt
- Route für /AGB.pdf hinzugefügt (serveStatic)
- AGB.pdf wird aus dem public-Verzeichnis bereitgestellt
- Behebt Problem, dass Link zur Startseite führte
2025-10-07 10:17:59 +02:00
ab5e5e67a6 feat: Login-Formular merkt sich Benutzername
- Benutzername wird in localStorage gespeichert
- Beim nächsten Login automatisch ausgefüllt
- Verbessert UX für wiederkehrende Logins
2025-10-07 10:14:15 +02:00
78a379546c feat: Buchungsformular merkt sich Benutzerdaten
- Name, E-Mail und Telefonnummer werden in localStorage gespeichert
- Beim nächsten Besuch werden die Felder automatisch ausgefüllt
- Verbessert die User Experience für wiederkehrende Buchungen
2025-10-07 10:13:06 +02:00
953a970220 fix: Caddy-Timeouts für Live-Queries deaktiviert
- read_timeout und write_timeout auf 0 gesetzt für unbegrenzte SSE-Verbindungen
- flush_interval -1 für sofortiges Flushen von Streaming-Daten
- Behebt 'context canceled' Fehler bei /rpc/recurringAvailability/live/listRules
2025-10-07 09:47:26 +02:00
c7d9fc689e style: Button 'Termin buchen' dunkler für besseren Kontrast (#790dc6) 2025-10-07 09:40:59 +02:00
f4593cd706 feat: Social-Media-Badges für TikTok und Instagram hinzugefügt
- Neue RPC-Route für Social-Media-URLs aus .env (social.ts)
- Social-Media-Badges auf der Startseite mit attraktiven Buttons
- Social-Media-Icons im Footer aller Seiten
- Social-Media-Links in allen E-Mail-Templates
- URLs aus .env: TIKTOK_PROFILE und INSTAGRAM_PROFILE
2025-10-07 09:32:06 +02:00
50 changed files with 931 additions and 2584 deletions

View File

@@ -13,6 +13,7 @@ pnpm-debug.log*
.env.production.local
# Build outputs
dist
build
.next
out

View File

@@ -1,7 +1,6 @@
# Admin Account Configuration
ADMIN_USERNAME=owner
ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy # bcrypt hashed password
# Legacy Base64 hashes are automatically migrated to bcrypt on server start/first login
ADMIN_PASSWORD_HASH=YWRtaW4xMjM= # Base64 encoded password
# Domain Configuration
DOMAIN=localhost:5173 # For production: your-domain.com
@@ -11,7 +10,9 @@ RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM=noreply@yourdomain.com
ADMIN_EMAIL=admin@yourdomain.com
# Frontend URL (for E-Mail Links)
# Social media profiles
TIKTOK_PROFILE=https://www.tiktok.com/@<dein Tiktok Profil>
INSTAGRAM_PROFILE=https://www.instagram.com/<dein Instragram Profil>
# Cancellation Policy (in hours)
MIN_STORNO_TIMESPAN=24

View File

@@ -8,6 +8,16 @@ stargirlnails.de {
health_uri /health
health_interval 30s
health_timeout 5s
# Timeouts für lange laufende Verbindungen (Live-Queries)
transport http {
read_timeout 0
write_timeout 0
dial_timeout 30s
}
# Buffer-Flush für Server-Sent Events (SSE) aktivieren
flush_interval -1
}
# Sicherheits-Header

View File

@@ -1,26 +1,29 @@
FROM node:22-alpine AS builder
# Multi-stage build for production
FROM node:22-alpine AS base
# Install pnpm
RUN npm install -g pnpm
# Set working directory
WORKDIR /app
# Install all deps for building server
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy only server sources and tsconfig for server build
COPY src/server ./src/server
COPY tsconfig.server.json ./tsconfig.server.json
COPY tsconfig.server.build.json ./tsconfig.server.build.json
# Copy source code
COPY . .
# Build server only (no client build)
RUN pnpm tsc -p tsconfig.server.build.json
# Build the application
RUN pnpm build
# Production stage
FROM node:22-alpine AS production
# Install pnpm and required runtime tools
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec curl
# Install pnpm and su-exec
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec
# Set working directory
WORKDIR /app
@@ -31,13 +34,17 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install production dependencies only
RUN pnpm install --frozen-lockfile --prod
# Copy prebuilt application artifacts from repository (no TS build in image)
COPY dist ./dist
# Use freshly built server from builder stage
COPY --from=builder /app/server-dist ./server-dist
COPY public ./public
# Copy built application from base stage
COPY --from=base /app/dist ./dist
COPY --from=base /app/server-dist ./server-dist
COPY --from=base /app/public ./public
# Copy necessary runtime files
# Copy necessary files for runtime
COPY --from=base /app/src/server/index.ts ./src/server/index.ts
COPY --from=base /app/src/server/routes ./src/server/routes
COPY --from=base /app/src/server/rpc ./src/server/rpc
COPY --from=base /app/src/server/lib ./src/server/lib
COPY --from=base /app/tsconfig.server.json ./tsconfig.server.json
COPY start.sh ./start.sh
# Create non-root user for security

View File

@@ -12,8 +12,6 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un
- [Hono](https://hono.dev/)
- [Zod](https://zod.dev/)
> Hinweis zu DOMPurify: Wir nutzen `isomorphic-dompurify`, das DOMPurify bereits mitliefert und sowohl in Node.js als auch im Browser funktioniert. Eine zusätzliche Installation von `dompurify` ist daher nicht erforderlich und würde eine redundante Abhängigkeit erzeugen.
## Setup
### 1. Umgebungsvariablen konfigurieren
@@ -24,28 +22,33 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
cp .env.example .env
```
### 2. Admin-Passwort Hash generieren (bcrypt)
### 2. Admin-Passwort Hash generieren
Das Admin-Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert. So erzeugst du einen Hash:
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
#### Node.js (empfohlen)
```bash
node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)"
#### 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))
```
Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst):
#### Node.js (falls verfügbar)
```javascript
// scripts/generate-hash.js
require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => {
console.log(h);
});
// 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);
```
Ausführen:
```bash
node scripts/generate-hash.js "dein_sicheres_passwort"
```
#### Online-Tools (nur für Entwicklung)
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
### 3. .env Datei konfigurieren
@@ -54,8 +57,7 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
```env
# Admin Account Configuration
ADMIN_USERNAME=owner
# bcrypt-Hash des Admin-Passworts (kein Base64). Beispielwert:
ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
# Domain Configuration
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
@@ -207,12 +209,10 @@ Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credent
⚠️ **Wichtige Hinweise:**
- Ändere das Standard-Passwort vor dem Produktionseinsatz
- Das Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert
- 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
Hinweis zur Migration: Vorhandene Base64-Hashes aus älteren Versionen werden beim Server-Start automatisch in bcrypt migriert. Zusätzlich erfolgt beim nächsten erfolgreichen Login ebenfalls eine Migration, falls noch erforderlich.
### Security.txt Endpoint
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:

View File

@@ -17,7 +17,7 @@ services:
networks:
- stargirlnails-network
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -36,7 +36,6 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
- caddy-logs:/var/log/caddy
networks:
- stargirlnails-network
depends_on:
@@ -50,8 +49,6 @@ volumes:
driver: local
caddy-config:
driver: local
caddy-logs:
driver: local
# Netzwerk für interne Kommunikation
networks:

View File

@@ -1,121 +0,0 @@
# CalDAV-Kalender Einrichtung
## Übersicht
Die App bietet einen CalDAV-Endpunkt, um Buchungen in externe Kalender-Apps zu synchronisieren. Aus Sicherheitsgründen erfolgt die Authentifizierung per Header. Unterstützt werden:
- Authorization: Bearer <TOKEN>
- Basic Auth, wobei der Token als Benutzername übergeben wird (Passwort leer/optional)
## Token generieren
1. Als Admin einloggen
2. Im Admin-Bereich den CalDAV-Token generieren
3. Token und URL werden angezeigt
**Wichtig:** Der Token ist 24 Stunden gültig. Danach muss ein neuer Token generiert werden.
## Endpunkt
```
GET /caldav/calendar/events.ics
Authorization: Bearer <TOKEN>
oder
Authorization: Basic <base64(token:)> # Token als Benutzername, Passwort leer
```
## Unterstützte Kalender-Apps
### ✅ Thunderbird (empfohlen)
1. Kalender → Neuer Kalender → Im Netzwerk
2. Format: CalDAV
3. Standort: CalDAV-URL eingeben
4. Benutzername: Den generierten Token eingeben (Basic Auth)
5. Passwort: Leer lassen
### ✅ Outlook (mit Einschränkungen)
1. Datei → Kontoeinstellungen → Internetkalender
2. URL eingeben
3. Erweiterte Einstellungen → Benutzerdefinierte Header:
```
Authorization: Bearer <TOKEN>
```
**Hinweis:** Nicht alle Outlook-Versionen unterstützen benutzerdefinierte Header.
### ⚠️ Apple Calendar (eingeschränkt)
Apple Calendar unterstützt keine Authorization-Header für Kalenderabonnements.
**Alternativen:**
- Manuelle ICS-Datei importieren (nicht automatisch aktualisiert)
- CalDAV-Bridge verwenden (z.B. über Proxy)
### ⚠️ Google Calendar (eingeschränkt)
Google Calendar unterstützt keine Authorization-Header für URL-Abonnements.
**Alternativen:**
- Google Apps Script als Bridge verwenden
- Manuelle ICS-Datei importieren
## Testen mit cURL
```bash
# Bearer
curl -H "Authorization: Bearer <DEIN_TOKEN>" \
https://deine-domain.de/caldav/calendar/events.ics
# Basic (Token als Benutzername, Passwort leer)
curl -H "Authorization: Basic $(printf "%s" "<DEIN_TOKEN>:" | base64)" \
https://deine-domain.de/caldav/calendar/events.ics
```
Erwartete Antwort: ICS-Datei mit allen bestätigten und ausstehenden Buchungen.
## Sicherheit
### Warum Authorization-Header?
- **Query-Parameter** (`?token=...`) werden in Browser-History, Server-Logs und Referrer-Headers gespeichert
- **Authorization-Header** werden nicht geloggt und nicht in der URL sichtbar
- Folgt REST-API Best Practices
### Token-Verwaltung
- Token sind 24 Stunden gültig
- Abgelaufene Token werden automatisch beim nächsten Zugriff gelöscht
- Bei Bedarf neuen Token generieren (alte Token werden nicht automatisch invalidiert)
### Backward Compatibility
Die alte Methode mit Query-Parameter (`?token=...`) wird noch unterstützt, aber als **deprecated** markiert. Der Server sendet zusätzlich Response-Header (`Deprecation: true` und `Warning: 299 ...`). Eine Warnung wird im Server-Log ausgegeben.
## Troubleshooting
### "Unauthorized - Token required"
- Prüfe, ob der Authorization-Header korrekt gesetzt ist
- Format: `Authorization: Bearer <TOKEN>` (mit Leerzeichen nach "Bearer")
### "Unauthorized - Invalid or expired token"
- Token ist abgelaufen (24h Gültigkeit)
- Generiere einen neuen Token im Admin-Bereich
### Kalender zeigt keine Termine
- Prüfe, ob Buchungen mit Status "confirmed" oder "pending" existieren
- Teste den Endpunkt mit cURL
- Prüfe Server-Logs auf Fehler
## Zukünftige Verbesserungen
- [ ] Langlebige Token mit Refresh-Mechanismus
- [ ] Token-Revocation-Endpoint
- [ ] CalDAV-Bridge für Apple Calendar und Google Calendar
- [ ] Webhook-basierte Push-Notifications statt Polling

View File

@@ -37,38 +37,17 @@ Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformu
- **Zeitfenster:** 10 Minuten
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
### Login (IP-basiert)
- **Limit:** 5 fehlgeschlagene Login-Versuche pro IP-Adresse
- **Zeitfenster:** 15 Minuten
- **Verhalten:** Das Limit zählt nur fehlgeschlagene Versuche. Nach erfolgreichem Login wird der Zähler zurückgesetzt. Bei Überschreitung muss der Nutzer 15 Minuten warten.
- **Zweck:** Schutz vor Brute-Force-Angriffen auf Admin-Accounts
### Admin-Operationen (Benutzer-basiert)
- **Limit:** 30 Anfragen pro Admin-Benutzer
- **Zeitfenster:** 5 Minuten
- **Verhalten:** Nach 30 Anfragen muss der Admin 5 Minuten warten
- **Zweck:** Verhindert versehentliches Spam durch Admin-UI (z.B. Doppelklicks, fehlerhafte Skripte)
- **Betroffene Endpoints:** Treatments (create/update/remove), Bookings (manualCreate/updateStatus/remove), Recurring Availability (alle Schreiboperationen), Gallery (alle Schreiboperationen), Reviews (approve/reject/delete)
### Admin-Operationen (IP-basiert)
- **Limit:** 50 Anfragen pro IP-Adresse
- **Zeitfenster:** 5 Minuten
- **Verhalten:** Nach 50 Anfragen muss 5 Minuten gewartet werden
- **Zweck:** Zusätzlicher Schutz gegen IP-basierte Angriffe auf Admin-Endpoints
## Wie es funktioniert
Das Rate-Limiting prüft die passenden Kriterien je Endpoint. Für Admin-Operationen werden **beide** Limits geprüft:
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
3. **Benutzer-basiert (Admin):** Limitiert Anfragen je Admin-Benutzer
4. **IP-basiert (Admin):** Limitiert Anfragen je IP zusätzlich
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 (unterstützt `Headers`-API und einfache Record-Objekte):
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)
@@ -81,7 +60,7 @@ Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch fo
## Anpassung
Die Limits können in `src/server/lib/rate-limiter.ts` angepasst werden. Beispiele:
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
```typescript
// E-Mail-Limit anpassen
@@ -95,23 +74,6 @@ const ipConfig: RateLimitConfig = {
maxRequests: 5, // Anzahl der Anfragen
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
};
// Login-Bruteforce-Schutz
const loginConfig: RateLimitConfig = {
maxRequests: 5,
windowMs: 15 * 60 * 1000,
};
// Admin-Operationen
const adminUserConfig: RateLimitConfig = {
maxRequests: 30,
windowMs: 5 * 60 * 1000,
};
const adminIpConfig: RateLimitConfig = {
maxRequests: 50,
windowMs: 5 * 60 * 1000,
};
```
## Fehlermeldungen
@@ -129,5 +91,3 @@ Für Produktionsumgebungen empfehlen sich:
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten
Siehe auch `docs/redis-migration.md` für Hinweise zur Migration auf Redis in Multi-Instance-Setups.

View File

@@ -1,355 +0,0 @@
# Redis Migration Guide
## Overview
This guide covers migrating from in-memory KV storage to Redis for multi-instance SaaS deployments. The current in-memory session storage works well for single-instance deployments but requires centralized storage for horizontal scaling.
### When to Migrate
**Current Setup (Sufficient For):**
- Single-instance deployments
- Development environments
- Small-scale production deployments
**Redis Required For:**
- Multiple server instances behind load balancer
- Container orchestration (Kubernetes, Docker Swarm)
- High-availability SaaS deployments
- Session persistence across server restarts
## What Needs Migration
### Critical Data (Must Migrate)
- **Sessions** (`sessionsKV`): Essential for authentication across instances
- **CSRF tokens**: Stored within session objects
- **Rate limiting data**: Currently in-memory Map in `rate-limiter.ts`
### Optional Data (Can Remain File-based)
- Bookings, treatments, reviews, gallery photos
- Can stay in file-based KV for now
- Migrate later if performance becomes an issue
## Redis Setup
### Installation Options
#### Self-hosted Redis
```bash
# Docker
docker run -d -p 6379:6379 redis:alpine
# Ubuntu/Debian
sudo apt update
sudo apt install redis-server
# macOS (Homebrew)
brew install redis
brew services start redis
```
#### Managed Services
- **Redis Cloud**: Managed Redis service
- **AWS ElastiCache**: Redis-compatible cache
- **Azure Cache for Redis**: Microsoft's managed service
- **DigitalOcean Managed Databases**: Redis option available
### Configuration
Add to `.env`:
```bash
# Redis Configuration (required for multi-instance deployments)
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Set to true for production
REDIS_PASSWORD=your_redis_password # Optional
```
## Code Changes
### 1. Install Redis Client
```bash
pnpm add ioredis
pnpm add -D @types/ioredis
```
### 2. Create Redis KV Adapter
Create `src/server/lib/create-redis-kv.ts`:
```typescript
import Redis from 'ioredis';
interface RedisKVStore<T> {
getItem(key: string): Promise<T | null>;
setItem(key: string, value: T): Promise<void>;
removeItem(key: string): Promise<void>;
getAllItems(): Promise<T[]>;
subscribe(): AsyncIterable<void>;
}
export function createRedisKV<T>(namespace: string): RedisKVStore<T> {
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
retryDelayOnFailover: 100,
enableReadyCheck: false,
maxRetriesPerRequest: null,
lazyConnect: true,
});
const prefix = `${namespace}:`;
return {
async getItem(key: string): Promise<T | null> {
const value = await redis.get(`${prefix}${key}`);
return value ? JSON.parse(value) : null;
},
async setItem(key: string, value: T): Promise<void> {
await redis.set(`${prefix}${key}`, JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
await redis.del(`${prefix}${key}`);
},
async getAllItems(): Promise<T[]> {
const keys = await redis.keys(`${prefix}*`);
if (keys.length === 0) return [];
const values = await redis.mget(...keys);
return values
.filter(v => v !== null)
.map(v => JSON.parse(v!));
},
async* subscribe(): AsyncIterable<void> {
const pubsub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
await pubsub.subscribe(`${namespace}:changes`);
for await (const message of pubsub) {
if (message[0] === 'message' && message[1] === `${namespace}:changes`) {
yield;
}
}
}
};
}
// Helper to notify subscribers of changes
export async function notifyRedisChanges(namespace: string): Promise<void> {
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
await redis.publish(`${namespace}:changes`, 'update');
await redis.quit();
}
```
### 3. Update Session Storage
In `src/server/lib/auth.ts`:
```typescript
import { createRedisKV } from './create-redis-kv.js';
// Replace this line:
// export const sessionsKV = createKV<Session>("sessions");
// With this:
export const sessionsKV = createRedisKV<Session>("sessions");
```
### 4. Update Rate Limiter
In `src/server/lib/rate-limiter.ts`:
```typescript
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
retryDelayOnFailover: 100,
enableReadyCheck: false,
maxRetriesPerRequest: null,
lazyConnect: true,
});
export async function checkBookingRateLimit(
email?: string,
ip?: string
): Promise<{ allowed: boolean; resetTime?: number }> {
const now = Date.now();
const windowMs = 15 * 60 * 1000; // 15 minutes
const maxRequests = 3;
const results = await Promise.all([
email ? checkRateLimit(`booking:email:${email}`, maxRequests, windowMs) : { allowed: true },
ip ? checkRateLimit(`booking:ip:${ip}`, maxRequests, windowMs) : { allowed: true }
]);
return {
allowed: results.every(r => r.allowed),
resetTime: results.find(r => !r.allowed)?.resetTime
};
}
async function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number
): Promise<{ allowed: boolean; resetTime?: number }> {
const now = Date.now();
const windowStart = now - windowMs;
// Use Redis sorted set for sliding window
const pipeline = redis.pipeline();
// Remove old entries
pipeline.zremrangebyscore(key, 0, windowStart);
// Count current entries
pipeline.zcard(key);
// Add current request
pipeline.zadd(key, now, `${now}-${Math.random()}`);
// Set expiry
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const currentCount = results?.[1]?.[1] as number || 0;
return {
allowed: currentCount < maxRequests,
resetTime: currentCount >= maxRequests ? windowStart + windowMs : undefined
};
}
```
### 5. Environment Configuration
Update `.env.example`:
```bash
# Redis Configuration (optional - required for multi-instance SaaS deployments)
# For single-instance deployments, the default in-memory storage is sufficient
# See docs/redis-migration.md for migration guide
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Enable for production
REDIS_PASSWORD=your_redis_password # Optional
```
## Migration Strategy
### Development Testing
1. **Start Redis locally**:
```bash
docker run -d -p 6379:6379 redis:alpine
```
2. **Test session functionality**:
- Login/logout
- Session validation
- CSRF token generation/validation
- Session rotation
3. **Test rate limiting**:
- Verify rate limit enforcement
- Check sliding window behavior
4. **Simulate multi-instance**:
- Run multiple server instances
- Verify session sharing works
### Staging Deployment
1. **Deploy Redis in staging**
2. **Run single instance first** to verify functionality
3. **Scale to multiple instances** and test session sharing
4. **Monitor Redis memory usage** and connection pool
### Production Rollout
#### Option A: Blue/Green Deployment
1. Deploy new version with Redis to green environment
2. Test thoroughly with production-like data
3. Switch traffic to green environment
4. **Note**: Existing sessions will be lost (users need to re-login)
#### Option B: Gradual Migration
1. Deploy Redis alongside existing system
2. Write to both stores temporarily (dual-write)
3. Read from Redis first, fallback to in-memory
4. After verification period, remove in-memory store
## Monitoring & Maintenance
### Key Metrics
- **Redis memory usage**: Monitor `used_memory` and `used_memory_peak`
- **Connection pool**: Track active connections and pool utilization
- **Session operations**: Monitor session creation/validation latency
- **Rate limit hits**: Track rate limit enforcement effectiveness
### Backup Strategy
- **Enable Redis persistence**: Configure RDB snapshots or AOF logging
- **Regular backups**: Backup Redis data regularly
- **Session data**: Ephemeral, but rate limit data should be backed up
### Scaling Considerations
- **Redis Cluster**: For horizontal scaling across multiple nodes
- **Redis Sentinel**: For high availability with automatic failover
- **Connection pooling**: Configure appropriate pool sizes
## Rollback Plan
If issues arise, you can revert to in-memory storage:
1. **Remove Redis imports** and revert to `createKV`
2. **Remove Redis environment variables**
3. **Redeploy** with in-memory storage
4. **Note**: All sessions will be lost (users need to re-login)
## Cost Considerations
### Redis Hosting Costs
- **Self-hosted**: Server costs + maintenance
- **Managed services**:
- Redis Cloud: ~$7/month for 30MB
- AWS ElastiCache: ~$15/month for t3.micro
- Azure Cache: ~$16/month for Basic tier
### Memory Requirements
**Session storage**: Minimal
- Example: 1000 concurrent users × 1KB per session = ~1MB
- CSRF tokens add minimal overhead
**Rate limiting**: Negligible
- Sliding window data is automatically cleaned up
- Minimal memory footprint per IP/email
## Alternative Solutions
### Sticky Sessions
- Load balancer routes user to same instance
- **Pros**: Simpler implementation
- **Cons**: Less resilient, harder to scale
### Database-backed Sessions
- Use PostgreSQL/MySQL instead of Redis
- **Pros**: No additional infrastructure
- **Cons**: Higher latency, more database load
### JWT Tokens
- Stateless authentication tokens
- **Pros**: No server-side session storage needed
- **Cons**: No server-side session invalidation, different security model
## References
- [Session Management Guide](session-management.md)
- [Rate Limiting Documentation](rate-limiting.md)
- [Redis Documentation](https://redis.io/documentation)
- [ioredis Library](https://github.com/luin/ioredis)
- [Redis Best Practices](https://redis.io/docs/manual/admin/)

View File

@@ -1,251 +0,0 @@
# Session Management & CSRF Protection
## Overview
This application uses **HttpOnly cookie-based session management** with CSRF protection to provide secure authentication while protecting against common web vulnerabilities like XSS and CSRF attacks.
### Security Benefits
- **XSS Protection**: SessionId stored in HttpOnly cookies is not accessible to malicious JavaScript
- **CSRF Protection**: Double-submit cookie pattern prevents cross-site request forgery
- **Session Rotation**: New sessions created after login and password changes prevent session fixation
- **GDPR Compliance**: HttpOnly cookies provide better privacy protection than localStorage
## Architecture
### Session Storage
Sessions are stored in an in-memory KV store with the following structure:
```typescript
type Session = {
id: string;
userId: string;
expiresAt: string;
createdAt: string;
csrfToken?: string;
}
```
- **Expiration**: 24 hours
- **Storage**: In-memory KV store (single-instance deployment)
- **CSRF Token**: Cryptographically secure 64-character hex string
### Cookie Configuration
#### Session Cookie (`sessionId`)
- **Type**: HttpOnly, Secure (production), SameSite=Lax
- **Path**: `/`
- **MaxAge**: 86400 seconds (24 hours)
- **Purpose**: Authenticates user across requests
#### CSRF Cookie (`csrf-token`)
- **Type**: Non-HttpOnly, Secure (production), SameSite=Lax
- **Path**: `/`
- **MaxAge**: 86400 seconds (24 hours)
- **Purpose**: Provides CSRF token for JavaScript to include in requests
### CSRF Protection
The application uses the **double-submit cookie pattern**:
1. **Server-side**: CSRF token generated and stored in session
2. **Client-side**: Same token stored in non-HttpOnly cookie
3. **Validation**: Token from `X-CSRF-Token` header must match session token
4. **Timing-safe comparison**: Prevents timing attacks
### Session Rotation
Sessions are automatically rotated (new session created, old invalidated) after:
- Successful login
- Password changes
This prevents session fixation attacks.
## Implementation Details
### Server-side
#### Cookie Parsing Middleware (`src/server/routes/rpc.ts`)
```typescript
// Cookie parsing middleware - extracts sessionId from cookies
rpcApp.use("/*", async (c, next) => {
try {
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
c.set('sessionId', sessionId || null);
await next();
} catch (error) {
console.error("Cookie parsing error:", error);
c.set('sessionId', null);
await next();
}
});
```
#### Authentication Helper (`src/server/lib/auth.ts`)
Key functions:
- `generateCSRFToken()`: Creates cryptographically secure token
- `getSessionFromCookies(c)`: Extracts and validates session from cookies
- `validateCSRFToken(c, sessionId)`: Validates CSRF token from header
- `assertOwner(c)`: Validates owner role with session and CSRF checks
- `rotateSession(oldSessionId, userId)`: Creates new session, invalidates old
#### RPC Handler Updates
All admin-only RPC handlers now:
- Accept Hono `context` parameter
- Use `assertOwner(context)` for authentication
- Remove `sessionId` from input schemas
- Automatically get session from cookies
### Client-side
#### RPC Client Configuration (`src/client/rpc-client.ts`)
```typescript
const link = new RPCLink({
url: `${window.location.origin}/rpc`,
headers: () => {
const csrfToken = getCSRFToken();
return csrfToken ? { 'X-CSRF-Token': csrfToken } : {};
},
fetch: (request, init) => {
return fetch(request, {
...init,
credentials: 'include' // Include cookies with all requests
});
}
});
```
#### AuthProvider Updates (`src/client/components/auth-provider.tsx`)
- Removed all localStorage usage
- Sessions managed entirely server-side
- No client-side sessionId storage
## Security Features
### XSS Protection
- SessionId not accessible to JavaScript (HttpOnly cookie)
- Malicious scripts cannot steal session tokens
### CSRF Protection
- Token validation on all state-changing operations (non-GET requests)
- Double-submit cookie pattern prevents CSRF attacks
- Timing-safe comparison prevents timing attacks
### Session Fixation Prevention
- Session rotation after authentication events
- Old sessions invalidated when new ones created
### Secure Defaults
- Secure flag enabled in production (requires HTTPS)
- SameSite=Lax prevents most CSRF attacks
- HttpOnly cookies prevent XSS token theft
## Development vs Production
### Development
- `secure: false` - allows cookies over HTTP (localhost)
- `NODE_ENV !== 'production'` detection
### Production
- `secure: true` - requires HTTPS
- All security flags enabled
## API Reference
### Key Functions (`src/server/lib/auth.ts`)
#### `generateCSRFToken(): string`
Creates a cryptographically secure random token using `crypto.randomBytes(32).toString('hex')`.
#### `getSessionFromCookies(c: Context): Promise<Session | null>`
Extracts sessionId from cookies, validates session exists and hasn't expired.
#### `validateCSRFToken(c: Context, sessionId: string): Promise<void>`
Validates CSRF token from `X-CSRF-Token` header against session token using timing-safe comparison.
#### `assertOwner(c: Context): Promise<void>`
Validates user has owner role and session is valid. Automatically validates CSRF token for non-GET requests.
#### `rotateSession(oldSessionId: string, userId: string): Promise<Session>`
Creates new session with new ID and CSRF token, deletes old session.
## Migration Guide
### Existing Password Hashes
- Base64 password hashes automatically migrated to bcrypt on server startup
- No manual intervention required
### Session Invalidation
- Old localStorage sessions will be invalidated
- Users need to re-login once after deployment
### Testing Migration
1. Deploy new version
2. Verify login creates cookies (check browser DevTools)
3. Test CSRF protection by manually calling API without token
4. Verify session rotation after password change
## Troubleshooting
### Cookies Not Being Sent
- Check `credentials: 'include'` in fetch configuration
- Verify CORS settings allow credentials
- Check cookie domain/path configuration
### CSRF Validation Failing
- Ensure `X-CSRF-Token` header is set
- Verify CSRF cookie is accessible to JavaScript
- Check token format (64-character hex string)
### Session Expired Errors
- Check cookie expiration settings
- Verify server time synchronization
- Check session cleanup logic
### Cross-Origin Issues
- Review CORS configuration for credentials
- Ensure domain configuration matches deployment
- Check SameSite cookie settings
## Future Scaling
For multi-instance deployments, see [Redis Migration Guide](redis-migration.md) for migrating to centralized session storage.
## Environment Configuration
### Required Environment Variables
```bash
# Domain Configuration
DOMAIN=localhost:5173 # For production: your-domain.com
# Note: Session cookies are scoped to this domain
# Server Configuration
NODE_ENV=development # Set to 'production' for production deployment
PORT=3000
```
### Optional Environment Variables
```bash
# Redis Configuration (for multi-instance deployments)
# See docs/redis-migration.md for migration guide
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Set to true for production
REDIS_PASSWORD=your_redis_password # Optional
```
### Cookie Behavior by Environment
- **Development** (`NODE_ENV !== 'production'`):
- `secure: false` - cookies work over HTTP
- `sameSite: 'Lax'` - allows cross-site navigation
- **Production** (`NODE_ENV === 'production'`):
- `secure: true` - requires HTTPS
- `sameSite: 'Lax'` - prevents most CSRF attacks
## References
- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [MDN HttpOnly Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies)
- [RFC 6265 - HTTP State Management](https://tools.ietf.org/html/rfc6265)

View File

@@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ec4899" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
<title>Stargirlnails Kiel - Terminbuchung</title>
</head>
<body>

View File

@@ -1,7 +1,7 @@
{
"name": "quests-template-basic",
"private": true,
"version": "0.0.0",
"version": "0.1.3",
"type": "module",
"scripts": {
"check:types": "tsc --noEmit",
@@ -12,8 +12,6 @@
"preview": "vite preview"
},
"dependencies": {
"@types/bcrypt": "^5.0.2",
"bcrypt": "^5.1.1",
"@hono/node-server": "^1.19.5",
"@orpc/client": "^1.8.8",
"@orpc/server": "^1.8.8",
@@ -22,7 +20,6 @@
"@tanstack/react-query": "^5.85.5",
"dotenv": "^17.2.3",
"hono": "^4.9.4",
"isomorphic-dompurify": "^2.16.0",
"jsonrepair": "^3.13.0",
"openai": "^5.17.0",
"react": "^19.1.1",

791
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

45
public/icons/README.md Normal file
View File

@@ -0,0 +1,45 @@
# PWA Icons Required
This directory must contain the following icon files for proper PWA installation on iOS and Android devices:
## Required Icon Files
### Android Icons
- **icon-192x192.png** (192×192 pixels)
- Used for Android home screen and app drawer
- Should have transparent background or match theme color
- Include safe zone padding for maskable icons (40px margin)
- **icon-512x512.png** (512×512 pixels)
- Used for Android splash screens and high-resolution displays
- Should have transparent background or match theme color
- Include safe zone padding for maskable icons (102px margin)
### iOS Icon
- **apple-touch-icon.png** (180×180 pixels)
- Used for iOS home screen
- Should NOT have transparent background (iOS adds its own rounded corners)
- Fill entire canvas with brand colors/logo
- iOS automatically applies rounded corners and shadow
## Design Guidelines
1. **Brand consistency**: Use Stargirlnails logo and brand colors
2. **Theme color**: Primary pink (#ec4899) matches manifest theme_color
3. **Contrast**: Ensure icon is visible on various backgrounds
4. **Simplicity**: Icons should be recognizable at small sizes
5. **No text**: Avoid small text that becomes unreadable when scaled
## Testing
After adding icons:
- Test on Android: Check home screen icon appearance
- Test on iOS Safari: Add to home screen and verify icon quality
- Validate with Lighthouse PWA audit
## Placeholder
Until actual icons are created, you can use a favicon.png (if available) or generate placeholder icons using tools like:
- https://realfavicongenerator.net/
- https://www.pwabuilder.com/imageGenerator

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

32
public/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "Stargirlnails Kiel - Terminbuchung",
"short_name": "Stargirlnails",
"description": "Online Terminbuchung für Nagelstudio Stargirlnails in Kiel",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ec4899",
"orientation": "portrait",
"lang": "de",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

View File

@@ -45,20 +45,5 @@ for i in {1..18}; do
sleep 5
done
echo "[6b/7] Verify HTTP /health via Caddy (localhost)"
for i in {1..12}; do
if curl -fsS http://localhost/health >/dev/null 2>&1; then
echo "Caddy proxy responds OK on /health."
break
fi
sleep 5
done
echo "[7/7] Tail recent logs for app and caddy (press Ctrl+C to exit)"
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f stargirlnails &
APP_LOG_PID=$!
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f caddy &
CADDY_LOG_PID=$!
trap "echo 'Stopping log tails...'; kill $APP_LOG_PID $CADDY_LOG_PID 2>/dev/null || true" INT TERM
wait $APP_LOG_PID $CADDY_LOG_PID || true
echo "[7/7] Tail recent logs (press Ctrl+C to exit)"
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile";
@@ -14,11 +16,18 @@ import BookingStatusPage from "@/client/components/booking-status-page";
import ReviewSubmissionPage from "@/client/components/review-submission-page";
import LegalPage from "@/client/components/legal-page";
import { ProfileLanding } from "@/client/components/profile-landing";
import { PWAInstallPrompt } from "@/client/components/pwa-install-prompt";
function App() {
const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"profile-landing" | "booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "admin-gallery" | "admin-reviews" | "profile" | "legal">("profile-landing");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { data: socialMedia } = useQuery(
queryClient.social.getSocialMedia.queryOptions()
);
const hasSocialMedia = (socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile;
// Prevent background scroll when menu is open
useEffect(() => {
@@ -28,10 +37,15 @@ function App() {
// Handle booking status page
const path = window.location.pathname;
const PwaPrompt = <PWAInstallPrompt />;
if (path.startsWith('/booking/')) {
const token = path.split('/booking/')[1];
if (token) {
return <BookingStatusPage token={token} />;
return <>
{PwaPrompt}
<BookingStatusPage token={token} />
</>;
}
}
@@ -39,7 +53,10 @@ function App() {
if (path.startsWith('/review/')) {
const token = path.split('/review/')[1];
if (token) {
return <ReviewSubmissionPage token={token} />;
return <>
{PwaPrompt}
<ReviewSubmissionPage token={token} />
</>;
}
}
@@ -373,11 +390,44 @@ function App() {
)}
</main>
{/* PWA Installation Prompt for iOS */}
<PWAInstallPrompt hidden={isMobileMenuOpen} />
{/* Footer */}
<footer className="bg-white border-t border-pink-100 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600">
<p>&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p>
<p className="mb-4">&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p>
{hasSocialMedia && (
<div className="flex justify-center items-center gap-3 mt-4">
{(socialMedia as any)?.instagramProfile && (
<a
href={(socialMedia as any).instagramProfile}
target="_blank"
rel="noopener noreferrer"
className="text-pink-600 hover:text-pink-700 transition-colors"
aria-label="Instagram"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</a>
)}
{(socialMedia as any)?.tiktokProfile && (
<a
href={(socialMedia as any).tiktokProfile}
target="_blank"
rel="noopener noreferrer"
className="text-gray-800 hover:text-gray-900 transition-colors"
aria-label="TikTok"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
</a>
)}
</div>
)}
</div>
</div>
</footer>

View File

@@ -24,12 +24,12 @@ export function AdminAvailability() {
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
input: {}
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
const { data: timeOffPeriods } = useQuery(
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
input: {}
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
@@ -177,9 +177,14 @@ export function AdminAvailability() {
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createRule(
{ dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
{
onSuccess: () => {
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
@@ -228,8 +233,13 @@ export function AdminAvailability() {
<div className="flex items-center gap-2">
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
toggleRuleActive(
{ id: rule.id },
{ sessionId, id: rule.id },
{
onSuccess: () => {
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
@@ -246,8 +256,13 @@ export function AdminAvailability() {
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteRule(
{ id: rule.id },
{ sessionId, id: rule.id },
{
onSuccess: () => {
setSuccessMsg("Regel gelöscht.");
@@ -333,9 +348,14 @@ export function AdminAvailability() {
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createTimeOff(
{ startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
{
onSuccess: () => {
setSuccessMsg("Urlaubszeit hinzugefügt.");
@@ -395,8 +415,13 @@ export function AdminAvailability() {
<div className="flex items-center gap-2">
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteTimeOff(
{ id: period.id },
{ sessionId, id: period.id },
{
onSuccess: () => {
setSuccessMsg("Urlaubszeit gelöscht.");

View File

@@ -255,7 +255,7 @@ export function AdminBookings() {
{booking.status === "pending" && (
<>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Confirm
@@ -271,7 +271,7 @@ export function AdminBookings() {
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
@@ -286,7 +286,7 @@ export function AdminBookings() {
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Reactivate
@@ -352,7 +352,8 @@ export function AdminBookings() {
<div className="flex space-x-3">
<button
onClick={() => {
updateBookingStatus({ id: showCancelConfirm, status: "cancelled" });
const sessionId = localStorage.getItem("sessionId") || "";
updateBookingStatus({ sessionId, id: showCancelConfirm, status: "cancelled" });
}}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>

View File

@@ -164,18 +164,24 @@ export function AdminCalendar() {
};
const handleStatusUpdate = (bookingId: string, newStatus: string) => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
updateBookingStatus({
sessionId,
id: bookingId,
status: newStatus as "pending" | "confirmed" | "cancelled" | "completed"
});
};
const handleDeleteBooking = () => {
if (!showDeleteConfirm) return;
const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showDeleteConfirm) return;
if (deleteActionType === 'cancel') {
// For cancel action, use updateStatus instead of remove
updateBookingStatus({
sessionId,
id: showDeleteConfirm,
status: "cancelled"
}, {
@@ -191,6 +197,7 @@ export function AdminCalendar() {
} else {
// For delete action, use remove with email option
removeBooking({
sessionId,
id: showDeleteConfirm,
sendEmail: sendDeleteEmail,
}, {
@@ -209,8 +216,11 @@ export function AdminCalendar() {
const today = new Date().toISOString().split('T')[0];
const handleCreateBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
createManualBooking({
sessionId,
...createFormData
}, {
onSuccess: () => {
@@ -252,11 +262,13 @@ export function AdminCalendar() {
};
const handleRescheduleBooking = () => {
if (!showRescheduleModal) return;
const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showRescheduleModal) return;
const booking = bookings?.find(b => b.id === showRescheduleModal);
if (!booking) return;
proposeReschedule({
sessionId,
bookingId: booking.id,
proposedDate: rescheduleFormData.appointmentDate,
proposedTime: rescheduleFormData.appointmentTime,
@@ -273,8 +285,11 @@ export function AdminCalendar() {
};
const handleGenerateCalDAVToken = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
generateCalDAVToken({
sessionId
}, {
onSuccess: (data) => {
setCaldavData(data);

View File

@@ -14,7 +14,7 @@ export function AdminGallery() {
// Data fetching with live query
const { data: photos, refetch: refetchPhotos } = useQuery(
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
input: {}
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
@@ -166,6 +166,12 @@ export function AdminGallery() {
return;
}
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
setDraggedPhotoId(null);
return;
}
// Build a fully reordered list based on the current sorted order
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
@@ -185,6 +191,7 @@ export function AdminGallery() {
updatePhotoOrder(
{
sessionId,
photoOrders
},
{
@@ -297,9 +304,15 @@ export function AdminGallery() {
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
uploadPhoto(
{
sessionId,
base64Data: photoBase64,
title: photoTitle || undefined
},
@@ -383,8 +396,13 @@ export function AdminGallery() {
<button
onClick={() => {
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deletePhoto(
{ id: photo.id },
{ sessionId, id: photo.id },
{
onSuccess: () => {
setSuccessMsg("Foto gelöscht.");
@@ -407,8 +425,13 @@ export function AdminGallery() {
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
setCoverPhoto(
{ id: photo.id },
{ sessionId, id: photo.id },
{
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),

View File

@@ -91,17 +91,18 @@ export function AdminReviews() {
}
}, [successMsg]);
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
const { data: reviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: { statusFilter: activeStatusTab },
input: { sessionId, statusFilter: activeStatusTab },
})
);
// Separate queries for quick stats calculation
const { data: allReviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: {},
input: { sessionId },
})
);
@@ -265,13 +266,13 @@ export function AdminReviews() {
{review.status === "pending" && (
<>
<button
onClick={() => approveReview({ id: review.id })}
onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Genehmigen
</button>
<button
onClick={() => rejectReview({ id: review.id })}
onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Ablehnen
@@ -288,7 +289,7 @@ export function AdminReviews() {
{review.status === "approved" && (
<>
<button
onClick={() => rejectReview({ id: review.id })}
onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Ablehnen
@@ -305,7 +306,7 @@ export function AdminReviews() {
{review.status === "rejected" && (
<>
<button
onClick={() => approveReview({ id: review.id })}
onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
>
Genehmigen
@@ -333,7 +334,7 @@ export function AdminReviews() {
</p>
<div className="flex space-x-3">
<button
onClick={() => deleteReview({ id: showDeleteConfirm })}
onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>
Ja, löschen

View File

@@ -11,6 +11,7 @@ interface User {
interface AuthContextType {
user: User | null;
sessionId: string | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
@@ -33,6 +34,7 @@ interface AuthProviderProps {
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { mutateAsync: loginMutation } = useMutation(
@@ -48,45 +50,56 @@ export function AuthProvider({ children }: AuthProviderProps) {
);
useEffect(() => {
// Check for existing session on app load - session comes from cookies
verifySessionMutation({})
.then((result) => {
setUser(result.user);
})
.catch(() => {
// Session invalid or expired - user remains null
})
.finally(() => {
setIsLoading(false);
});
// Check for existing session on app load
const storedSessionId = localStorage.getItem("sessionId");
if (storedSessionId) {
verifySessionMutation(storedSessionId)
.then((result) => {
setUser(result.user);
setSessionId(storedSessionId);
})
.catch(() => {
localStorage.removeItem("sessionId");
})
.finally(() => {
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}, [verifySessionMutation]);
const login = async (username: string, password: string) => {
try {
const result = await loginMutation({ username, password });
setUser(result.user);
// Cookies are set automatically by the server
setSessionId(result.sessionId);
localStorage.setItem("sessionId", result.sessionId);
} catch (error) {
throw error;
}
};
const logout = async () => {
try {
await logoutMutation({});
// Cookies are cleared automatically by the server
} catch (error) {
// Continue with logout even if server call fails
console.error("Logout error:", error);
if (sessionId) {
try {
await logoutMutation(sessionId);
} catch (error) {
// Continue with logout even if server call fails
console.error("Logout error:", error);
}
}
setUser(null);
setSessionId(null);
localStorage.removeItem("sessionId");
};
const isOwner = user?.role === "owner";
const value: AuthContextType = {
user,
sessionId,
isLoading,
login,
logout,

View File

@@ -11,9 +11,52 @@ export function BookingForm() {
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [agbAccepted, setAgbAccepted] = useState(false);
const [ageConfirmed, setAgeConfirmed] = useState(false);
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
const [photoPreview, setPhotoPreview] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");
const [isInitialized, setIsInitialized] = useState(false);
// Load saved customer data from localStorage on mount
useEffect(() => {
const savedName = localStorage.getItem("bookingForm_customerName");
const savedEmail = localStorage.getItem("bookingForm_customerEmail");
const savedPhone = localStorage.getItem("bookingForm_customerPhone");
if (savedName) setCustomerName(savedName);
if (savedEmail) setCustomerEmail(savedEmail);
if (savedPhone) setCustomerPhone(savedPhone);
setIsInitialized(true);
}, []);
// Save customer data to localStorage when it changes (after initial load)
useEffect(() => {
if (!isInitialized) return;
if (customerName) {
localStorage.setItem("bookingForm_customerName", customerName);
} else {
localStorage.removeItem("bookingForm_customerName");
}
}, [customerName, isInitialized]);
useEffect(() => {
if (!isInitialized) return;
if (customerEmail) {
localStorage.setItem("bookingForm_customerEmail", customerEmail);
} else {
localStorage.removeItem("bookingForm_customerEmail");
}
}, [customerEmail, isInitialized]);
useEffect(() => {
if (!isInitialized) return;
if (customerPhone) {
localStorage.setItem("bookingForm_customerPhone", customerPhone);
} else {
localStorage.removeItem("bookingForm_customerPhone");
}
}, [customerPhone, isInitialized]);
const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
@@ -147,6 +190,10 @@ export function BookingForm() {
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
return;
}
if (!ageConfirmed) {
setErrorMessage("Bitte bestätige, dass du mindestens 16 Jahre alt bist.");
return;
}
// Email validation now handled in backend before booking creation
const appointmentTime = selectedTime;
@@ -181,6 +228,7 @@ export function BookingForm() {
setSelectedTime("");
setNotes("");
setAgbAccepted(false);
setAgeConfirmed(false);
setInspirationPhoto("");
setPhotoPreview("");
setErrorMessage("");
@@ -383,7 +431,7 @@ export function BookingForm() {
</div>
{/* AGB Acceptance */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-start space-x-3">
<input
type="checkbox"
@@ -409,6 +457,22 @@ export function BookingForm() {
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="age-confirmation"
checked={ageConfirmed}
onChange={(e) => setAgeConfirmed(e.target.checked)}
className="mt-1 h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded"
required
/>
<div className="flex-1">
<label htmlFor="age-confirmation" className="text-sm font-medium text-gray-700 cursor-pointer">
Ich bestätige, dass ich mindestens 16 Jahre alt bin *
</label>
</div>
</div>
</div>
{/* Error Message */}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useAuth } from "@/client/components/auth-provider";
export function LoginForm() {
@@ -6,9 +6,27 @@ export function LoginForm() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const { login } = useAuth();
// Load saved username from localStorage on mount
useEffect(() => {
const savedUsername = localStorage.getItem("loginForm_username");
if (savedUsername) setUsername(savedUsername);
setIsInitialized(true);
}, []);
// Save username to localStorage when it changes (after initial load)
useEffect(() => {
if (!isInitialized) return;
if (username) {
localStorage.setItem("loginForm_username", username);
} else {
localStorage.removeItem("loginForm_username");
}
}, [username, isInitialized]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");

View File

@@ -57,6 +57,10 @@ export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
queryClient.recurringAvailability.live.listRules.experimental_liveOptions()
);
const { data: socialMedia } = useQuery(
queryClient.social.getSocialMedia.queryOptions()
);
// Calculate next 7 days for opening hours
const getNext7Days = () => {
const days: Date[] = [];
@@ -84,12 +88,44 @@ export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
</p>
<button
onClick={onNavigateToBooking}
className="bg-pink-600 text-white py-4 px-8 rounded-lg hover:bg-pink-700 text-lg font-semibold shadow-lg transition-colors w-full md:w-auto"
className="bg-[#790dc6] text-white py-4 px-8 rounded-lg hover:bg-[#6609ad] text-lg font-semibold shadow-lg transition-colors w-full md:w-auto"
>
Termin buchen
</button>
</div>
{/* Social Media Badges */}
{((socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile) && (
<div className="flex justify-center items-center gap-4">
{(socialMedia as any)?.instagramProfile && (
<a
href={(socialMedia as any).instagramProfile}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
)}
{(socialMedia as any)?.tiktokProfile && (
<a
href={(socialMedia as any).tiktokProfile}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-black text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
)}
</div>
)}
{/* Featured Section: Erstes Foto (Reihenfolge 0) */}
{featuredPhoto && (
<div className="bg-white rounded-lg shadow-lg p-0 overflow-hidden">

View File

@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
declare global {
interface Navigator { standalone?: boolean }
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
const LAST_SHOWN_KEY = 'pwaInstallPrompt_lastShown';
const ANDROID_DISMISSED_KEY = 'pwaInstallPrompt_androidDismissed';
function isIOS(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
const iOS = /iPhone|iPad|iPod/i.test(ua);
const iPadOS13Plus = /Macintosh/.test(ua) && 'ontouchend' in document;
return iOS || iPadOS13Plus;
}
function isSafari(): boolean {
const ua = navigator.userAgent || '';
const isSafari = /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS/i.test(ua);
return isSafari;
}
function isStandalone(): boolean {
const navStandalone = (navigator as any)?.standalone === true;
const mm = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(display-mode: standalone)').matches
: false;
return Boolean(navStandalone || mm);
}
export function PWAInstallPrompt({ hidden = false }: { hidden?: boolean }) {
if (hidden) return null;
const [show, setShow] = useState(false);
const [promptType, setPromptType] = useState<'ios' | 'android' | null>(null);
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
// Only run on client
if (typeof window === 'undefined') return;
// Check if already in standalone mode
if (isStandalone()) {
setInitialized(true);
return;
}
// Handle iOS
const isIOSDevice = isIOS() && isSafari();
if (isIOSDevice) {
let lastShown = 0;
try {
lastShown = Number(localStorage.getItem(LAST_SHOWN_KEY) || 0);
} catch {}
const oneWeek = 7 * 24 * 60 * 60 * 1000;
const shouldShow = !lastShown || Date.now() - lastShown > oneWeek;
if (shouldShow) {
setPromptType('ios');
setShow(true);
}
setInitialized(true);
return;
}
// Handle Android (beforeinstallprompt)
const handleBeforeInstall = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
// Check if user has dismissed Android prompt before
let dismissed = false;
try {
dismissed = localStorage.getItem(ANDROID_DISMISSED_KEY) === 'true';
} catch {}
if (!dismissed) {
setDeferredPrompt(e);
setPromptType('android');
setShow(true);
}
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
setInitialized(true);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const dismiss = () => {
if (promptType === 'ios') {
try { localStorage.setItem(LAST_SHOWN_KEY, String(Date.now())); } catch {}
} else if (promptType === 'android') {
try { localStorage.setItem(ANDROID_DISMISSED_KEY, 'true'); } catch {}
}
setShow(false);
};
const handleAndroidInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const choiceResult = await deferredPrompt.userChoice;
if (choiceResult.outcome === 'accepted') {
console.log('PWA installation accepted');
}
setDeferredPrompt(null);
setShow(false);
} catch (error) {
console.error('Error during PWA installation:', error);
}
};
if (!initialized || !show || !promptType) return null;
return (
<div
role="dialog"
aria-label={promptType === 'android' ? 'PWA Installation' : 'PWA Installation Anleitung'}
className="fixed bottom-0 left-0 right-0 z-50 px-4 pb-4"
style={{ paddingBottom: `max(env(safe-area-inset-bottom), 1rem)` }}
>
<div className="relative max-w-3xl mx-auto rounded-xl shadow-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white p-4 sm:p-6">
<button aria-label="Hinweis schließen" onClick={dismiss} className="absolute top-2 right-2 text-white/90 hover:text-white text-2xl leading-none">×</button>
{promptType === 'android' ? (
// Android: Direct install button
<div className="flex items-center gap-4">
<div className="text-3xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-3">
Installiere Stargirlnails als App für schnellen Zugriff direkt vom Startbildschirm!
</p>
<div className="flex gap-2">
<button
onClick={handleAndroidInstall}
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-semibold hover:bg-pink-50 transition-colors"
>
Jetzt installieren
</button>
<button
onClick={dismiss}
className="bg-white/20 text-white px-4 py-2 rounded-lg font-semibold hover:bg-white/30 transition-colors"
>
Später
</button>
</div>
</div>
</div>
) : (
// iOS: Manual instructions
<div className="flex items-start gap-3">
<div className="text-2xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-2">So installierst du Stargirlnails als App auf deinem iPhone/iPad:</p>
<ol className="list-decimal pl-5 space-y-1 text-white/95">
<li>Öffne diese Seite in Safari (nicht Chrome oder andere Browser).</li>
<li>Tippe auf das Teilen-Symbol () unten in der Mitte.</li>
<li>Scrolle nach unten und wähle Zum Home-Bildschirm".</li>
<li>Tippe auf „Hinzufügen".</li>
</ol>
<p className="mt-3 text-sm text-white/90"> Schneller Zugriff, keine App-Store-Installation nötig, automatische Updates.</p>
</div>
</div>
)}
</div>
</div>
);
}
export default PWAInstallPrompt;

View File

@@ -4,7 +4,7 @@ import { useAuth } from "@/client/components/auth-provider";
import { queryClient } from "@/client/rpc-client";
export function UserProfile() {
const { user, logout } = useAuth();
const { user, sessionId, logout } = useAuth();
const [showPasswordChange, setShowPasswordChange] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -31,7 +31,13 @@ export function UserProfile() {
return;
}
if (!sessionId) {
setError("Keine gültige Sitzung");
return;
}
changePassword({
sessionId,
currentPassword,
newPassword,
}, {

View File

@@ -5,32 +5,8 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { router } from "@/server/rpc";
// Helper function to read CSRF token from cookie
function getCSRFToken(): string {
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
return cookieValue || '';
}
const link = new RPCLink({
url: `${window.location.origin}/rpc`,
headers: () => {
const csrfToken = getCSRFToken();
return csrfToken ? { 'X-CSRF-Token': csrfToken } : {};
},
fetch: (request, init) => {
return fetch(request, {
...init,
credentials: 'include' // Include cookies with all requests
});
}
});
const link = new RPCLink({ url: `${window.location.origin}/rpc` });
export const rpcClient: RouterClient<typeof router> = createORPCClient(link);
export const queryClient = createTanstackQueryUtils(rpcClient);
// Export helper for potential use in other parts of the client code
export { getCSRFToken };

View File

@@ -1,7 +1,6 @@
import { Hono } from "hono";
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { cors } from 'hono/cors';
import { rpcApp } from "./routes/rpc.js";
import { caldavApp } from "./routes/caldav.js";
@@ -9,58 +8,10 @@ import { clientEntry } from "./routes/client-entry.js";
const app = new Hono();
// CORS Configuration
const isDev = process.env.NODE_ENV === 'development';
const domain = process.env.DOMAIN || 'localhost:5173';
// Build allowed origins list
const allowedOrigins: string[] = [
`https://${domain}`,
isDev ? `http://${domain}` : null,
isDev ? 'http://localhost:5173' : null,
isDev ? 'http://localhost:3000' : null,
].filter((origin): origin is string => origin !== null);
app.use('*', cors({
origin: (origin) => {
// Allow requests with no origin (e.g., mobile apps, curl, Postman)
if (!origin) return null;
// Check if origin is in whitelist
if (allowedOrigins.includes(origin)) {
return origin;
}
// Reject all other origins
return null;
},
credentials: true, // Enable cookies for authentication
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
exposeHeaders: ['Set-Cookie'],
maxAge: 86400, // Cache preflight requests for 24 hours
}));
// Content-Security-Policy and other security headers
// Allow all hosts for Tailscale Funnel
app.use("*", async (c, next) => {
const isDev = process.env.NODE_ENV === 'development';
const directives = [
"default-src 'self'",
`script-src 'self'${isDev ? " 'unsafe-inline'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
];
const csp = directives.join('; ');
c.header('Content-Security-Policy', csp);
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
await next();
// Accept requests from any host
return next();
});
// Health check endpoint
@@ -111,6 +62,9 @@ if (process.env.NODE_ENV === 'production') {
app.use('/assets/*', serveStatic({ root: './dist' }));
}
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
app.use('/icons/*', serveStatic({ root: './public' }));
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
app.route("/rpc", rpcApp);
app.route("/caldav", caldavApp);

View File

@@ -1,104 +1,17 @@
import { createKV } from "./create-kv.js";
import { getCookie } from "hono/cookie";
import type { Context } from "hono";
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
type Session = { id: string; userId: string; expiresAt: string; createdAt: string; csrfToken?: string };
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
export const sessionsKV = createKV<Session>("sessions");
export const usersKV = createKV<User>("users");
// Cookie configuration constants
export const SESSION_COOKIE_NAME = 'sessionId';
export const CSRF_COOKIE_NAME = 'csrf-token';
export const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax' as const,
path: '/',
maxAge: 86400 // 24 hours
};
// CSRF token generation
export function generateCSRFToken(): string {
return randomBytes(32).toString('hex');
}
// Session extraction from cookies
export async function getSessionFromCookies(c: Context): Promise<Session | null> {
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
if (!sessionId) return null;
export async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) return null;
// Check expiration
if (new Date(session.expiresAt) < new Date()) {
// Clean up expired session
await sessionsKV.removeItem(sessionId);
return null;
}
return session;
}
// CSRF token validation
export async function validateCSRFToken(c: Context, sessionId: string): Promise<void> {
const headerToken = c.req.header('X-CSRF-Token');
if (!headerToken) throw new Error("CSRF token missing");
const session = await sessionsKV.getItem(sessionId);
if (!session?.csrfToken) throw new Error("Invalid session");
// Use timing-safe comparison to prevent timing attacks
const sessionTokenBuffer = Buffer.from(session.csrfToken, 'hex');
const headerTokenBuffer = Buffer.from(headerToken, 'hex');
if (sessionTokenBuffer.length !== headerTokenBuffer.length || !timingSafeEqual(sessionTokenBuffer, headerTokenBuffer)) {
throw new Error("CSRF token mismatch");
}
}
// Session rotation helper
export async function rotateSession(oldSessionId: string, userId: string): Promise<Session> {
// Delete old session
await sessionsKV.removeItem(oldSessionId);
// Create new session with CSRF token
const newSessionId = randomUUID();
const csrfToken = generateCSRFToken();
const now = new Date();
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours
const newSession: Session = {
id: newSessionId,
userId,
expiresAt: expiresAt.toISOString(),
createdAt: now.toISOString(),
csrfToken
};
await sessionsKV.setItem(newSessionId, newSession);
return newSession;
}
// Updated assertOwner function with CSRF validation
export async function assertOwner(c: Context): Promise<void> {
const session = await getSessionFromCookies(c);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
// Validate CSRF token for non-GET requests
const method = c.req.method;
if (method !== 'GET' && method !== 'HEAD') {
await validateCSRFToken(c, session.id);
}
}
// Export types for use in other modules
export type { Session, User };

View File

@@ -1,5 +1,4 @@
import { readFile } from "node:fs/promises";
import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -32,13 +31,18 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`;
const instagramProfile = process.env.INSTAGRAM_PROFILE;
const tiktokProfile = process.env.TIKTOK_PROFILE;
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
return `
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<tr>
<td style="padding:24px 24px 0 24px; text-align:center;">
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td>
</tr>
<tr>
@@ -50,6 +54,29 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
<div style="text-align:center; margin-bottom:16px;">
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
</div>
${(instagramProfile || tiktokProfile) ? `
<div style="text-align:center; margin-bottom:16px;">
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
<div style="display:inline-block;">
${instagramProfile ? `
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
` : ''}
${tiktokProfile ? `
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
` : ''}
</div>
</div>
` : ''}
<div style="font-size:12px; color:#64748b; text-align:center;">
&copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
</div>
@@ -61,14 +88,13 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
const { name, date, time, statusUrl } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
<p>Hallo ${safeName},</p>
<p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? `
@@ -89,14 +115,13 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
const { name, date, time, cancellationUrl, reviewUrl } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
<p>Hallo ${safeName},</p>
<p>Hallo ${name},</p>
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
<p>Wir freuen uns auf dich!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
@@ -129,14 +154,13 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
const { name, date, time } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const inner = `
<p>Hallo ${safeName},</p>
<p>Hallo ${name},</p>
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
@@ -158,10 +182,6 @@ export async function renderAdminBookingNotificationHTML(params: {
hasInspirationPhoto: boolean;
}) {
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
const safeName = sanitizeText(name);
const safeTreatment = sanitizeText(treatment);
const safePhone = sanitizePhone(phone);
const safeNotes = sanitizeHtml(notes);
const formattedDate = formatDateGerman(date);
const inner = `
<p>Hallo Admin,</p>
@@ -169,12 +189,12 @@ export async function renderAdminBookingNotificationHTML(params: {
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Name:</strong> ${safeName}</li>
<li><strong>Telefon:</strong> ${safePhone}</li>
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Name:</strong> ${name}</li>
<li><strong>Telefon:</strong> ${phone}</li>
<li><strong>Behandlung:</strong> ${treatment}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${time}</li>
${safeNotes ? `<li><strong>Notizen:</strong> ${safeNotes}</li>` : ''}
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
</ul>
</div>
@@ -196,15 +216,13 @@ export async function renderBookingRescheduleProposalHTML(params: {
declineUrl: string;
expiresAt: string;
}) {
const safeName = sanitizeText(params.name);
const safeTreatment = sanitizeText(params.treatmentName);
const formattedOriginalDate = formatDateGerman(params.originalDate);
const formattedProposedDate = formatDateGerman(params.proposedDate);
const expiryDate = new Date(params.expiresAt);
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const inner = `
<p>Hallo ${safeName},</p>
<p>Hallo ${params.name},</p>
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
@@ -219,7 +237,7 @@ export async function renderBookingRescheduleProposalHTML(params: {
</tr>
<tr>
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
<td style="padding:6px 0;">${safeTreatment}</td>
<td style="padding:6px 0;">${params.treatmentName}</td>
</tr>
</table>
</div>
@@ -249,19 +267,15 @@ export async function renderAdminRescheduleDeclinedHTML(params: {
customerEmail?: string;
customerPhone?: string;
}) {
const safeCustomerName = sanitizeText(params.customerName);
const safeTreatment = sanitizeText(params.treatmentName);
const safeEmail = params.customerEmail ? sanitizeText(params.customerEmail) : undefined;
const safePhone = params.customerPhone ? sanitizeText(params.customerPhone) : undefined;
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Kunde:</strong> ${params.customerName}</li>
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
</ul>
@@ -279,15 +293,13 @@ export async function renderAdminRescheduleAcceptedHTML(params: {
newTime: string;
treatmentName: string;
}) {
const safeCustomerName = sanitizeText(params.customerName);
const safeTreatment = sanitizeText(params.treatmentName);
const inner = `
<p>Hallo Admin,</p>
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Kunde:</strong> ${params.customerName}</li>
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
</ul>
@@ -315,24 +327,19 @@ export async function renderAdminRescheduleExpiredHTML(params: {
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
${params.expiredProposals.map(proposal => {
const safeName = sanitizeText(proposal.customerName);
const safeTreatment = sanitizeText(proposal.treatmentName);
const safeEmail = proposal.customerEmail ? sanitizeText(proposal.customerEmail) : undefined;
const safePhone = proposal.customerPhone ? sanitizeText(proposal.customerPhone) : undefined;
return `
${params.expiredProposals.map(proposal => `
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
<li><strong>Kunde:</strong> ${safeName}</li>
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
</ul>
</div>
`;}).join('')}
`).join('')}
</div>
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
<p>Die ursprünglichen Termine bleiben bestehen.</p>

View File

@@ -137,29 +137,20 @@ export function checkBookingRateLimit(params: {
/**
* Get client IP from various headers (for proxy/load balancer support)
*/
export function getClientIP(headers: Headers | Record<string, string | undefined>): string | undefined {
export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
// Check common proxy headers
const get = (name: string): string | undefined => {
if (typeof (headers as any).get === 'function') {
// Headers interface
const v = (headers as Headers).get(name);
return v === null ? undefined : v;
}
return (headers as Record<string, string | undefined>)[name];
};
const forwardedFor = get('x-forwarded-for');
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 = get('x-real-ip');
const realIP = headers['x-real-ip'];
if (realIP) {
return realIP;
}
const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
if (cfConnectingIP) {
return cfConnectingIP;
}
@@ -168,117 +159,4 @@ export function getClientIP(headers: Headers | Record<string, string | undefined
return undefined;
}
/**
* Reset a rate limit entry immediately (e.g., after successful login)
*/
export function resetRateLimit(key: string): void {
rateLimitStore.delete(key);
}
/**
* Convenience helper to reset login attempts for an IP
*/
export function resetLoginRateLimit(ip: string | undefined): void {
if (!ip) return;
resetRateLimit(`login:ip:${ip}`);
}
import type { Context } from "hono";
import { getSessionFromCookies } from "./auth.js";
/**
* Enforce admin rate limiting by IP and user. Throws standardized German error on exceed.
*/
export async function enforceAdminRateLimit(context: Context): Promise<void> {
const ip = getClientIP((context.req as any).raw.headers as Headers);
const session = await getSessionFromCookies(context);
if (!session) return; // No session -> owner assertion elsewhere; no per-user throttling
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
/**
* Brute-Force-Schutz für Logins (IP-basiert)
*
* Konfiguration:
* - max. 5 Versuche je IP in 15 Minuten
*
* Schlüssel: "login:ip:${ip}"
*/
export function checkLoginRateLimit(ip: string | undefined): RateLimitResult {
// Wenn keine IP ermittelbar ist, erlauben (kein Tracking möglich)
if (!ip) {
return {
allowed: true,
remaining: 5,
resetAt: Date.now() + 15 * 60 * 1000,
};
}
const loginConfig: RateLimitConfig = {
maxRequests: 5,
windowMs: 15 * 60 * 1000, // 15 Minuten
};
const key = `login:ip:${ip}`;
return checkRateLimit(key, loginConfig);
}
/**
* Rate Limiting für Admin-Operationen
*
* Konfigurationen (beide Checks werden geprüft, restriktiverer gewinnt):
* - Benutzer-basiert: 30 Anfragen je Benutzer in 5 Minuten
* - IP-basiert: 50 Anfragen je IP in 5 Minuten
*
* Schlüssel:
* - "admin:user:${userId}"
* - "admin:ip:${ip}"
*/
export function checkAdminRateLimit(params: { ip?: string; userId: string }): RateLimitResult {
const { ip, userId } = params;
const userConfig: RateLimitConfig = {
maxRequests: 30,
windowMs: 5 * 60 * 1000, // 5 Minuten
};
const ipConfig: RateLimitConfig = {
maxRequests: 50,
windowMs: 5 * 60 * 1000, // 5 Minuten
};
const userKey = `admin:user:${userId}`;
const userResult = checkRateLimit(userKey, userConfig);
// Wenn Benutzerlimit bereits überschritten ist, direkt zurückgeben
if (!userResult.allowed) {
return { ...userResult, allowed: false };
}
// Falls IP verfügbar, zusätzlich prüfen
if (ip) {
const ipKey = `admin:ip:${ip}`;
const ipResult = checkRateLimit(ipKey, ipConfig);
if (!ipResult.allowed) {
return { ...ipResult, allowed: false };
}
// Beide Checks erlaubt: restriktivere Restwerte/Reset nehmen
return {
allowed: true,
remaining: Math.min(userResult.remaining, ipResult.remaining),
resetAt: Math.min(userResult.resetAt, ipResult.resetAt),
};
}
// Kein IP-Check möglich
return {
allowed: true,
remaining: userResult.remaining,
resetAt: userResult.resetAt,
};
}

View File

@@ -1,37 +0,0 @@
import DOMPurify from "isomorphic-dompurify";
/**
* Sanitize plain text inputs by stripping all HTML tags.
* Use for names, phone numbers, and simple text fields.
*/
export function sanitizeText(input: string | undefined): string {
if (!input) return "";
const cleaned = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
return cleaned.trim();
}
/**
* Sanitize rich text notes allowing only a minimal, safe subset of tags.
* Use for free-form notes or comments where basic formatting is acceptable.
*/
export function sanitizeHtml(input: string | undefined): string {
if (!input) return "";
const cleaned = DOMPurify.sanitize(input, {
ALLOWED_TAGS: ["br", "p", "strong", "em", "u", "a", "ul", "li"],
ALLOWED_ATTR: ["href", "title", "target", "rel"],
ALLOWED_URI_REGEXP: /^(?:https?:)?\/\//i,
KEEP_CONTENT: true,
});
return cleaned.trim();
}
/**
* Sanitize phone numbers by stripping HTML and keeping only digits and a few symbols.
* Allowed characters: digits, +, -, (, ), and spaces.
*/
export function sanitizePhone(input: string | undefined): string {
const text = sanitizeText(input);
return text.replace(/[^0-9+\-()\s]/g, "");
}

View File

@@ -31,12 +31,6 @@ type Treatment = {
const bookingsKV = createKV<Booking>("bookings");
const treatmentsKV = createKV<Treatment>("treatments");
const sessionsKV = createKV<any>("sessions");
const caldavTokensKV = createKV<{
id: string;
userId: string;
expiresAt: string;
createdAt: string;
}>("caldavTokens");
export const caldavApp = new Hono();
@@ -50,15 +44,6 @@ function formatDateTime(dateStr: string, timeStr: string): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
// Helper to add minutes to an HH:MM time string and return HH:MM
function addMinutesToTime(timeStr: string, minutesToAdd: number): string {
const [hours, minutes] = timeStr.split(':').map(Number);
const total = hours * 60 + minutes + minutesToAdd;
const endHours = Math.floor(total / 60) % 24;
const endMinutes = total % 60;
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
}
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -83,8 +68,9 @@ X-WR-TIMEZONE:Europe/Berlin
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const computedEnd = addMinutesToTime(booking.appointmentTime, duration);
const endTime = formatDateTime(booking.appointmentDate, computedEnd);
const endTime = formatDateTime(booking.appointmentDate,
`${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`
);
// UID für jeden Termin (eindeutig)
const uid = `booking-${booking.id}@stargirlnails.de`;
@@ -110,60 +96,6 @@ END:VEVENT
return ics;
}
/**
* Extract and validate CalDAV token from Authorization header or query parameter (legacy)
* @param c Hono context
* @returns { token: string; source: 'bearer'|'basic'|'query' } | null
*/
function extractCalDAVToken(c: any): { token: string; source: 'bearer'|'basic'|'query' } | null {
// UUID v4 pattern for hardening (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Prefer Authorization header (new secure methods: Bearer or Basic)
const authHeader = c.req.header('Authorization');
if (authHeader) {
// Bearer
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
if (bearerMatch) {
const token = bearerMatch[1].trim();
if (!uuidV4Regex.test(token)) {
console.warn('CalDAV: Bearer token does not match UUID v4 format.');
return null;
}
return { token, source: 'bearer' };
}
// Basic (use username or password as token)
const basicMatch = authHeader.match(/^Basic\s+(.+)$/i);
if (basicMatch) {
try {
const decoded = Buffer.from(basicMatch[1], 'base64').toString('utf8');
// Format: username:password (password optional)
const [username, password] = decoded.split(':');
const candidate = (username && username.trim().length > 0)
? username.trim()
: (password ? password.trim() : '');
if (candidate && uuidV4Regex.test(candidate)) {
return { token: candidate, source: 'basic' };
}
console.warn('CalDAV: Basic auth credential does not contain a valid UUID v4 token.');
} catch (e) {
console.warn('CalDAV: Failed to decode Basic auth header');
}
return null;
}
}
// Fallback to query parameter (legacy, will be deprecated)
const queryToken = c.req.query('token');
if (queryToken) {
console.warn('CalDAV: Token passed via query parameter (deprecated). Please use Authorization header.');
return { token: queryToken, source: 'query' };
}
return null;
}
// CalDAV Discovery (PROPFIND auf Root)
caldavApp.all("/", async (c) => {
if (c.req.method !== 'PROPFIND') {
@@ -252,54 +184,42 @@ caldavApp.all("/calendar/events.ics", async (c) => {
// GET Calendar Data (ICS-Datei)
caldavApp.get("/calendar/events.ics", async (c) => {
try {
// Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
const tokenResult = extractCalDAVToken(c);
if (!tokenResult) {
return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
// Authentifizierung über Token im Query-Parameter
const token = c.req.query('token');
if (!token) {
return c.text('Unauthorized - Token required', 401);
}
// Validate token against caldavTokens KV store
const tokenData = await caldavTokensKV.getItem(tokenResult.token);
// Token validieren
const tokenData = await sessionsKV.getItem(token);
if (!tokenData) {
return c.text('Unauthorized - Invalid or expired token', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
return c.text('Unauthorized - Invalid token', 401);
}
// Check token expiration
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
return c.text('Unauthorized - Token expired', 401);
}
// Token-Ablaufzeit prüfen
if (new Date(tokenData.expiresAt) < new Date()) {
// Clean up expired token
await caldavTokensKV.removeItem(tokenResult.token);
return c.text('Unauthorized - Token expired', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
return c.text('Unauthorized - Token expired', 401);
}
// Note: Token is valid for 24 hours from creation.
// Expired tokens are cleaned up on access attempt.
const bookings = await bookingsKV.getAllItems();
const treatments = await treatmentsKV.getAllItems();
const icsContent = generateICSContent(bookings, treatments);
const headers: Record<string, string> = {
return c.text(icsContent, 200, {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
};
// If legacy query token was used, inform clients about deprecation
if (tokenResult.source === 'query') {
headers["Deprecation"] = "true";
headers["Warning"] = "299 - \"Query parameter token authentication is deprecated. Use Authorization header (Bearer or Basic).\"";
}
return c.text(icsContent, 200, headers);
});
} catch (error) {
console.error("CalDAV GET error:", error);
return c.text('Internal Server Error', 500);

View File

@@ -30,12 +30,18 @@ export function clientEntry(c: Context<BlankEnv>) {
}
return c.html(
<html lang="en">
<html lang="de">
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<meta name="theme-color" content="#ec4899" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
<title>Stargirlnails Kiel</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
{cssFiles && cssFiles.map((css: string) => (
<link key={css} rel="stylesheet" href={css} />
))}

View File

@@ -11,7 +11,6 @@ rpcApp.all("/*", async (c) => {
try {
const { matched, response } = await handler.handle(c.req.raw, {
prefix: "/rpc",
context: c,
});
if (matched) {

View File

@@ -1,25 +1,8 @@
import { call, os } from "@orpc/server";
import type { Context } from "hono";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv";
import bcrypt from "bcrypt";
import { setCookie } from "hono/cookie";
import { checkLoginRateLimit, getClientIP, resetLoginRateLimit } from "../lib/rate-limiter.js";
import {
generateCSRFToken,
getSessionFromCookies,
validateCSRFToken,
rotateSession,
COOKIE_OPTIONS,
SESSION_COOKIE_NAME,
CSRF_COOKIE_NAME,
sessionsKV,
usersKV,
type Session,
type User
} from "../lib/auth.js";
// Load environment variables from .env file
config();
@@ -38,68 +21,26 @@ const SessionSchema = z.object({
userId: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
csrfToken: z.string().optional(),
});
// Use shared KV stores from auth.ts to avoid duplication
type User = z.output<typeof UserSchema>;
type Session = z.output<typeof SessionSchema>;
// Password hashing using bcrypt
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
const usersKV = createKV<User>("users");
const sessionsKV = createKV<Session>("sessions");
const isBase64Hash = (hash: string): boolean => {
if (hash.startsWith(BCRYPT_PREFIX)) return false;
try {
const decoded = Buffer.from(hash, 'base64');
// If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64
const reencoded = decoded.toString('base64');
// Additionally ensure that decoding does not produce too short/empty unless original was empty
return reencoded === hash && decoded.toString('utf8').length > 0;
} catch {
return false;
}
// Simple password hashing (in production, use bcrypt or similar)
const hashPassword = (password: string): string => {
return Buffer.from(password).toString('base64');
};
const hashPassword = async (password: string): Promise<string> => {
return bcrypt.hash(password, 10);
};
const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
if (hash.startsWith(BCRYPT_PREFIX)) {
return bcrypt.compare(password, hash);
}
if (isBase64Hash(hash)) {
const base64OfPassword = Buffer.from(password).toString('base64');
return base64OfPassword === hash;
}
// Unknown format -> fail closed
return false;
const verifyPassword = (password: string, hash: string): boolean => {
return hashPassword(password) === hash;
};
// Export hashPassword for external use (e.g., generating hashes for .env)
export const generatePasswordHash = hashPassword;
// Migrate all legacy Base64 password hashes to bcrypt on server startup
const migrateLegacyHashesOnStartup = async (): Promise<void> => {
const users = await usersKV.getAllItems();
let migratedCount = 0;
for (const user of users) {
if (isBase64Hash(user.passwordHash)) {
try {
const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8');
const bcryptHash = await hashPassword(plaintext);
const updatedUser: User = { ...user, passwordHash: bcryptHash };
await usersKV.setItem(user.id, updatedUser);
migratedCount += 1;
} catch {
// ignore individual failures; continue with others
}
}
}
if (migratedCount > 0) {
console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`);
}
};
// Initialize default owner account
const initializeOwner = async () => {
const existingUsers = await usersKV.getAllItems();
@@ -108,12 +49,7 @@ const initializeOwner = async () => {
// Get admin credentials from environment variables
const adminUsername = process.env.ADMIN_USERNAME || "owner";
let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123");
// If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt
if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) {
const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8');
adminPasswordHash = await hashPassword(plaintext);
}
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
const owner: User = {
@@ -130,52 +66,24 @@ const initializeOwner = async () => {
}
};
// Initialize on module load: first migrate legacy hashes, then ensure owner exists
(async () => {
try {
await migrateLegacyHashesOnStartup();
} finally {
await initializeOwner();
}
})();
// Initialize on module load
initializeOwner();
const login = os
.input(z.object({
username: z.string(),
password: z.string(),
}))
.handler(async ({ input, context }) => {
const ip = getClientIP((context.req as any).raw.headers as Headers);
.handler(async ({ input }) => {
const users = await usersKV.getAllItems();
const user = users.find(u => u.username === input.username);
if (!user) {
const rl = checkLoginRateLimit(ip);
if (!rl.allowed) {
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
}
if (!user || !verifyPassword(input.password, user.passwordHash)) {
throw new Error("Invalid credentials");
}
const isValid = await verifyPassword(input.password, user.passwordHash);
if (!isValid) {
const rl = checkLoginRateLimit(ip);
if (!rl.allowed) {
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
}
throw new Error("Invalid credentials");
}
// Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt
if (isBase64Hash(user.passwordHash)) {
const migratedHash = await hashPassword(input.password);
const migratedUser = { ...user, passwordHash: migratedHash } as User;
await usersKV.setItem(user.id, migratedUser);
}
// Create session with CSRF token
// Create session
const sessionId = randomUUID();
const csrfToken = generateCSRFToken();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
@@ -184,22 +92,12 @@ const login = os
userId: user.id,
expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(),
csrfToken,
};
await sessionsKV.setItem(sessionId, session);
// Optional: Reset login attempts on successful login
resetLoginRateLimit(ip);
// Set cookies in response
setCookie(context, SESSION_COOKIE_NAME, sessionId, COOKIE_OPTIONS);
setCookie(context, CSRF_COOKIE_NAME, csrfToken, {
...COOKIE_OPTIONS,
httpOnly: false, // CSRF token needs to be readable by JavaScript
});
// Return only user object (no sessionId in response)
return {
sessionId,
user: {
id: user.id,
username: user.username,
@@ -210,28 +108,25 @@ const login = os
});
const logout = os
.input(z.object({})) // No input needed - session comes from cookies
.handler(async ({ context }) => {
const session = await getSessionFromCookies(context);
if (session) {
await sessionsKV.removeItem(session.id);
}
// Clear both cookies with correct options
setCookie(context, SESSION_COOKIE_NAME, '', { ...COOKIE_OPTIONS, maxAge: 0 });
setCookie(context, CSRF_COOKIE_NAME, '', { ...COOKIE_OPTIONS, httpOnly: false, maxAge: 0 });
.input(z.string()) // sessionId
.handler(async ({ input }) => {
await sessionsKV.removeItem(input);
return { success: true };
});
const verifySession = os
.input(z.object({})) // No input needed - session comes from cookies
.handler(async ({ context }) => {
const session = await getSessionFromCookies(context);
.input(z.string()) // sessionId
.handler(async ({ input }) => {
const session = await sessionsKV.getItem(input);
if (!session) {
throw new Error("Invalid session");
}
if (new Date(session.expiresAt) < new Date()) {
await sessionsKV.removeItem(input);
throw new Error("Session expired");
}
const user = await usersKV.getItem(session.userId);
if (!user) {
throw new Error("User not found");
@@ -249,11 +144,12 @@ const verifySession = os
const changePassword = os
.input(z.object({
sessionId: z.string(),
currentPassword: z.string(),
newPassword: z.string(),
}))
.handler(async ({ input, context }) => {
const session = await getSessionFromCookies(context);
.handler(async ({ input }) => {
const session = await sessionsKV.getItem(input.sessionId);
if (!session) {
throw new Error("Invalid session");
}
@@ -263,31 +159,16 @@ const changePassword = os
throw new Error("User not found");
}
// Validate CSRF token for password change
await validateCSRFToken(context, session.id);
const currentOk = await verifyPassword(input.currentPassword, user.passwordHash);
if (!currentOk) {
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
throw new Error("Current password is incorrect");
}
const updatedUser = {
...user,
passwordHash: await hashPassword(input.newPassword),
passwordHash: hashPassword(input.newPassword),
};
await usersKV.setItem(user.id, updatedUser);
// Implement session rotation after password change
const newSession = await rotateSession(session.id, user.id);
// Set new session and CSRF cookies
setCookie(context, SESSION_COOKIE_NAME, newSession.id, COOKIE_OPTIONS);
setCookie(context, CSRF_COOKIE_NAME, newSession.csrfToken!, {
...COOKIE_OPTIONS,
httpOnly: false,
});
return { success: true };
});

View File

@@ -1,17 +1,14 @@
import type { Context } from "hono";
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { router as rootRouter, os, call } from "./index.js";
import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit, getClientIP, checkAdminRateLimit, enforceAdminRateLimit } from "../lib/rate-limiter.js";
import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
// Using centrally typed os and call from rpc/index
// Create a server-side client to call other RPC endpoints
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
@@ -295,26 +292,14 @@ const create = os
treatment.duration
);
// Sanitize user-provided fields before storage
const sanitizedName = sanitizeText(input.customerName);
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
customerName: sanitizedName,
customerEmail: input.customerEmail,
customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: sanitizedNotes,
inspirationPhoto: input.inspirationPhoto,
id,
...input,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending" as const,
createdAt: new Date().toISOString()
} as Booking;
};
// Save the booking
await kv.setItem(id, booking);
@@ -328,7 +313,7 @@ const create = os
const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({
name: sanitizedName,
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
statusUrl: bookingUrl
@@ -336,7 +321,7 @@ const create = os
await sendEmail({
to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen",
text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
}).catch(() => {});
})();
@@ -351,24 +336,24 @@ const create = os
const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({
name: sanitizedName,
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
treatment: treatmentName,
phone: sanitizedPhone || "Nicht angegeben",
notes: sanitizedNotes,
phone: input.customerPhone || "Nicht angegeben",
notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto
});
const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${sanitizedName}\n` +
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
`Name: ${input.customerName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` +
`${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
`Zur Website: ${homepageUrl}\n\n` +
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
@@ -376,14 +361,14 @@ const create = os
if (input.inspirationPhoto) {
await sendEmailWithInspirationPhoto({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText,
html: adminHtml,
}, input.inspirationPhoto, sanitizedName).catch(() => {});
}, input.inspirationPhoto, input.customerName).catch(() => {});
} else {
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText,
html: adminHtml,
}).catch(() => {});
@@ -399,16 +384,26 @@ const create = os
});
// Owner check reuse (simple inline version)
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
const sessionsKV = createKV<Session>("sessions");
const usersKV = createKV<User>("users");
async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}
const updateStatus = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
}))
.handler(async ({ input, context }) => {
await assertOwner(context as unknown as Context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.id);
if (!booking) throw new Error("Booking not found");
@@ -446,7 +441,7 @@ const updateStatus = os
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${sanitizeText(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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, {
@@ -465,7 +460,7 @@ const updateStatus = os
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
@@ -479,13 +474,12 @@ const updateStatus = os
const remove = os
.input(z.object({
sessionId: z.string(),
id: z.string(),
sendEmail: z.boolean().optional().default(false)
}))
.handler(async ({ input, context }) => {
await assertOwner(context as unknown as Context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.id);
if (!booking) throw new Error("Booking not found");
@@ -514,7 +508,7 @@ const remove = os
await sendEmail({
to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt",
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
});
@@ -529,6 +523,7 @@ const remove = os
// Admin-only manual booking creation (immediately confirmed)
const createManual = os
.input(z.object({
sessionId: z.string(),
treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
@@ -537,11 +532,9 @@ const createManual = os
appointmentTime: z.string(),
notes: z.string().optional(),
}))
.handler(async ({ input, context }) => {
.handler(async ({ input }) => {
// Admin authentication
await assertOwner(context as unknown as Context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
await assertOwner(input.sessionId);
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
@@ -584,21 +577,16 @@ const createManual = os
treatment.duration
);
// Sanitize user-provided fields before storage (admin manual booking)
const sanitizedName = sanitizeText(input.customerName);
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
const id = randomUUID();
const booking = {
id,
treatmentId: input.treatmentId,
customerName: sanitizedName,
customerName: input.customerName,
customerEmail: input.customerEmail,
customerPhone: sanitizedPhone,
customerPhone: input.customerPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: sanitizedNotes,
notes: input.notes,
bookedDurationMinutes: treatment.duration,
status: "confirmed" as const,
createdAt: new Date().toISOString()
@@ -619,7 +607,7 @@ const createManual = os
const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({
name: sanitizedName,
name: input.customerName,
date: input.appointmentDate,
time: input.appointmentTime,
cancellationUrl: bookingUrl,
@@ -629,13 +617,13 @@ const createManual = os
await sendEmailWithAGBAndCalendar({
to: input.customerEmail!,
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html,
}, {
date: input.appointmentDate,
time: input.appointmentTime,
durationMinutes: treatment.duration,
customerName: sanitizedName,
customerName: input.customerName,
treatmentName: treatment.name
});
} catch (e) {
@@ -696,12 +684,13 @@ export const router = {
// Admin proposes a reschedule for a confirmed booking
proposeReschedule: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
proposedDate: z.string(),
proposedTime: z.string(),
}))
.handler(async ({ input, context }) => {
await assertOwner(context as unknown as Context);
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking) throw new Error("Booking not found");
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
@@ -876,32 +865,32 @@ export const router = {
// CalDAV Token für Admin generieren
generateCalDAVToken: os
.input(z.object({}))
.handler(async ({ input, context }) => {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Generiere einen sicheren Token für CalDAV-Zugriff
const token = randomUUID();
// Hole Session-Daten aus Cookies
const session = await getSessionFromCookies(context as unknown as Context);
if (!session) throw new Error("Invalid session");
// Hole Session-Daten für Token-Erstellung
const session = await sessionsKV.getItem(input.sessionId);
if (!session) throw new Error("Session nicht gefunden");
// Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = {
id: token,
userId: session.userId,
sessionId: input.sessionId,
userId: session.userId, // Benötigt für Session-Typ
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
createdAt: new Date().toISOString(),
};
// Dedizierten KV-Store für CalDAV-Token verwenden
const caldavTokensKV = createKV<typeof tokenData>("caldavTokens");
await caldavTokensKV.setItem(token, tokenData);
// Verwende den sessionsKV Store für Token-Speicherung
await sessionsKV.setItem(token, tokenData);
const domain = process.env.DOMAIN || 'localhost:3000';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
return {
token,
@@ -910,44 +899,15 @@ export const router = {
instructions: {
title: "CalDAV-Kalender abonnieren",
steps: [
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
"",
"📋 Dein CalDAV-Token (kopieren):",
token,
"",
"🔗 CalDAV-URL (ohne Token):",
caldavUrl,
"",
"📱 Einrichtung nach Kalender-App:",
"",
"🍎 Apple Calendar (macOS/iOS):",
"- Leider keine native Unterstützung für Authorization-Header",
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
"",
"📧 Outlook:",
"- Datei → Kontoeinstellungen → Internetkalender",
"- URL eingeben (ohne Token)",
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
" Authorization: Bearer <DEIN_TOKEN>",
"",
"🌐 Google Calendar:",
"- Andere Kalender → Von URL hinzufügen",
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
"- Alternative: Verwende Google Apps Script oder importiere manuell",
"",
"🦅 Thunderbird:",
"- Kalender → Neuer Kalender → Im Netzwerk",
"- Format: CalDAV",
"- URL eingeben",
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
"",
"💻 cURL-Beispiel zum Testen:",
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
"",
"⏰ Token-Gültigkeit: 24 Stunden",
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
"Kopiere die CalDAV-URL unten",
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
"- Apple Calendar: Abonnement → Neue Abonnements",
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
"Der Kalender wird automatisch aktualisiert"
],
note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)."
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
}
};
}),

View File

@@ -2,8 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
import { assertOwner } from "../lib/auth.js";
// Schema Definition
const GalleryPhotoSchema = z.object({
@@ -26,17 +25,16 @@ const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
const uploadPhoto = os
.input(
z.object({
sessionId: z.string(),
base64Data: z
.string()
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
title: z.string().optional().default(""),
})
)
.handler(async ({ input, context }) => {
.handler(async ({ input }) => {
try {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
await assertOwner(input.sessionId);
const id = randomUUID();
const existing = await galleryPhotosKV.getAllItems();
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
@@ -60,11 +58,9 @@ const uploadPhoto = os
});
const setCoverPhoto = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
let updatedCover: GalleryPhoto | null = null;
for (const p of all) {
@@ -77,24 +73,21 @@ const setCoverPhoto = os
});
const deletePhoto = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await galleryPhotosKV.removeItem(input.id);
});
const updatePhotoOrder = os
.input(
z.object({
sessionId: z.string(),
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
})
)
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const updated: GalleryPhoto[] = [];
for (const { id, order } of input.photoOrders) {
const existing = await galleryPhotosKV.getItem(id);
@@ -113,9 +106,9 @@ const listPhotos = os.handler(async () => {
});
const adminListPhotos = os
.input(z.object({}))
.handler(async ({ context }) => {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
});
@@ -130,9 +123,9 @@ const live = {
}),
adminListPhotos: os
.input(z.object({}))
.handler(async function* ({ context, signal }) {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems();
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sorted;

View File

@@ -1,6 +1,4 @@
import { demo } from "./demo/index.js";
import { os as baseOs, call as baseCall } from "@orpc/server";
import type { Context } from "hono";
import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js";
@@ -9,6 +7,7 @@ import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js";
import { router as social } from "./social.js";
export const router = {
demo,
@@ -20,10 +19,5 @@ export const router = {
legal,
gallery,
reviews,
social,
};
// Export centrally typed oRPC helpers so all modules share the same Hono Context typing
const osAny = baseOs as any;
export const os = osAny.withContext?.<Context>() ?? osAny.context?.<Context>() ?? baseOs;
export const call = baseCall;

View File

@@ -2,8 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
import { assertOwner } from "../lib/auth.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
@@ -88,23 +87,15 @@ function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string;
const createRule = os
.input(
z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough()
)
.handler(async ({ input, context }) => {
.handler(async ({ input }) => {
try {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
@@ -141,18 +132,9 @@ const createRule = os
});
const updateRule = os
.input(RecurringRuleSchema.passthrough())
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
@@ -170,40 +152,22 @@ const updateRule = os
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const rule = input as any;
const { sessionId, ...rule } = input as any;
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
return rule as RecurringRule;
});
const deleteRule = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await recurringRulesKV.removeItem(input.id);
});
const toggleRuleActive = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const rule = await recurringRulesKV.getItem(input.id);
if (!rule) throw new Error("Regel nicht gefunden.");
@@ -221,9 +185,9 @@ const listRules = os.handler(async () => {
});
const adminListRules = os
.input(z.object({}))
.handler(async ({ context }) => {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
@@ -235,16 +199,15 @@ const adminListRules = os
const createTimeOff = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(),
})
)
.handler(async ({ input, context }) => {
.handler(async ({ input }) => {
try {
await assertOwner(context);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
@@ -269,28 +232,24 @@ const createTimeOff = os
});
const updateTimeOff = os
.input(TimeOffPeriodSchema.passthrough())
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const timeOff = input as any;
const { sessionId, ...timeOff } = input as any;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
return timeOff as TimeOffPeriod;
});
const deleteTimeOff = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await timeOffPeriodsKV.removeItem(input.id);
});
@@ -300,9 +259,9 @@ const listTimeOff = os.handler(async () => {
});
const adminListTimeOff = os
.input(z.object({}))
.handler(async ({ context }) => {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
@@ -459,9 +418,9 @@ const live = {
}),
adminListRules: os
.input(z.object({}))
.handler(async function* ({ context, signal }) {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
@@ -479,9 +438,9 @@ const live = {
}),
adminListTimeOff: os
.input(z.object({}))
.handler(async function* ({ context, signal }) {
await assertOwner(context);
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff;

View File

@@ -2,8 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP } from "../lib/rate-limiter.js";
import { assertOwner, sessionsKV } from "../lib/auth.js";
// Schema Definition
const ReviewSchema = z.object({
@@ -134,31 +133,22 @@ const submitReview = os
// Admin Endpoint: approveReview
const approveReview = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session2 = await getSessionFromCookies(context);
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "approved" as const,
reviewedAt: new Date().toISOString(),
reviewedBy: session2?.userId || review.reviewedBy,
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
@@ -171,31 +161,22 @@ const approveReview = os
// Admin Endpoint: rejectReview
const rejectReview = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
await assertOwner(input.sessionId);
const review = await reviewsKV.getItem(input.id);
if (!review) {
throw new Error("Bewertung nicht gefunden");
}
const session2 = await getSessionFromCookies(context);
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = {
...review,
status: "rejected" as const,
reviewedAt: new Date().toISOString(),
reviewedBy: session2?.userId || review.reviewedBy,
reviewedBy: session?.userId || review.reviewedBy,
};
await reviewsKV.setItem(input.id, updatedReview);
@@ -208,19 +189,10 @@ const rejectReview = os
// Admin Endpoint: deleteReview
const deleteReview = os
.input(z.object({ id: z.string() }))
.handler(async ({ input, context }) => {
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
try {
await assertOwner(context);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
await assertOwner(input.sessionId);
await reviewsKV.removeItem(input.id);
} catch (err) {
console.error("reviews.deleteReview error", err);
@@ -253,12 +225,13 @@ const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
const adminListReviews = os
.input(
z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
})
)
.handler(async ({ input, context }) => {
.handler(async ({ input }) => {
try {
await assertOwner(context);
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"
@@ -285,11 +258,12 @@ const live = {
adminListReviews: os
.input(
z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
})
)
.handler(async function* ({ input, context, signal }) {
await assertOwner(context);
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all"

13
src/server/rpc/social.ts Normal file
View File

@@ -0,0 +1,13 @@
import { os } from "@orpc/server";
const getSocialMedia = os.handler(async () => {
return {
tiktokProfile: process.env.TIKTOK_PROFILE,
instagramProfile: process.env.INSTAGRAM_PROFILE,
};
});
export const router = os.router({
getSocialMedia,
});

View File

@@ -2,8 +2,6 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
const TreatmentSchema = z.object({
id: z.string(),
@@ -20,10 +18,7 @@ const kv = createKV<Treatment>("treatments");
const create = os
.input(TreatmentSchema.omit({ id: true }))
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as any);
.handler(async ({ input }) => {
const id = randomUUID();
const treatment = { id, ...input };
await kv.setItem(id, treatment);
@@ -32,18 +27,12 @@ const create = os
const update = os
.input(TreatmentSchema)
.handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
.handler(async ({ input }) => {
await kv.setItem(input.id, input);
return input;
});
const remove = os.input(z.string()).handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
const remove = os.input(z.string()).handler(async ({ input }) => {
await kv.removeItem(input);
});

View File

@@ -7,7 +7,6 @@
"declaration": false,
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"allowImportingTsExtensions": false,
"target": "ES2022",
"baseUrl": ".",

View File

@@ -1,11 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"types": ["node"],
"paths": {
"@/*": ["./src/*"]