9 Commits

36 changed files with 2577 additions and 422 deletions

View File

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

View File

@@ -1,6 +1,7 @@
# Admin Account Configuration # Admin Account Configuration
ADMIN_USERNAME=owner ADMIN_USERNAME=owner
ADMIN_PASSWORD_HASH=YWRtaW4xMjM= # Base64 encoded password ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy # bcrypt hashed password
# Legacy Base64 hashes are automatically migrated to bcrypt on server start/first login
# Domain Configuration # Domain Configuration
DOMAIN=localhost:5173 # For production: your-domain.com DOMAIN=localhost:5173 # For production: your-domain.com

View File

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

View File

@@ -12,6 +12,8 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un
- [Hono](https://hono.dev/) - [Hono](https://hono.dev/)
- [Zod](https://zod.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 ## Setup
### 1. Umgebungsvariablen konfigurieren ### 1. Umgebungsvariablen konfigurieren
@@ -22,33 +24,28 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
cp .env.example .env cp .env.example .env
``` ```
### 2. Admin-Passwort Hash generieren ### 2. Admin-Passwort Hash generieren (bcrypt)
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren: Das Admin-Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert. So erzeugst du einen Hash:
#### PowerShell (Windows) #### Node.js (empfohlen)
```powershell ```bash
# Einfache Methode mit Base64-Encoding node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)"
$password = "dein_sicheres_passwort"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
$hash = [System.Convert]::ToBase64String($bytes)
Write-Host "Password Hash: $hash"
# Alternative mit PowerShell 7+ (kürzer)
$password = "dein_sicheres_passwort"
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
``` ```
#### Node.js (falls verfügbar) Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst):
```javascript ```javascript
// In der Node.js Konsole oder als separates Script // scripts/generate-hash.js
const password = "dein_sicheres_passwort"; require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => {
const hash = Buffer.from(password).toString('base64'); console.log(h);
console.log("Password Hash:", hash); });
``` ```
#### Online-Tools (nur für Entwicklung) Ausführen:
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/) ```bash
node scripts/generate-hash.js "dein_sicheres_passwort"
```
### 3. .env Datei konfigurieren ### 3. .env Datei konfigurieren
@@ -57,7 +54,8 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
```env ```env
# Admin Account Configuration # Admin Account Configuration
ADMIN_USERNAME=owner ADMIN_USERNAME=owner
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash # bcrypt-Hash des Admin-Passworts (kein Base64). Beispielwert:
ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
# Domain Configuration # Domain Configuration
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
@@ -209,10 +207,12 @@ Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credent
⚠️ **Wichtige Hinweise:** ⚠️ **Wichtige Hinweise:**
- Ändere das Standard-Passwort vor dem Produktionseinsatz - Ändere das Standard-Passwort vor dem Produktionseinsatz
- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert - Das Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash - Verwende ein sicheres Passwort und generiere den entsprechenden Hash
- Die `.env` Datei sollte niemals in das Repository committet werden - 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 ### Security.txt Endpoint
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`: Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:

View File

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

121
docs/caldav-setup.md Normal file
View File

@@ -0,0 +1,121 @@
# 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,17 +37,38 @@ Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformu
- **Zeitfenster:** 10 Minuten - **Zeitfenster:** 10 Minuten
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten - **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 ## Wie es funktioniert
Das Rate-Limiting prüft **beide** Kriterien: Das Rate-Limiting prüft die passenden Kriterien je Endpoint. Für Admin-Operationen werden **beide** Limits geprüft:
1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt 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 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. Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
## IP-Erkennung ## IP-Erkennung
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers: Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers (unterstützt `Headers`-API und einfache Record-Objekte):
- `x-forwarded-for` - `x-forwarded-for`
- `x-real-ip` - `x-real-ip`
- `cf-connecting-ip` (Cloudflare) - `cf-connecting-ip` (Cloudflare)
@@ -60,7 +81,7 @@ Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch fo
## Anpassung ## Anpassung
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden: Die Limits können in `src/server/lib/rate-limiter.ts` angepasst werden. Beispiele:
```typescript ```typescript
// E-Mail-Limit anpassen // E-Mail-Limit anpassen
@@ -74,6 +95,23 @@ const ipConfig: RateLimitConfig = {
maxRequests: 5, // Anzahl der Anfragen maxRequests: 5, // Anzahl der Anfragen
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden 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 ## Fehlermeldungen
@@ -91,3 +129,5 @@ Für Produktionsumgebungen empfehlen sich:
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang) - ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten - ✅ 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.

355
docs/redis-migration.md Normal file
View File

@@ -0,0 +1,355 @@
# 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/)

251
docs/session-management.md Normal file
View File

@@ -0,0 +1,251 @@
# 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

@@ -12,6 +12,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@types/bcrypt": "^5.0.2",
"bcrypt": "^5.1.1",
"@hono/node-server": "^1.19.5", "@hono/node-server": "^1.19.5",
"@orpc/client": "^1.8.8", "@orpc/client": "^1.8.8",
"@orpc/server": "^1.8.8", "@orpc/server": "^1.8.8",
@@ -20,6 +22,7 @@
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"hono": "^4.9.4", "hono": "^4.9.4",
"isomorphic-dompurify": "^2.16.0",
"jsonrepair": "^3.13.0", "jsonrepair": "^3.13.0",
"openai": "^5.17.0", "openai": "^5.17.0",
"react": "^19.1.1", "react": "^19.1.1",

791
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,5 +45,20 @@ for i in {1..18}; do
sleep 5 sleep 5
done done
echo "[7/7] Tail recent logs (press Ctrl+C to exit)" echo "[6b/7] Verify HTTP /health via Caddy (localhost)"
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,32 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { router } from "@/server/rpc"; import type { router } from "@/server/rpc";
const link = new RPCLink({ url: `${window.location.origin}/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
});
}
});
export const rpcClient: RouterClient<typeof router> = createORPCClient(link); export const rpcClient: RouterClient<typeof router> = createORPCClient(link);
export const queryClient = createTanstackQueryUtils(rpcClient); export const queryClient = createTanstackQueryUtils(rpcClient);
// Export helper for potential use in other parts of the client code
export { getCSRFToken };

