CalDAV: Support Basic auth; trim+validate UUID; deprecate query token via headers; ICS end time helper; docs+instructions updated

This commit is contained in:
2025-10-06 17:25:25 +02:00
parent 90029f4b6a
commit 31b007d145
29 changed files with 2311 additions and 321 deletions

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

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