Compare commits
6 Commits
v0.1.5.2
...
3f2282f680
Author | SHA1 | Date | |
---|---|---|---|
3f2282f680 | |||
ed28d7aea2 | |||
1708ae588c | |||
31b007d145 | |||
90029f4b6a | |||
df26c7da6b |
@@ -1,6 +1,7 @@
|
||||
# Admin Account Configuration
|
||||
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=localhost:5173 # For production: your-domain.com
|
||||
|
41
Dockerfile
41
Dockerfile
@@ -1,29 +1,7 @@
|
||||
# Multi-stage build for production
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
# Install pnpm and su-exec
|
||||
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec
|
||||
# Install pnpm and required runtime tools
|
||||
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec curl
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -34,17 +12,12 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy built application from base stage
|
||||
COPY --from=base /app/dist ./dist
|
||||
COPY --from=base /app/server-dist ./server-dist
|
||||
COPY --from=base /app/public ./public
|
||||
# Copy prebuilt application artifacts from repository (no TS build in image)
|
||||
COPY dist ./dist
|
||||
COPY server-dist ./server-dist
|
||||
COPY public ./public
|
||||
|
||||
# Copy necessary files for runtime
|
||||
COPY --from=base /app/src/server/index.ts ./src/server/index.ts
|
||||
COPY --from=base /app/src/server/routes ./src/server/routes
|
||||
COPY --from=base /app/src/server/rpc ./src/server/rpc
|
||||
COPY --from=base /app/src/server/lib ./src/server/lib
|
||||
COPY --from=base /app/tsconfig.server.json ./tsconfig.server.json
|
||||
# Copy necessary runtime files
|
||||
COPY start.sh ./start.sh
|
||||
|
||||
# Create non-root user for security
|
||||
|
44
README.md
44
README.md
@@ -12,6 +12,8 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un
|
||||
- [Hono](https://hono.dev/)
|
||||
- [Zod](https://zod.dev/)
|
||||
|
||||
> Hinweis zu DOMPurify: Wir nutzen `isomorphic-dompurify`, das DOMPurify bereits mitliefert und sowohl in Node.js als auch im Browser funktioniert. Eine zusätzliche Installation von `dompurify` ist daher nicht erforderlich und würde eine redundante Abhängigkeit erzeugen.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Umgebungsvariablen konfigurieren
|
||||
@@ -22,33 +24,28 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
|
||||
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)
|
||||
```powershell
|
||||
# Einfache Methode mit Base64-Encoding
|
||||
$password = "dein_sicheres_passwort"
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
|
||||
$hash = [System.Convert]::ToBase64String($bytes)
|
||||
Write-Host "Password Hash: $hash"
|
||||
|
||||
# Alternative mit PowerShell 7+ (kürzer)
|
||||
$password = "dein_sicheres_passwort"
|
||||
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
|
||||
#### Node.js (empfohlen)
|
||||
```bash
|
||||
node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)"
|
||||
```
|
||||
|
||||
#### Node.js (falls verfügbar)
|
||||
Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst):
|
||||
|
||||
```javascript
|
||||
// In der Node.js Konsole oder als separates Script
|
||||
const password = "dein_sicheres_passwort";
|
||||
const hash = Buffer.from(password).toString('base64');
|
||||
console.log("Password Hash:", hash);
|
||||
// scripts/generate-hash.js
|
||||
require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => {
|
||||
console.log(h);
|
||||
});
|
||||
```
|
||||
|
||||
#### Online-Tools (nur für Entwicklung)
|
||||
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
|
||||
Ausführen:
|
||||
```bash
|
||||
node scripts/generate-hash.js "dein_sicheres_passwort"
|
||||
```
|
||||
|
||||
### 3. .env Datei konfigurieren
|
||||
|
||||
@@ -57,7 +54,8 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
|
||||
```env
|
||||
# Admin Account Configuration
|
||||
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=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:**
|
||||
- Ä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
|
||||
- Die `.env` Datei sollte niemals in das Repository committet werden
|
||||
|
||||
Hinweis zur Migration: Vorhandene Base64-Hashes aus älteren Versionen werden beim Server-Start automatisch in bcrypt migriert. Zusätzlich erfolgt beim nächsten erfolgreichen Login ebenfalls eine Migration, falls noch erforderlich.
|
||||
|
||||
### Security.txt Endpoint
|
||||
|
||||
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:
|
||||
|
@@ -17,7 +17,7 @@ services:
|
||||
networks:
|
||||
- stargirlnails-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
- caddy-logs:/var/log/caddy
|
||||
networks:
|
||||
- stargirlnails-network
|
||||
depends_on:
|
||||
@@ -49,6 +50,8 @@ volumes:
|
||||
driver: local
|
||||
caddy-config:
|
||||
driver: local
|
||||
caddy-logs:
|
||||
driver: local
|
||||
|
||||
# Netzwerk für interne Kommunikation
|
||||
networks:
|
||||
|
121
docs/caldav-setup.md
Normal file
121
docs/caldav-setup.md
Normal 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
|
@@ -37,17 +37,38 @@ Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformu
|
||||
- **Zeitfenster:** 10 Minuten
|
||||
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
|
||||
|
||||
### Login (IP-basiert)
|
||||
- **Limit:** 5 fehlgeschlagene Login-Versuche pro IP-Adresse
|
||||
- **Zeitfenster:** 15 Minuten
|
||||
- **Verhalten:** Das Limit zählt nur fehlgeschlagene Versuche. Nach erfolgreichem Login wird der Zähler zurückgesetzt. Bei Überschreitung muss der Nutzer 15 Minuten warten.
|
||||
- **Zweck:** Schutz vor Brute-Force-Angriffen auf Admin-Accounts
|
||||
|
||||
### Admin-Operationen (Benutzer-basiert)
|
||||
- **Limit:** 30 Anfragen pro Admin-Benutzer
|
||||
- **Zeitfenster:** 5 Minuten
|
||||
- **Verhalten:** Nach 30 Anfragen muss der Admin 5 Minuten warten
|
||||
- **Zweck:** Verhindert versehentliches Spam durch Admin-UI (z.B. Doppelklicks, fehlerhafte Skripte)
|
||||
- **Betroffene Endpoints:** Treatments (create/update/remove), Bookings (manualCreate/updateStatus/remove), Recurring Availability (alle Schreiboperationen), Gallery (alle Schreiboperationen), Reviews (approve/reject/delete)
|
||||
|
||||
### Admin-Operationen (IP-basiert)
|
||||
- **Limit:** 50 Anfragen pro IP-Adresse
|
||||
- **Zeitfenster:** 5 Minuten
|
||||
- **Verhalten:** Nach 50 Anfragen muss 5 Minuten gewartet werden
|
||||
- **Zweck:** Zusätzlicher Schutz gegen IP-basierte Angriffe auf Admin-Endpoints
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
Das Rate-Limiting prüft **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
|
||||
2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt
|
||||
3. **Benutzer-basiert (Admin):** Limitiert Anfragen je Admin-Benutzer
|
||||
4. **IP-basiert (Admin):** Limitiert Anfragen je IP zusätzlich
|
||||
|
||||
Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
|
||||
|
||||
## IP-Erkennung
|
||||
|
||||
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers:
|
||||
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-real-ip`
|
||||
- `cf-connecting-ip` (Cloudflare)
|
||||
@@ -60,7 +81,7 @@ Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch fo
|
||||
|
||||
## Anpassung
|
||||
|
||||
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
|
||||
Die Limits können in `src/server/lib/rate-limiter.ts` angepasst werden. Beispiele:
|
||||
|
||||
```typescript
|
||||
// E-Mail-Limit anpassen
|
||||
@@ -74,6 +95,23 @@ const ipConfig: RateLimitConfig = {
|
||||
maxRequests: 5, // Anzahl der Anfragen
|
||||
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
|
||||
};
|
||||
|
||||
// Login-Bruteforce-Schutz
|
||||
const loginConfig: RateLimitConfig = {
|
||||
maxRequests: 5,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
};
|
||||
|
||||
// Admin-Operationen
|
||||
const adminUserConfig: RateLimitConfig = {
|
||||
maxRequests: 30,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
};
|
||||
|
||||
const adminIpConfig: RateLimitConfig = {
|
||||
maxRequests: 50,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
};
|
||||
```
|
||||
|
||||
## Fehlermeldungen
|
||||
@@ -91,3 +129,5 @@ Für Produktionsumgebungen empfehlen sich:
|
||||
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
|
||||
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten
|
||||
|
||||
Siehe auch `docs/redis-migration.md` für Hinweise zur Migration auf Redis in Multi-Instance-Setups.
|
||||
|
||||
|
355
docs/redis-migration.md
Normal file
355
docs/redis-migration.md
Normal 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
251
docs/session-management.md
Normal 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)
|
@@ -12,6 +12,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"@hono/node-server": "^1.19.5",
|
||||
"@orpc/client": "^1.8.8",
|
||||
"@orpc/server": "^1.8.8",
|
||||
@@ -20,6 +22,7 @@
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"hono": "^4.9.4",
|
||||
"isomorphic-dompurify": "^2.16.0",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"openai": "^5.17.0",
|
||||
"react": "^19.1.1",
|
||||
|
791
pnpm-lock.yaml
generated
791
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -45,5 +45,20 @@ for i in {1..18}; do
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[7/7] Tail recent logs (press Ctrl+C to exit)"
|
||||
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f
|
||||
echo "[6b/7] Verify HTTP /health via Caddy (localhost)"
|
||||
for i in {1..12}; do
|
||||
if curl -fsS http://localhost/health >/dev/null 2>&1; then
|
||||
echo "Caddy proxy responds OK on /health."
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[7/7] Tail recent logs for app and caddy (press Ctrl+C to exit)"
|
||||
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f stargirlnails &
|
||||
APP_LOG_PID=$!
|
||||
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f caddy &
|
||||
CADDY_LOG_PID=$!
|
||||
|
||||
trap "echo 'Stopping log tails...'; kill $APP_LOG_PID $CADDY_LOG_PID 2>/dev/null || true" INT TERM
|
||||
wait $APP_LOG_PID $CADDY_LOG_PID || true
|
||||
|
@@ -24,12 +24,12 @@ export function AdminAvailability() {
|
||||
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
|
||||
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
|
||||
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
|
||||
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||
input: {}
|
||||
})
|
||||
);
|
||||
const { data: timeOffPeriods } = useQuery(
|
||||
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
|
||||
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||
input: {}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -177,14 +177,9 @@ export function AdminAvailability() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
createRule(
|
||||
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
|
||||
{ dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
|
||||
@@ -233,13 +228,8 @@ export function AdminAvailability() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
toggleRuleActive(
|
||||
{ sessionId, id: rule.id },
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
|
||||
@@ -256,13 +246,8 @@ export function AdminAvailability() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
deleteRule(
|
||||
{ sessionId, id: rule.id },
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Regel gelöscht.");
|
||||
@@ -348,14 +333,9 @@ export function AdminAvailability() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
createTimeOff(
|
||||
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
|
||||
{ startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Urlaubszeit hinzugefügt.");
|
||||
@@ -415,13 +395,8 @@ export function AdminAvailability() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
deleteTimeOff(
|
||||
{ sessionId, id: period.id },
|
||||
{ id: period.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Urlaubszeit gelöscht.");
|
||||
|
@@ -255,7 +255,7 @@ export function AdminBookings() {
|
||||
{booking.status === "pending" && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
Confirm
|
||||
@@ -271,7 +271,7 @@ export function AdminBookings() {
|
||||
{booking.status === "confirmed" && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
Complete
|
||||
@@ -286,7 +286,7 @@ export function AdminBookings() {
|
||||
)}
|
||||
{(booking.status === "cancelled" || booking.status === "completed") && (
|
||||
<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"
|
||||
>
|
||||
Reactivate
|
||||
@@ -352,8 +352,7 @@ export function AdminBookings() {
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
updateBookingStatus({ sessionId, id: showCancelConfirm, status: "cancelled" });
|
||||
updateBookingStatus({ id: showCancelConfirm, status: "cancelled" });
|
||||
}}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
|
@@ -164,24 +164,18 @@ export function AdminCalendar() {
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (bookingId: string, newStatus: string) => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
updateBookingStatus({
|
||||
sessionId,
|
||||
id: bookingId,
|
||||
status: newStatus as "pending" | "confirmed" | "cancelled" | "completed"
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId || !showDeleteConfirm) return;
|
||||
if (!showDeleteConfirm) return;
|
||||
|
||||
if (deleteActionType === 'cancel') {
|
||||
// For cancel action, use updateStatus instead of remove
|
||||
updateBookingStatus({
|
||||
sessionId,
|
||||
id: showDeleteConfirm,
|
||||
status: "cancelled"
|
||||
}, {
|
||||
@@ -197,7 +191,6 @@ export function AdminCalendar() {
|
||||
} else {
|
||||
// For delete action, use remove with email option
|
||||
removeBooking({
|
||||
sessionId,
|
||||
id: showDeleteConfirm,
|
||||
sendEmail: sendDeleteEmail,
|
||||
}, {
|
||||
@@ -216,11 +209,8 @@ export function AdminCalendar() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const handleCreateBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
createManualBooking({
|
||||
sessionId,
|
||||
...createFormData
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
@@ -262,13 +252,11 @@ export function AdminCalendar() {
|
||||
};
|
||||
|
||||
const handleRescheduleBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId || !showRescheduleModal) return;
|
||||
if (!showRescheduleModal) return;
|
||||
const booking = bookings?.find(b => b.id === showRescheduleModal);
|
||||
if (!booking) return;
|
||||
|
||||
proposeReschedule({
|
||||
sessionId,
|
||||
bookingId: booking.id,
|
||||
proposedDate: rescheduleFormData.appointmentDate,
|
||||
proposedTime: rescheduleFormData.appointmentTime,
|
||||
@@ -285,11 +273,8 @@ export function AdminCalendar() {
|
||||
};
|
||||
|
||||
const handleGenerateCalDAVToken = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
generateCalDAVToken({
|
||||
sessionId
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
setCaldavData(data);
|
||||
|
@@ -14,7 +14,7 @@ export function AdminGallery() {
|
||||
// Data fetching with live query
|
||||
const { data: photos, refetch: refetchPhotos } = useQuery(
|
||||
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
|
||||
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||
input: {}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -166,12 +166,6 @@ export function AdminGallery() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
setDraggedPhotoId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a fully reordered list based on the current sorted order
|
||||
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
|
||||
@@ -191,7 +185,6 @@ export function AdminGallery() {
|
||||
|
||||
updatePhotoOrder(
|
||||
{
|
||||
sessionId,
|
||||
photoOrders
|
||||
},
|
||||
{
|
||||
@@ -304,15 +297,9 @@ export function AdminGallery() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadPhoto(
|
||||
{
|
||||
sessionId,
|
||||
base64Data: photoBase64,
|
||||
title: photoTitle || undefined
|
||||
},
|
||||
@@ -396,13 +383,8 @@ export function AdminGallery() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
deletePhoto(
|
||||
{ sessionId, id: photo.id },
|
||||
{ id: photo.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Foto gelöscht.");
|
||||
@@ -425,13 +407,8 @@ export function AdminGallery() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
setCoverPhoto(
|
||||
{ sessionId, id: photo.id },
|
||||
{ id: photo.id },
|
||||
{
|
||||
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
|
||||
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),
|
||||
|
@@ -91,18 +91,17 @@ export function AdminReviews() {
|
||||
}
|
||||
}, [successMsg]);
|
||||
|
||||
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
|
||||
|
||||
const { data: reviews } = useQuery(
|
||||
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
|
||||
input: { sessionId, statusFilter: activeStatusTab },
|
||||
input: { statusFilter: activeStatusTab },
|
||||
})
|
||||
);
|
||||
|
||||
// Separate queries for quick stats calculation
|
||||
const { data: allReviews } = useQuery(
|
||||
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
|
||||
input: { sessionId },
|
||||
input: {},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -266,13 +265,13 @@ export function AdminReviews() {
|
||||
{review.status === "pending" && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
Genehmigen
|
||||
</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"
|
||||
>
|
||||
Ablehnen
|
||||
@@ -289,7 +288,7 @@ export function AdminReviews() {
|
||||
{review.status === "approved" && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
Ablehnen
|
||||
@@ -306,7 +305,7 @@ export function AdminReviews() {
|
||||
{review.status === "rejected" && (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
Genehmigen
|
||||
@@ -334,7 +333,7 @@ export function AdminReviews() {
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<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"
|
||||
>
|
||||
Ja, löschen
|
||||
|
@@ -11,7 +11,6 @@ interface User {
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
sessionId: string | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -34,7 +33,6 @@ interface AuthProviderProps {
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { mutateAsync: loginMutation } = useMutation(
|
||||
@@ -50,56 +48,45 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session on app load
|
||||
const storedSessionId = localStorage.getItem("sessionId");
|
||||
if (storedSessionId) {
|
||||
verifySessionMutation(storedSessionId)
|
||||
.then((result) => {
|
||||
setUser(result.user);
|
||||
setSessionId(storedSessionId);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("sessionId");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// Check for existing session on app load - session comes from cookies
|
||||
verifySessionMutation({})
|
||||
.then((result) => {
|
||||
setUser(result.user);
|
||||
})
|
||||
.catch(() => {
|
||||
// Session invalid or expired - user remains null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [verifySessionMutation]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const result = await loginMutation({ username, password });
|
||||
setUser(result.user);
|
||||
setSessionId(result.sessionId);
|
||||
localStorage.setItem("sessionId", result.sessionId);
|
||||
// Cookies are set automatically by the server
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (sessionId) {
|
||||
try {
|
||||
await logoutMutation(sessionId);
|
||||
} catch (error) {
|
||||
// Continue with logout even if server call fails
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
try {
|
||||
await logoutMutation({});
|
||||
// Cookies are cleared automatically by the server
|
||||
} catch (error) {
|
||||
// Continue with logout even if server call fails
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setSessionId(null);
|
||||
localStorage.removeItem("sessionId");
|
||||
};
|
||||
|
||||
const isOwner = user?.role === "owner";
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
sessionId,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
|
@@ -4,7 +4,7 @@ import { useAuth } from "@/client/components/auth-provider";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function UserProfile() {
|
||||
const { user, sessionId, logout } = useAuth();
|
||||
const { user, logout } = useAuth();
|
||||
const [showPasswordChange, setShowPasswordChange] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
@@ -31,13 +31,7 @@ export function UserProfile() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
setError("Keine gültige Sitzung");
|
||||
return;
|
||||
}
|
||||
|
||||
changePassword({
|
||||
sessionId,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}, {
|
||||
|
@@ -5,8 +5,32 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
|
||||
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 queryClient = createTanstackQueryUtils(rpcClient);
|
||||
|
||||
// Export helper for potential use in other parts of the client code
|
||||
export { getCSRFToken };
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { cors } from 'hono/cors';
|
||||
|
||||
import { rpcApp } from "./routes/rpc.js";
|
||||
import { caldavApp } from "./routes/caldav.js";
|
||||
@@ -8,10 +9,58 @@ import { clientEntry } from "./routes/client-entry.js";
|
||||
|
||||
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) => {
|
||||
// Accept requests from any host
|
||||
return next();
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const directives = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self'${isDev ? " 'unsafe-inline'" : ''}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
];
|
||||
const csp = directives.join('; ');
|
||||
c.header('Content-Security-Policy', csp);
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
await next();
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
|
@@ -1,17 +1,104 @@
|
||||
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 };
|
||||
|
||||
export const sessionsKV = createKV<Session>("sessions");
|
||||
export const usersKV = createKV<User>("users");
|
||||
|
||||
export 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");
|
||||
// Cookie configuration constants
|
||||
export const SESSION_COOKIE_NAME = 'sessionId';
|
||||
export const CSRF_COOKIE_NAME = 'csrf-token';
|
||||
export const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax' as const,
|
||||
path: '/',
|
||||
maxAge: 86400 // 24 hours
|
||||
};
|
||||
|
||||
// CSRF token generation
|
||||
export function generateCSRFToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Session extraction from cookies
|
||||
export async function getSessionFromCookies(c: Context): Promise<Session | null> {
|
||||
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
|
||||
if (!sessionId) return null;
|
||||
|
||||
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 };
|
||||
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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 }) {
|
||||
const { name, date, time, statusUrl } = params;
|
||||
const safeName = sanitizeText(name);
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const legalUrl = `${protocol}://${domain}/legal`;
|
||||
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>Hallo ${safeName},</p>
|
||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||
${statusUrl ? `
|
||||
@@ -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 }) {
|
||||
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
||||
const safeName = sanitizeText(name);
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const legalUrl = `${protocol}://${domain}/legal`;
|
||||
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>Hallo ${safeName},</p>
|
||||
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||
<p>Wir freuen uns auf dich!</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
@@ -126,13 +129,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
||||
|
||||
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
|
||||
const { name, date, time } = params;
|
||||
const safeName = sanitizeText(name);
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const legalUrl = `${protocol}://${domain}/legal`;
|
||||
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
<p>Hallo ${safeName},</p>
|
||||
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
@@ -154,6 +158,10 @@ export async function renderAdminBookingNotificationHTML(params: {
|
||||
hasInspirationPhoto: boolean;
|
||||
}) {
|
||||
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
||||
const safeName = sanitizeText(name);
|
||||
const safeTreatment = sanitizeText(treatment);
|
||||
const safePhone = sanitizePhone(phone);
|
||||
const safeNotes = sanitizeHtml(notes);
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
@@ -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;">
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||
<li><strong>Name:</strong> ${name}</li>
|
||||
<li><strong>Telefon:</strong> ${phone}</li>
|
||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
||||
<li><strong>Name:</strong> ${safeName}</li>
|
||||
<li><strong>Telefon:</strong> ${safePhone}</li>
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||
<li><strong>Datum:</strong> ${formattedDate}</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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -188,13 +196,15 @@ export async function renderBookingRescheduleProposalHTML(params: {
|
||||
declineUrl: string;
|
||||
expiresAt: string;
|
||||
}) {
|
||||
const safeName = sanitizeText(params.name);
|
||||
const safeTreatment = sanitizeText(params.treatmentName);
|
||||
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||
const expiryDate = new Date(params.expiresAt);
|
||||
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
const inner = `
|
||||
<p>Hallo ${params.name},</p>
|
||||
<p>Hallo ${safeName},</p>
|
||||
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||
@@ -209,7 +219,7 @@ export async function renderBookingRescheduleProposalHTML(params: {
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -239,15 +249,19 @@ export async function renderAdminRescheduleDeclinedHTML(params: {
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
}) {
|
||||
const safeCustomerName = sanitizeText(params.customerName);
|
||||
const safeTreatment = sanitizeText(params.treatmentName);
|
||||
const safeEmail = params.customerEmail ? sanitizeText(params.customerEmail) : undefined;
|
||||
const safePhone = params.customerPhone ? sanitizeText(params.customerPhone) : undefined;
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${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;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||
</ul>
|
||||
@@ -265,13 +279,15 @@ export async function renderAdminRescheduleAcceptedHTML(params: {
|
||||
newTime: string;
|
||||
treatmentName: string;
|
||||
}) {
|
||||
const safeCustomerName = sanitizeText(params.customerName);
|
||||
const safeTreatment = sanitizeText(params.treatmentName);
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${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;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||
</ul>
|
||||
@@ -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>
|
||||
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||
${params.expiredProposals.map(proposal => `
|
||||
${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;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||
<li><strong>Kunde:</strong> ${safeName}</li>
|
||||
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;}).join('')}
|
||||
</div>
|
||||
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||
|
@@ -137,20 +137,29 @@ export function checkBookingRateLimit(params: {
|
||||
/**
|
||||
* 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
|
||||
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) {
|
||||
// x-forwarded-for can contain multiple IPs, take the first one
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIP = headers['x-real-ip'];
|
||||
const realIP = get('x-real-ip');
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
|
||||
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||
const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
|
||||
if (cfConnectingIP) {
|
||||
return cfConnectingIP;
|
||||
}
|
||||
@@ -159,4 +168,117 @@ export function getClientIP(headers: Record<string, string | undefined>): string
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
37
src/server/lib/sanitize.ts
Normal file
37
src/server/lib/sanitize.ts
Normal 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, "");
|
||||
}
|
||||
|
||||
|
@@ -31,6 +31,12 @@ type Treatment = {
|
||||
const bookingsKV = createKV<Booking>("bookings");
|
||||
const treatmentsKV = createKV<Treatment>("treatments");
|
||||
const sessionsKV = createKV<any>("sessions");
|
||||
const caldavTokensKV = createKV<{
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}>("caldavTokens");
|
||||
|
||||
export const caldavApp = new Hono();
|
||||
|
||||
@@ -44,6 +50,15 @@ function formatDateTime(dateStr: string, timeStr: string): string {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
// Helper to add minutes to an HH:MM time string and return HH:MM
|
||||
function addMinutesToTime(timeStr: string, minutesToAdd: number): string {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
const total = hours * 60 + minutes + minutesToAdd;
|
||||
const endHours = Math.floor(total / 60) % 24;
|
||||
const endMinutes = total % 60;
|
||||
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
|
||||
@@ -68,9 +83,8 @@ X-WR-TIMEZONE:Europe/Berlin
|
||||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
||||
const endTime = formatDateTime(booking.appointmentDate,
|
||||
`${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`
|
||||
);
|
||||
const computedEnd = addMinutesToTime(booking.appointmentTime, duration);
|
||||
const endTime = formatDateTime(booking.appointmentDate, computedEnd);
|
||||
|
||||
// UID für jeden Termin (eindeutig)
|
||||
const uid = `booking-${booking.id}@stargirlnails.de`;
|
||||
@@ -96,6 +110,60 @@ END:VEVENT
|
||||
return ics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate CalDAV token from Authorization header or query parameter (legacy)
|
||||
* @param c Hono context
|
||||
* @returns { token: string; source: 'bearer'|'basic'|'query' } | null
|
||||
*/
|
||||
function extractCalDAVToken(c: any): { token: string; source: 'bearer'|'basic'|'query' } | null {
|
||||
// UUID v4 pattern for hardening (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
|
||||
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Prefer Authorization header (new secure methods: Bearer or Basic)
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (authHeader) {
|
||||
// Bearer
|
||||
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (bearerMatch) {
|
||||
const token = bearerMatch[1].trim();
|
||||
if (!uuidV4Regex.test(token)) {
|
||||
console.warn('CalDAV: Bearer token does not match UUID v4 format.');
|
||||
return null;
|
||||
}
|
||||
return { token, source: 'bearer' };
|
||||
}
|
||||
|
||||
// Basic (use username or password as token)
|
||||
const basicMatch = authHeader.match(/^Basic\s+(.+)$/i);
|
||||
if (basicMatch) {
|
||||
try {
|
||||
const decoded = Buffer.from(basicMatch[1], 'base64').toString('utf8');
|
||||
// Format: username:password (password optional)
|
||||
const [username, password] = decoded.split(':');
|
||||
const candidate = (username && username.trim().length > 0)
|
||||
? username.trim()
|
||||
: (password ? password.trim() : '');
|
||||
if (candidate && uuidV4Regex.test(candidate)) {
|
||||
return { token: candidate, source: 'basic' };
|
||||
}
|
||||
console.warn('CalDAV: Basic auth credential does not contain a valid UUID v4 token.');
|
||||
} catch (e) {
|
||||
console.warn('CalDAV: Failed to decode Basic auth header');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to query parameter (legacy, will be deprecated)
|
||||
const queryToken = c.req.query('token');
|
||||
if (queryToken) {
|
||||
console.warn('CalDAV: Token passed via query parameter (deprecated). Please use Authorization header.');
|
||||
return { token: queryToken, source: 'query' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// CalDAV Discovery (PROPFIND auf Root)
|
||||
caldavApp.all("/", async (c) => {
|
||||
if (c.req.method !== 'PROPFIND') {
|
||||
@@ -184,42 +252,54 @@ caldavApp.all("/calendar/events.ics", async (c) => {
|
||||
// GET Calendar Data (ICS-Datei)
|
||||
caldavApp.get("/calendar/events.ics", async (c) => {
|
||||
try {
|
||||
// Authentifizierung über Token im Query-Parameter
|
||||
const token = c.req.query('token');
|
||||
if (!token) {
|
||||
return c.text('Unauthorized - Token required', 401);
|
||||
// Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
|
||||
const tokenResult = extractCalDAVToken(c);
|
||||
if (!tokenResult) {
|
||||
return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, {
|
||||
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||
});
|
||||
}
|
||||
|
||||
// Token validieren
|
||||
const tokenData = await sessionsKV.getItem(token);
|
||||
// Validate token against caldavTokens KV store
|
||||
const tokenData = await caldavTokensKV.getItem(tokenResult.token);
|
||||
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)
|
||||
// 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
|
||||
// Check token expiration
|
||||
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 treatments = await treatmentsKV.getAllItems();
|
||||
|
||||
const icsContent = generateICSContent(bookings, treatments);
|
||||
|
||||
return c.text(icsContent, 200, {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
});
|
||||
};
|
||||
|
||||
// If legacy query token was used, inform clients about deprecation
|
||||
if (tokenResult.source === 'query') {
|
||||
headers["Deprecation"] = "true";
|
||||
headers["Warning"] = "299 - \"Query parameter token authentication is deprecated. Use Authorization header (Bearer or Basic).\"";
|
||||
}
|
||||
|
||||
return c.text(icsContent, 200, headers);
|
||||
} catch (error) {
|
||||
console.error("CalDAV GET error:", error);
|
||||
return c.text('Internal Server Error', 500);
|
||||
|
@@ -11,6 +11,7 @@ rpcApp.all("/*", async (c) => {
|
||||
try {
|
||||
const { matched, response } = await handler.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: c,
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
|
@@ -1,8 +1,25 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import type { Context } from "hono";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { config } from "dotenv";
|
||||
import bcrypt from "bcrypt";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { checkLoginRateLimit, getClientIP, resetLoginRateLimit } from "../lib/rate-limiter.js";
|
||||
import {
|
||||
generateCSRFToken,
|
||||
getSessionFromCookies,
|
||||
validateCSRFToken,
|
||||
rotateSession,
|
||||
COOKIE_OPTIONS,
|
||||
SESSION_COOKIE_NAME,
|
||||
CSRF_COOKIE_NAME,
|
||||
sessionsKV,
|
||||
usersKV,
|
||||
type Session,
|
||||
type User
|
||||
} from "../lib/auth.js";
|
||||
|
||||
// Load environment variables from .env file
|
||||
config();
|
||||
@@ -21,26 +38,68 @@ const SessionSchema = z.object({
|
||||
userId: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
csrfToken: z.string().optional(),
|
||||
});
|
||||
|
||||
type User = z.output<typeof UserSchema>;
|
||||
type Session = z.output<typeof SessionSchema>;
|
||||
// Use shared KV stores from auth.ts to avoid duplication
|
||||
|
||||
const usersKV = createKV<User>("users");
|
||||
const sessionsKV = createKV<Session>("sessions");
|
||||
// Password hashing using bcrypt
|
||||
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
|
||||
|
||||
// Simple password hashing (in production, use bcrypt or similar)
|
||||
const hashPassword = (password: string): string => {
|
||||
return Buffer.from(password).toString('base64');
|
||||
const isBase64Hash = (hash: string): boolean => {
|
||||
if (hash.startsWith(BCRYPT_PREFIX)) return false;
|
||||
try {
|
||||
const decoded = Buffer.from(hash, 'base64');
|
||||
// If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64
|
||||
const reencoded = decoded.toString('base64');
|
||||
// Additionally ensure that decoding does not produce too short/empty unless original was empty
|
||||
return reencoded === hash && decoded.toString('utf8').length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyPassword = (password: string, hash: string): boolean => {
|
||||
return hashPassword(password) === hash;
|
||||
const hashPassword = async (password: string): Promise<string> => {
|
||||
return bcrypt.hash(password, 10);
|
||||
};
|
||||
|
||||
const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
|
||||
if (hash.startsWith(BCRYPT_PREFIX)) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
if (isBase64Hash(hash)) {
|
||||
const base64OfPassword = Buffer.from(password).toString('base64');
|
||||
return base64OfPassword === hash;
|
||||
}
|
||||
// Unknown format -> fail closed
|
||||
return false;
|
||||
};
|
||||
|
||||
// Export hashPassword for external use (e.g., generating hashes for .env)
|
||||
export const generatePasswordHash = hashPassword;
|
||||
|
||||
// Migrate all legacy Base64 password hashes to bcrypt on server startup
|
||||
const migrateLegacyHashesOnStartup = async (): Promise<void> => {
|
||||
const users = await usersKV.getAllItems();
|
||||
let migratedCount = 0;
|
||||
for (const user of users) {
|
||||
if (isBase64Hash(user.passwordHash)) {
|
||||
try {
|
||||
const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8');
|
||||
const bcryptHash = await hashPassword(plaintext);
|
||||
const updatedUser: User = { ...user, passwordHash: bcryptHash };
|
||||
await usersKV.setItem(user.id, updatedUser);
|
||||
migratedCount += 1;
|
||||
} catch {
|
||||
// ignore individual failures; continue with others
|
||||
}
|
||||
}
|
||||
}
|
||||
if (migratedCount > 0) {
|
||||
console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize default owner account
|
||||
const initializeOwner = async () => {
|
||||
const existingUsers = await usersKV.getAllItems();
|
||||
@@ -49,7 +108,12 @@ const initializeOwner = async () => {
|
||||
|
||||
// Get admin credentials from environment variables
|
||||
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 owner: User = {
|
||||
@@ -66,24 +130,52 @@ const initializeOwner = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on module load
|
||||
initializeOwner();
|
||||
// Initialize on module load: first migrate legacy hashes, then ensure owner exists
|
||||
(async () => {
|
||||
try {
|
||||
await migrateLegacyHashesOnStartup();
|
||||
} finally {
|
||||
await initializeOwner();
|
||||
}
|
||||
})();
|
||||
|
||||
const login = os
|
||||
.input(z.object({
|
||||
username: 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 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");
|
||||
}
|
||||
|
||||
// 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 csrfToken = generateCSRFToken();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
||||
|
||||
@@ -92,12 +184,22 @@ const login = os
|
||||
userId: user.id,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
csrfToken,
|
||||
};
|
||||
|
||||
await sessionsKV.setItem(sessionId, session);
|
||||
// Optional: Reset login attempts on successful login
|
||||
resetLoginRateLimit(ip);
|
||||
|
||||
// Set cookies in response
|
||||
setCookie(context, SESSION_COOKIE_NAME, sessionId, COOKIE_OPTIONS);
|
||||
setCookie(context, CSRF_COOKIE_NAME, csrfToken, {
|
||||
...COOKIE_OPTIONS,
|
||||
httpOnly: false, // CSRF token needs to be readable by JavaScript
|
||||
});
|
||||
|
||||
// Return only user object (no sessionId in response)
|
||||
return {
|
||||
sessionId,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -108,25 +210,28 @@ const login = os
|
||||
});
|
||||
|
||||
const logout = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
await sessionsKV.removeItem(input);
|
||||
.input(z.object({})) // No input needed - session comes from cookies
|
||||
.handler(async ({ context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
await sessionsKV.removeItem(session.id);
|
||||
}
|
||||
|
||||
// Clear both cookies with correct options
|
||||
setCookie(context, SESSION_COOKIE_NAME, '', { ...COOKIE_OPTIONS, maxAge: 0 });
|
||||
setCookie(context, CSRF_COOKIE_NAME, '', { ...COOKIE_OPTIONS, httpOnly: false, maxAge: 0 });
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const verifySession = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input);
|
||||
.input(z.object({})) // No input needed - session comes from cookies
|
||||
.handler(async ({ context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
await sessionsKV.removeItem(input);
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
@@ -144,12 +249,11 @@ const verifySession = os
|
||||
|
||||
const changePassword = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
@@ -159,16 +263,31 @@ const changePassword = os
|
||||
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");
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...user,
|
||||
passwordHash: hashPassword(input.newPassword),
|
||||
passwordHash: await hashPassword(input.newPassword),
|
||||
};
|
||||
|
||||
await usersKV.setItem(user.id, updatedUser);
|
||||
|
||||
// Implement session rotation after password change
|
||||
const newSession = await rotateSession(session.id, user.id);
|
||||
|
||||
// Set new session and CSRF cookies
|
||||
setCookie(context, SESSION_COOKIE_NAME, newSession.id, COOKIE_OPTIONS);
|
||||
setCookie(context, CSRF_COOKIE_NAME, newSession.csrfToken!, {
|
||||
...COOKIE_OPTIONS,
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
|
@@ -1,14 +1,17 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import type { Context } from "hono";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||
import { router as rootRouter } from "./index.js";
|
||||
import { router as rootRouter, os, call } from "./index.js";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
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 { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||
// Using centrally typed os and call from rpc/index
|
||||
|
||||
// Create a server-side client to call other RPC endpoints
|
||||
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
@@ -292,14 +295,26 @@ const create = os
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
// Sanitize user-provided fields before storage
|
||||
const sanitizedName = sanitizeText(input.customerName);
|
||||
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
|
||||
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
|
||||
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
...input,
|
||||
id,
|
||||
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
|
||||
status: "pending" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
} as Booking;
|
||||
|
||||
// Save the booking
|
||||
await kv.setItem(id, booking);
|
||||
@@ -313,7 +328,7 @@ const create = os
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingPendingHTML({
|
||||
name: input.customerName,
|
||||
name: sanitizedName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
statusUrl: bookingUrl
|
||||
@@ -321,7 +336,7 @@ const create = os
|
||||
await sendEmail({
|
||||
to: input.customerEmail,
|
||||
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,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -336,24 +351,24 @@ const create = os
|
||||
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||
|
||||
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||
name: input.customerName,
|
||||
name: sanitizedName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
treatment: treatmentName,
|
||||
phone: input.customerPhone || "Nicht angegeben",
|
||||
notes: input.notes,
|
||||
phone: sanitizedPhone || "Nicht angegeben",
|
||||
notes: sanitizedNotes,
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
|
||||
const homepageUrl = generateUrl();
|
||||
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||
`Name: ${sanitizedName}\n` +
|
||||
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
|
||||
`Behandlung: ${treatmentName}\n` +
|
||||
`Datum: ${formatDateGerman(input.appointmentDate)}\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` +
|
||||
`Zur Website: ${homepageUrl}\n\n` +
|
||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||
@@ -361,14 +376,14 @@ const create = os
|
||||
if (input.inspirationPhoto) {
|
||||
await sendEmailWithInspirationPhoto({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}, input.inspirationPhoto, input.customerName).catch(() => {});
|
||||
}, input.inspirationPhoto, sanitizedName).catch(() => {});
|
||||
} else {
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}).catch(() => {});
|
||||
@@ -384,26 +399,16 @@ const create = os
|
||||
});
|
||||
|
||||
// Owner check reuse (simple inline version)
|
||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||
const sessionsKV = createKV<Session>("sessions");
|
||||
const usersKV = createKV<User>("users");
|
||||
async function assertOwner(sessionId: string): Promise<void> {
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session) throw new Error("Invalid session");
|
||||
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
const updateStatus = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context as unknown as Context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context as unknown as Context);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
@@ -441,7 +446,7 @@ const updateStatus = os
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\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,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
@@ -460,7 +465,7 @@ const updateStatus = os
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
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,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
@@ -474,12 +479,13 @@ const updateStatus = os
|
||||
|
||||
const remove = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
sendEmail: z.boolean().optional().default(false)
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context as unknown as Context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context as unknown as Context);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
@@ -508,7 +514,7 @@ const remove = os
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
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,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
@@ -523,7 +529,6 @@ const remove = os
|
||||
// Admin-only manual booking creation (immediately confirmed)
|
||||
const createManual = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
@@ -532,9 +537,11 @@ const createManual = os
|
||||
appointmentTime: z.string(),
|
||||
notes: z.string().optional(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
// 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
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
@@ -577,16 +584,21 @@ const createManual = os
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
// Sanitize user-provided fields before storage (admin manual booking)
|
||||
const sanitizedName = sanitizeText(input.customerName);
|
||||
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
|
||||
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
|
||||
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
treatmentId: input.treatmentId,
|
||||
customerName: input.customerName,
|
||||
customerName: sanitizedName,
|
||||
customerEmail: input.customerEmail,
|
||||
customerPhone: input.customerPhone,
|
||||
customerPhone: sanitizedPhone,
|
||||
appointmentDate: input.appointmentDate,
|
||||
appointmentTime: input.appointmentTime,
|
||||
notes: input.notes,
|
||||
notes: sanitizedNotes,
|
||||
bookedDurationMinutes: treatment.duration,
|
||||
status: "confirmed" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
@@ -607,7 +619,7 @@ const createManual = os
|
||||
const homepageUrl = generateUrl();
|
||||
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: input.customerName,
|
||||
name: sanitizedName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
cancellationUrl: bookingUrl,
|
||||
@@ -617,13 +629,13 @@ const createManual = os
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: input.customerEmail!,
|
||||
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,
|
||||
}, {
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
durationMinutes: treatment.duration,
|
||||
customerName: input.customerName,
|
||||
customerName: sanitizedName,
|
||||
treatmentName: treatment.name
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -684,13 +696,12 @@ export const router = {
|
||||
// Admin proposes a reschedule for a confirmed booking
|
||||
proposeReschedule: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
bookingId: z.string(),
|
||||
proposedDate: z.string(),
|
||||
proposedTime: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context as unknown as Context);
|
||||
const booking = await kv.getItem(input.bookingId);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||||
@@ -865,32 +876,32 @@ export const router = {
|
||||
|
||||
// CalDAV Token für Admin generieren
|
||||
generateCalDAVToken: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
|
||||
// Generiere einen sicheren Token für CalDAV-Zugriff
|
||||
const token = randomUUID();
|
||||
|
||||
// Hole Session-Daten für Token-Erstellung
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
if (!session) throw new Error("Session nicht gefunden");
|
||||
// Hole Session-Daten aus Cookies
|
||||
const session = await getSessionFromCookies(context as unknown as Context);
|
||||
if (!session) throw new Error("Invalid session");
|
||||
|
||||
// Speichere Token mit Ablaufzeit (24 Stunden)
|
||||
const tokenData = {
|
||||
id: token,
|
||||
sessionId: input.sessionId,
|
||||
userId: session.userId, // Benötigt für Session-Typ
|
||||
userId: session.userId,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Verwende den sessionsKV Store für Token-Speicherung
|
||||
await sessionsKV.setItem(token, tokenData);
|
||||
// Dedizierten KV-Store für CalDAV-Token verwenden
|
||||
const caldavTokensKV = createKV<typeof tokenData>("caldavTokens");
|
||||
await caldavTokensKV.setItem(token, tokenData);
|
||||
|
||||
const domain = process.env.DOMAIN || 'localhost:3000';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
|
||||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
|
||||
|
||||
return {
|
||||
token,
|
||||
@@ -899,15 +910,44 @@ export const router = {
|
||||
instructions: {
|
||||
title: "CalDAV-Kalender abonnieren",
|
||||
steps: [
|
||||
"Kopiere die CalDAV-URL unten",
|
||||
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
|
||||
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
|
||||
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
|
||||
"- Apple Calendar: Abonnement → Neue Abonnements",
|
||||
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
|
||||
"Der Kalender wird automatisch aktualisiert"
|
||||
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
|
||||
"",
|
||||
"📋 Dein CalDAV-Token (kopieren):",
|
||||
token,
|
||||
"",
|
||||
"🔗 CalDAV-URL (ohne Token):",
|
||||
caldavUrl,
|
||||
"",
|
||||
"📱 Einrichtung nach Kalender-App:",
|
||||
"",
|
||||
"🍎 Apple Calendar (macOS/iOS):",
|
||||
"- Leider keine native Unterstützung für Authorization-Header",
|
||||
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
|
||||
"",
|
||||
"📧 Outlook:",
|
||||
"- Datei → Kontoeinstellungen → Internetkalender",
|
||||
"- URL eingeben (ohne Token)",
|
||||
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
|
||||
" Authorization: Bearer <DEIN_TOKEN>",
|
||||
"",
|
||||
"🌐 Google Calendar:",
|
||||
"- Andere Kalender → Von URL hinzufügen",
|
||||
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
|
||||
"- Alternative: Verwende Google Apps Script oder importiere manuell",
|
||||
"",
|
||||
"🦅 Thunderbird:",
|
||||
"- Kalender → Neuer Kalender → Im Netzwerk",
|
||||
"- Format: CalDAV",
|
||||
"- URL eingeben",
|
||||
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
|
||||
"",
|
||||
"💻 cURL-Beispiel zum Testen:",
|
||||
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
|
||||
"",
|
||||
"⏰ Token-Gültigkeit: 24 Stunden",
|
||||
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
|
||||
],
|
||||
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)."
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
@@ -2,7 +2,8 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
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
|
||||
const GalleryPhotoSchema = z.object({
|
||||
@@ -25,16 +26,17 @@ const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
|
||||
const uploadPhoto = os
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
base64Data: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
||||
title: z.string().optional().default(""),
|
||||
})
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context as any);
|
||||
const id = randomUUID();
|
||||
const existing = await galleryPhotosKV.getAllItems();
|
||||
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||
@@ -58,9 +60,11 @@ const uploadPhoto = os
|
||||
});
|
||||
|
||||
const setCoverPhoto = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context as any);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
let updatedCover: GalleryPhoto | null = null;
|
||||
for (const p of all) {
|
||||
@@ -73,21 +77,24 @@ const setCoverPhoto = os
|
||||
});
|
||||
|
||||
const deletePhoto = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context as any);
|
||||
await galleryPhotosKV.removeItem(input.id);
|
||||
});
|
||||
|
||||
const updatePhotoOrder = os
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
||||
})
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context as any);
|
||||
const updated: GalleryPhoto[] = [];
|
||||
for (const { id, order } of input.photoOrders) {
|
||||
const existing = await galleryPhotosKV.getItem(id);
|
||||
@@ -106,9 +113,9 @@ const listPhotos = os.handler(async () => {
|
||||
});
|
||||
|
||||
const adminListPhotos = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ context }) => {
|
||||
await assertOwner(context);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
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
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async function* ({ context, signal }) {
|
||||
await assertOwner(context);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
yield sorted;
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import { demo } from "./demo/index.js";
|
||||
import { os as baseOs, call as baseCall } from "@orpc/server";
|
||||
import type { Context } from "hono";
|
||||
import { router as treatments } from "./treatments.js";
|
||||
import { router as bookings } from "./bookings.js";
|
||||
import { router as auth } from "./auth.js";
|
||||
@@ -19,3 +21,9 @@ export const router = {
|
||||
gallery,
|
||||
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;
|
||||
|
@@ -2,7 +2,8 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
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
|
||||
const RecurringRuleSchema = z.object({
|
||||
@@ -87,15 +88,23 @@ function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string;
|
||||
const createRule = os
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
dayOfWeek: z.number().int().min(0).max(6),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
}).passthrough()
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
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
|
||||
const startMinutes = parseTime(input.startTime);
|
||||
@@ -132,9 +141,18 @@ const createRule = os
|
||||
});
|
||||
|
||||
const updateRule = os
|
||||
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(RecurringRuleSchema.passthrough())
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP((context.req as any).raw.headers as any);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validierung: startTime < endTime
|
||||
const startMinutes = parseTime(input.startTime);
|
||||
@@ -152,22 +170,40 @@ const updateRule = os
|
||||
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);
|
||||
return rule as RecurringRule;
|
||||
});
|
||||
|
||||
const deleteRule = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP((context.req as any).raw.headers as any);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
await recurringRulesKV.removeItem(input.id);
|
||||
});
|
||||
|
||||
const toggleRuleActive = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP((context.req as any).raw.headers as any);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
const rule = await recurringRulesKV.getItem(input.id);
|
||||
if (!rule) throw new Error("Regel nicht gefunden.");
|
||||
|
||||
@@ -185,9 +221,9 @@ const listRules = os.handler(async () => {
|
||||
});
|
||||
|
||||
const adminListRules = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ context }) => {
|
||||
await assertOwner(context);
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
return allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||
@@ -199,15 +235,16 @@ const adminListRules = os
|
||||
const createTimeOff = os
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
reason: z.string(),
|
||||
})
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting direkt nach Owner-Check
|
||||
await enforceAdminRateLimit(context as any);
|
||||
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
@@ -232,24 +269,28 @@ const createTimeOff = os
|
||||
});
|
||||
|
||||
const updateTimeOff = os
|
||||
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(TimeOffPeriodSchema.passthrough())
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting direkt nach Owner-Check
|
||||
await enforceAdminRateLimit(context as any);
|
||||
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
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);
|
||||
return timeOff as TimeOffPeriod;
|
||||
});
|
||||
|
||||
const deleteTimeOff = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting direkt nach Owner-Check
|
||||
await enforceAdminRateLimit(context as any);
|
||||
await timeOffPeriodsKV.removeItem(input.id);
|
||||
});
|
||||
|
||||
@@ -259,9 +300,9 @@ const listTimeOff = os.handler(async () => {
|
||||
});
|
||||
|
||||
const adminListTimeOff = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ context }) => {
|
||||
await assertOwner(context);
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
});
|
||||
@@ -418,9 +459,9 @@ const live = {
|
||||
}),
|
||||
|
||||
adminListRules: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async function* ({ context, signal }) {
|
||||
await assertOwner(context);
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const sortedRules = allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||
@@ -438,9 +479,9 @@ const live = {
|
||||
}),
|
||||
|
||||
adminListTimeOff: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async function* ({ context, signal }) {
|
||||
await assertOwner(context);
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
yield sortedTimeOff;
|
||||
|
@@ -2,7 +2,8 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
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
|
||||
const ReviewSchema = z.object({
|
||||
@@ -133,22 +134,31 @@ const submitReview = os
|
||||
|
||||
// Admin Endpoint: approveReview
|
||||
const approveReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
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);
|
||||
if (!review) {
|
||||
throw new Error("Bewertung nicht gefunden");
|
||||
}
|
||||
|
||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||
const session2 = await getSessionFromCookies(context);
|
||||
const updatedReview = {
|
||||
...review,
|
||||
status: "approved" as const,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
reviewedBy: session2?.userId || review.reviewedBy,
|
||||
};
|
||||
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
@@ -161,22 +171,31 @@ const approveReview = os
|
||||
|
||||
// Admin Endpoint: rejectReview
|
||||
const rejectReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
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);
|
||||
if (!review) {
|
||||
throw new Error("Bewertung nicht gefunden");
|
||||
}
|
||||
|
||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||
const session2 = await getSessionFromCookies(context);
|
||||
const updatedReview = {
|
||||
...review,
|
||||
status: "rejected" as const,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
reviewedBy: session2?.userId || review.reviewedBy,
|
||||
};
|
||||
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
@@ -189,10 +208,19 @@ const rejectReview = os
|
||||
|
||||
// Admin Endpoint: deleteReview
|
||||
const deleteReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error("reviews.deleteReview error", err);
|
||||
@@ -225,13 +253,12 @@ const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
|
||||
const adminListReviews = os
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||
})
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const filtered = input.statusFilter === "all"
|
||||
@@ -258,12 +285,11 @@ const live = {
|
||||
adminListReviews: os
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||
})
|
||||
)
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async function* ({ input, context, signal }) {
|
||||
await assertOwner(context);
|
||||
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const filtered = input.statusFilter === "all"
|
||||
|
@@ -2,6 +2,8 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||
|
||||
const TreatmentSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -18,7 +20,10 @@ const kv = createKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.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 treatment = { id, ...input };
|
||||
await kv.setItem(id, treatment);
|
||||
@@ -27,12 +32,18 @@ const create = os
|
||||
|
||||
const update = os
|
||||
.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);
|
||||
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);
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user