View File

@@ -1,6 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static'; import { serveStatic } from '@hono/node-server/serve-static';
import { cors } from 'hono/cors';
import { rpcApp } from "./routes/rpc.js"; import { rpcApp } from "./routes/rpc.js";
import { caldavApp } from "./routes/caldav.js"; import { caldavApp } from "./routes/caldav.js";
@@ -8,10 +9,58 @@ import { clientEntry } from "./routes/client-entry.js";
const app = new Hono(); const app = new Hono();
// Allow all hosts for Tailscale Funnel // 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
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
// Accept requests from any host const isDev = process.env.NODE_ENV === 'development';
return next(); 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();
}); });
// Health check endpoint // Health check endpoint

View File

@@ -1,17 +1,104 @@
import { createKV } from "./create-kv.js"; 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 }; type Session = { id: string; userId: string; expiresAt: string; createdAt: string; csrfToken?: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; 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 sessionsKV = createKV<Session>("sessions");
export const usersKV = createKV<User>("users"); export const usersKV = createKV<User>("users");
export async function assertOwner(sessionId: string): Promise<void> { // Cookie configuration constants
const session = await sessionsKV.getItem(sessionId); export const SESSION_COOKIE_NAME = 'sessionId';
if (!session) throw new Error("Invalid session"); export const CSRF_COOKIE_NAME = 'csrf-token';
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired"); export const COOKIE_OPTIONS = {
const user = await usersKV.getItem(session.userId); httpOnly: true,
if (!user || user.role !== "owner") throw new Error("Forbidden"); 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;
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");
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,4 +1,5 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
@@ -60,13 +61,14 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) { export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
const { name, date, time, statusUrl } = params; const { name, date, time, statusUrl } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`; const legalUrl = `${protocol}://${domain}/legal`;
const inner = ` const inner = `
<p>Hallo ${name},</p> <p>Hallo ${safeName},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</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> <p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? ` ${statusUrl ? `
@@ -87,13 +89,14 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) { export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
const { name, date, time, cancellationUrl, reviewUrl } = params; const { name, date, time, cancellationUrl, reviewUrl } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`; const legalUrl = `${protocol}://${domain}/legal`;
const inner = ` const inner = `
<p>Hallo ${name},</p> <p>Hallo ${safeName},</p>
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p> <p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
<p>Wir freuen uns auf dich!</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;"> <div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
@@ -126,13 +129,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) { export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
const { name, date, time } = params; const { name, date, time } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`; const legalUrl = `${protocol}://${domain}/legal`;
const inner = ` const inner = `
<p>Hallo ${name},</p> <p>Hallo ${safeName},</p>
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</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> <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;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
@@ -154,6 +158,10 @@ export async function renderAdminBookingNotificationHTML(params: {
hasInspirationPhoto: boolean; hasInspirationPhoto: boolean;
}) { }) {
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params; 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 formattedDate = formatDateGerman(date);
const inner = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
@@ -161,12 +169,12 @@ export async function renderAdminBookingNotificationHTML(params: {
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;"> <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> <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;"> <ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Name:</strong> ${name}</li> <li><strong>Name:</strong> ${safeName}</li>
<li><strong>Telefon:</strong> ${phone}</li> <li><strong>Telefon:</strong> ${safePhone}</li>
<li><strong>Behandlung:</strong> ${treatment}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Datum:</strong> ${formattedDate}</li> <li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${time}</li> <li><strong>Uhrzeit:</strong> ${time}</li>
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''} ${safeNotes ? `<li><strong>Notizen:</strong> ${safeNotes}</li>` : ''}
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li> <li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
</ul> </ul>
</div> </div>
@@ -188,13 +196,15 @@ export async function renderBookingRescheduleProposalHTML(params: {
declineUrl: string; declineUrl: string;
expiresAt: string; expiresAt: string;
}) { }) {
const safeName = sanitizeText(params.name);
const safeTreatment = sanitizeText(params.treatmentName);
const formattedOriginalDate = formatDateGerman(params.originalDate); const formattedOriginalDate = formatDateGerman(params.originalDate);
const formattedProposedDate = formatDateGerman(params.proposedDate); const formattedProposedDate = formatDateGerman(params.proposedDate);
const expiryDate = new Date(params.expiresAt); const expiryDate = new Date(params.expiresAt);
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const inner = ` const inner = `
<p>Hallo ${params.name},</p> <p>Hallo ${safeName},</p>
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</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;"> <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> <p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
@@ -209,7 +219,7 @@ export async function renderBookingRescheduleProposalHTML(params: {
</tr> </tr>
<tr> <tr>
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td> <td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
<td style="padding:6px 0;">${params.treatmentName}</td> <td style="padding:6px 0;">${safeTreatment}</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -239,15 +249,19 @@ export async function renderAdminRescheduleDeclinedHTML(params: {
customerEmail?: string; customerEmail?: string;
customerPhone?: 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 = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p> <p>der Kunde <strong>${safeCustomerName}</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;"> <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;"> <ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li> <li><strong>Kunde:</strong> ${safeCustomerName}</li>
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''} ${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''} ${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${params.treatmentName}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</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> <li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
</ul> </ul>
@@ -265,13 +279,15 @@ export async function renderAdminRescheduleAcceptedHTML(params: {
newTime: string; newTime: string;
treatmentName: string; treatmentName: string;
}) { }) {
const safeCustomerName = sanitizeText(params.customerName);
const safeTreatment = sanitizeText(params.treatmentName);
const inner = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p> <p>der Kunde <strong>${safeCustomerName}</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;"> <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;"> <ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${params.customerName}</li> <li><strong>Kunde:</strong> ${safeCustomerName}</li>
<li><strong>Behandlung:</strong> ${params.treatmentName}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</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> <li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
</ul> </ul>
@@ -299,19 +315,24 @@ 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> <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;"> <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> <p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
${params.expiredProposals.map(proposal => ` ${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 `
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;"> <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;"> <ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
<li><strong>Kunde:</strong> ${proposal.customerName}</li> <li><strong>Kunde:</strong> ${safeName}</li>
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''} ${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''} ${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</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>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li> <li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
</ul> </ul>
</div> </div>
`).join('')} `;}).join('')}
</div> </div>
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p> <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> <p>Die ursprünglichen Termine bleiben bestehen.</p>

View File

@@ -137,20 +137,29 @@ export function checkBookingRateLimit(params: {
/** /**
* Get client IP from various headers (for proxy/load balancer support) * Get client IP from various headers (for proxy/load balancer support)
*/ */
export function getClientIP(headers: Record<string, string | undefined>): string | undefined { export function getClientIP(headers: Headers | Record<string, string | undefined>): string | undefined {
// Check common proxy headers // Check common proxy headers
const forwardedFor = headers['x-forwarded-for']; 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');
if (forwardedFor) { if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one // x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim(); return forwardedFor.split(',')[0].trim();
} }
const realIP = headers['x-real-ip']; const realIP = get('x-real-ip');
if (realIP) { if (realIP) {
return realIP; return realIP;
} }
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
if (cfConnectingIP) { if (cfConnectingIP) {
return cfConnectingIP; return cfConnectingIP;
} }
@@ -159,4 +168,117 @@ export function getClientIP(headers: Record<string, string | undefined>): string
return 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

@@ -0,0 +1,37 @@
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,6 +31,12 @@ type Treatment = {
const bookingsKV = createKV<Booking>("bookings"); const bookingsKV = createKV<Booking>("bookings");
const treatmentsKV = createKV<Treatment>("treatments"); const treatmentsKV = createKV<Treatment>("treatments");
const sessionsKV = createKV<any>("sessions"); const sessionsKV = createKV<any>("sessions");
const caldavTokensKV = createKV<{
id: string;
userId: string;
expiresAt: string;
createdAt: string;
}>("caldavTokens");
export const caldavApp = new Hono(); export const caldavApp = new Hono();
@@ -44,6 +50,15 @@ function formatDateTime(dateStr: string, timeStr: string): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); 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 { function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -68,9 +83,8 @@ X-WR-TIMEZONE:Europe/Berlin
const duration = booking.bookedDurationMinutes || treatment?.duration || 60; const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime); const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const endTime = formatDateTime(booking.appointmentDate, const computedEnd = addMinutesToTime(booking.appointmentTime, duration);
`${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')}` const endTime = formatDateTime(booking.appointmentDate, computedEnd);
);
// UID für jeden Termin (eindeutig) // UID für jeden Termin (eindeutig)
const uid = `booking-${booking.id}@stargirlnails.de`; const uid = `booking-${booking.id}@stargirlnails.de`;
@@ -96,6 +110,60 @@ END:VEVENT
return ics; 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) // CalDAV Discovery (PROPFIND auf Root)
caldavApp.all("/", async (c) => { caldavApp.all("/", async (c) => {
if (c.req.method !== 'PROPFIND') { if (c.req.method !== 'PROPFIND') {
@@ -184,42 +252,54 @@ caldavApp.all("/calendar/events.ics", async (c) => {
// GET Calendar Data (ICS-Datei) // GET Calendar Data (ICS-Datei)
caldavApp.get("/calendar/events.ics", async (c) => { caldavApp.get("/calendar/events.ics", async (c) => {
try { try {
// Authentifizierung über Token im Query-Parameter // Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
const token = c.req.query('token'); const tokenResult = extractCalDAVToken(c);
if (!token) { if (!tokenResult) {
return c.text('Unauthorized - Token required', 401); 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"'
});
} }
// Token validieren // Validate token against caldavTokens KV store
const tokenData = await sessionsKV.getItem(token); const tokenData = await caldavTokensKV.getItem(tokenResult.token);
if (!tokenData) { if (!tokenData) {
return c.text('Unauthorized - Invalid token', 401); return c.text('Unauthorized - Invalid or expired token', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
} }
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar) // Check token expiration
// 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()) { if (new Date(tokenData.expiresAt) < new Date()) {
return c.text('Unauthorized - Token expired', 401); // 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"'
});
} }
// Note: Token is valid for 24 hours from creation.
// Expired tokens are cleaned up on access attempt.
const bookings = await bookingsKV.getAllItems(); const bookings = await bookingsKV.getAllItems();
const treatments = await treatmentsKV.getAllItems(); const treatments = await treatmentsKV.getAllItems();
const icsContent = generateICSContent(bookings, treatments); const icsContent = generateICSContent(bookings, treatments);
return c.text(icsContent, 200, { const headers: Record<string, string> = {
"Content-Type": "text/calendar; charset=utf-8", "Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"", "Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
"Cache-Control": "no-cache, no-store, must-revalidate", "Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache", "Pragma": "no-cache",
"Expires": "0", "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) { } catch (error) {
console.error("CalDAV GET error:", error); console.error("CalDAV GET error:", error);
return c.text('Internal Server Error', 500); return c.text('Internal Server Error', 500);

View File

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

View File

@@ -1,8 +1,25 @@
import { call, os } from "@orpc/server"; import { call, os } from "@orpc/server";
import type { Context } from "hono";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv"; 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 // Load environment variables from .env file
config(); config();
@@ -21,26 +38,68 @@ const SessionSchema = z.object({
userId: z.string(), userId: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
createdAt: z.string(), createdAt: z.string(),
csrfToken: z.string().optional(),
}); });
type User = z.output<typeof UserSchema>; // Use shared KV stores from auth.ts to avoid duplication
type Session = z.output<typeof SessionSchema>;
const usersKV = createKV<User>("users"); // Password hashing using bcrypt
const sessionsKV = createKV<Session>("sessions"); const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
// Simple password hashing (in production, use bcrypt or similar) const isBase64Hash = (hash: string): boolean => {
const hashPassword = (password: string): string => { if (hash.startsWith(BCRYPT_PREFIX)) return false;
return Buffer.from(password).toString('base64'); 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;
}
}; };
const verifyPassword = (password: string, hash: string): boolean => { const hashPassword = async (password: string): Promise<string> => {
return hashPassword(password) === hash; 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;
}; };
// Export hashPassword for external use (e.g., generating hashes for .env) // Export hashPassword for external use (e.g., generating hashes for .env)
export const generatePasswordHash = hashPassword; 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 // Initialize default owner account
const initializeOwner = async () => { const initializeOwner = async () => {
const existingUsers = await usersKV.getAllItems(); const existingUsers = await usersKV.getAllItems();
@@ -49,7 +108,12 @@ const initializeOwner = async () => {
// Get admin credentials from environment variables // Get admin credentials from environment variables
const adminUsername = process.env.ADMIN_USERNAME || "owner"; const adminUsername = process.env.ADMIN_USERNAME || "owner";
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123"); 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 adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de"; const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
const owner: User = { const owner: User = {
@@ -66,24 +130,52 @@ const initializeOwner = async () => {
} }
}; };
// Initialize on module load // Initialize on module load: first migrate legacy hashes, then ensure owner exists
initializeOwner(); (async () => {
try {
await migrateLegacyHashesOnStartup();
} finally {
await initializeOwner();
}
})();
const login = os const login = os
.input(z.object({ .input(z.object({
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
const ip = getClientIP((context.req as any).raw.headers as Headers);
const users = await usersKV.getAllItems(); const users = await usersKV.getAllItems();
const user = users.find(u => u.username === input.username); const user = users.find(u => u.username === input.username);
if (!user || !verifyPassword(input.password, user.passwordHash)) { if (!user) {
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"); throw new Error("Invalid credentials");
} }
// Create session 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
const sessionId = randomUUID(); const sessionId = randomUUID();
const csrfToken = generateCSRFToken();
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
@@ -92,12 +184,22 @@ const login = os
userId: user.id, userId: user.id,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
csrfToken,
}; };
await sessionsKV.setItem(sessionId, session); 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 { return {
sessionId,
user: { user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
@@ -108,25 +210,28 @@ const login = os
}); });
const logout = os const logout = os
.input(z.string()) // sessionId .input(z.object({})) // No input needed - session comes from cookies
.handler(async ({ input }) => { .handler(async ({ context }) => {
await sessionsKV.removeItem(input); 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 });
return { success: true }; return { success: true };
}); });
const verifySession = os const verifySession = os
.input(z.string()) // sessionId .input(z.object({})) // No input needed - session comes from cookies
.handler(async ({ input }) => { .handler(async ({ context }) => {
const session = await sessionsKV.getItem(input); const session = await getSessionFromCookies(context);
if (!session) { if (!session) {
throw new Error("Invalid 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); const user = await usersKV.getItem(session.userId);
if (!user) { if (!user) {
throw new Error("User not found"); throw new Error("User not found");
@@ -144,12 +249,11 @@ const verifySession = os
const changePassword = os const changePassword = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string(), newPassword: z.string(),
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
const session = await sessionsKV.getItem(input.sessionId); const session = await getSessionFromCookies(context);
if (!session) { if (!session) {
throw new Error("Invalid session"); throw new Error("Invalid session");
} }
@@ -159,16 +263,31 @@ const changePassword = os
throw new Error("User not found"); throw new Error("User not found");
} }
if (!verifyPassword(input.currentPassword, user.passwordHash)) { // Validate CSRF token for password change
await validateCSRFToken(context, session.id);
const currentOk = await verifyPassword(input.currentPassword, user.passwordHash);
if (!currentOk) {
throw new Error("Current password is incorrect"); throw new Error("Current password is incorrect");
} }
const updatedUser = { const updatedUser = {
...user, ...user,
passwordHash: hashPassword(input.newPassword), passwordHash: await hashPassword(input.newPassword),
}; };
await usersKV.setItem(user.id, updatedUser); 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 }; return { success: true };
}); });

View File

@@ -1,14 +1,17 @@
import { call, os } from "@orpc/server"; import type { Context } from "hono";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; 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 { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js"; import { router as rootRouter, os, call } from "./index.js";
import { createORPCClient } from "@orpc/client"; import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch"; import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js"; import { checkBookingRateLimit, getClientIP, checkAdminRateLimit, enforceAdminRateLimit } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.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 // Create a server-side client to call other RPC endpoints
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000; const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
@@ -292,14 +295,26 @@ const create = os
treatment.duration 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 id = randomUUID();
const booking = { const booking = {
id, id,
...input, treatmentId: input.treatmentId,
customerName: sanitizedName,
customerEmail: input.customerEmail,
customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime,
notes: sanitizedNotes,
inspirationPhoto: input.inspirationPhoto,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending" as const, status: "pending" as const,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}; } as Booking;
// Save the booking // Save the booking
await kv.setItem(id, booking); await kv.setItem(id, booking);
@@ -313,7 +328,7 @@ const create = os
const formattedDate = formatDateGerman(input.appointmentDate); const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({ const html = await renderBookingPendingHTML({
name: input.customerName, name: sanitizedName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
statusUrl: bookingUrl statusUrl: bookingUrl
@@ -321,7 +336,7 @@ const create = os
await sendEmail({ await sendEmail({
to: input.customerEmail, to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen", subject: "Deine Terminanfrage ist eingegangen",
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`, 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`,
html, html,
}).catch(() => {}); }).catch(() => {});
})(); })();
@@ -336,24 +351,24 @@ const create = os
const treatmentName = treatment?.name || "Unbekannte Behandlung"; const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({ const adminHtml = await renderAdminBookingNotificationHTML({
name: input.customerName, name: sanitizedName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
treatment: treatmentName, treatment: treatmentName,
phone: input.customerPhone || "Nicht angegeben", phone: sanitizedPhone || "Nicht angegeben",
notes: input.notes, notes: sanitizedNotes,
hasInspirationPhoto: !!input.inspirationPhoto hasInspirationPhoto: !!input.inspirationPhoto
}); });
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` + `Name: ${sanitizedName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` + `Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` + `Behandlung: ${treatmentName}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` + `Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` + `Uhrzeit: ${input.appointmentTime}\n` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` + `${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` +
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` + `Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
`Zur Website: ${homepageUrl}\n\n` + `Zur Website: ${homepageUrl}\n\n` +
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`; `Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
@@ -361,14 +376,14 @@ const create = os
if (input.inspirationPhoto) { if (input.inspirationPhoto) {
await sendEmailWithInspirationPhoto({ await sendEmailWithInspirationPhoto({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`, subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText, text: adminText,
html: adminHtml, html: adminHtml,
}, input.inspirationPhoto, input.customerName).catch(() => {}); }, input.inspirationPhoto, sanitizedName).catch(() => {});
} else { } else {
await sendEmail({ await sendEmail({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`, subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText, text: adminText,
html: adminHtml, html: adminHtml,
}).catch(() => {}); }).catch(() => {});
@@ -384,26 +399,16 @@ const create = os
}); });
// Owner check reuse (simple inline version) // 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 const updateStatus = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
id: z.string(), id: z.string(),
status: z.enum(["pending", "confirmed", "cancelled", "completed"]) status: z.enum(["pending", "confirmed", "cancelled", "completed"])
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
const booking = await kv.getItem(input.id); const booking = await kv.getItem(input.id);
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
@@ -441,7 +446,7 @@ const updateStatus = os
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang", subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\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 ${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`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, { }, {
@@ -460,7 +465,7 @@ const updateStatus = os
await sendEmail({ await sendEmail({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt", subject: "Dein Termin wurde abgesagt",
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`, 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`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });
@@ -474,12 +479,13 @@ const updateStatus = os
const remove = os const remove = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
id: z.string(), id: z.string(),
sendEmail: z.boolean().optional().default(false) sendEmail: z.boolean().optional().default(false)
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
const booking = await kv.getItem(input.id); const booking = await kv.getItem(input.id);
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
@@ -508,7 +514,7 @@ const remove = os
await sendEmail({ await sendEmail({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt", subject: "Dein Termin wurde abgesagt",
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`, 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`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });
@@ -523,7 +529,6 @@ const remove = os
// Admin-only manual booking creation (immediately confirmed) // Admin-only manual booking creation (immediately confirmed)
const createManual = os const createManual = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
treatmentId: z.string(), treatmentId: z.string(),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"), customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(), customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
@@ -532,9 +537,11 @@ const createManual = os
appointmentTime: z.string(), appointmentTime: z.string(),
notes: z.string().optional(), notes: z.string().optional(),
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
// Admin authentication // Admin authentication
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
// Validate appointment time is on 15-minute grid // Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime); const appointmentMinutes = parseTime(input.appointmentTime);
@@ -577,16 +584,21 @@ const createManual = os
treatment.duration 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 id = randomUUID();
const booking = { const booking = {
id, id,
treatmentId: input.treatmentId, treatmentId: input.treatmentId,
customerName: input.customerName, customerName: sanitizedName,
customerEmail: input.customerEmail, customerEmail: input.customerEmail,
customerPhone: input.customerPhone, customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate, appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime, appointmentTime: input.appointmentTime,
notes: input.notes, notes: sanitizedNotes,
bookedDurationMinutes: treatment.duration, bookedDurationMinutes: treatment.duration,
status: "confirmed" as const, status: "confirmed" as const,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
@@ -607,7 +619,7 @@ const createManual = os
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({ const html = await renderBookingConfirmedHTML({
name: input.customerName, name: sanitizedName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
cancellationUrl: bookingUrl, cancellationUrl: bookingUrl,
@@ -617,13 +629,13 @@ const createManual = os
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: input.customerEmail!, to: input.customerEmail!,
subject: "Dein Termin wurde bestätigt - AGB im Anhang", subject: "Dein Termin wurde bestätigt - AGB im Anhang",
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`, 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`,
html, html,
}, { }, {
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
durationMinutes: treatment.duration, durationMinutes: treatment.duration,
customerName: input.customerName, customerName: sanitizedName,
treatmentName: treatment.name treatmentName: treatment.name
}); });
} catch (e) { } catch (e) {
@@ -684,13 +696,12 @@ export const router = {
// Admin proposes a reschedule for a confirmed booking // Admin proposes a reschedule for a confirmed booking
proposeReschedule: os proposeReschedule: os
.input(z.object({ .input(z.object({
sessionId: z.string(),
bookingId: z.string(), bookingId: z.string(),
proposedDate: z.string(), proposedDate: z.string(),
proposedTime: z.string(), proposedTime: z.string(),
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
const booking = await kv.getItem(input.bookingId); const booking = await kv.getItem(input.bookingId);
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden."); if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
@@ -865,32 +876,32 @@ export const router = {
// CalDAV Token für Admin generieren // CalDAV Token für Admin generieren
generateCalDAVToken: os generateCalDAVToken: os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// Generiere einen sicheren Token für CalDAV-Zugriff // Generiere einen sicheren Token für CalDAV-Zugriff
const token = randomUUID(); const token = randomUUID();
// Hole Session-Daten für Token-Erstellung // Hole Session-Daten aus Cookies
const session = await sessionsKV.getItem(input.sessionId); const session = await getSessionFromCookies(context as unknown as Context);
if (!session) throw new Error("Session nicht gefunden"); if (!session) throw new Error("Invalid session");
// Speichere Token mit Ablaufzeit (24 Stunden) // Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = { const tokenData = {
id: token, id: token,
sessionId: input.sessionId, userId: session.userId,
userId: session.userId, // Benötigt für Session-Typ
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
// Verwende den sessionsKV Store für Token-Speicherung // Dedizierten KV-Store für CalDAV-Token verwenden
await sessionsKV.setItem(token, tokenData); const caldavTokensKV = createKV<typeof tokenData>("caldavTokens");
await caldavTokensKV.setItem(token, tokenData);
const domain = process.env.DOMAIN || 'localhost:3000'; const domain = process.env.DOMAIN || 'localhost:3000';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`; const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
return { return {
token, token,
@@ -899,15 +910,44 @@ export const router = {
instructions: { instructions: {
title: "CalDAV-Kalender abonnieren", title: "CalDAV-Kalender abonnieren",
steps: [ steps: [
"Kopiere die CalDAV-URL unten", "⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
"Füge sie in deiner Kalender-App als Abonnement hinzu:", "",
"- Outlook: Datei → Konto hinzufügen → Internetkalender", "📋 Dein CalDAV-Token (kopieren):",
"- Google Calendar: Andere Kalender hinzufügen → Von URL", token,
"- Apple Calendar: Abonnement → Neue Abonnements", "",
"- Thunderbird: Kalender hinzufügen → Im Netzwerk", "🔗 CalDAV-URL (ohne Token):",
"Der Kalender wird automatisch aktualisiert" 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."
], ],
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren." note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)."
} }
}; };
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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