diff --git a/.dockerignore b/.dockerignore index 618f99b..19fdb94 100644 --- a/.dockerignore +++ b/.dockerignore @@ -90,3 +90,7 @@ coverage # Temporary folders tmp/ temp/ + +# Build artifacts and runtime data +.pnpm-store/ +.storage/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 024f8c3..ab2434a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,27 +10,15 @@ RUN pnpm config set enable-pre-post-scripts true WORKDIR /app -# Install all deps for building server -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# Copy full sources and build both client and server +COPY . . RUN pnpm install --frozen-lockfile --ignore-scripts=false --enable-pre-post-scripts \ - && pnpm rebuild bcrypt --build-from-source || true - -# Ensure Node types are available for server build -RUN pnpm add -D @types/node@^22 - -# Copy only server sources and tsconfig for server build -COPY src/server ./src/server -COPY tsconfig.server.json ./tsconfig.server.json -COPY tsconfig.server.build.json ./tsconfig.server.build.json -COPY tsconfig.json ./tsconfig.json - -# Build server only (no client build) -RUN tsc -p tsconfig.server.build.json + && pnpm run build FROM node:22-alpine AS production # Install pnpm, runtime tools and build deps for native modules present in prod deps -RUN npm install -g pnpm ts-node \ +RUN npm install -g pnpm \ && apk add --no-cache su-exec curl python3 make g++ libc6-compat ENV npm_config_build_from_source=1 \ @@ -45,11 +33,17 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # Install production dependencies only RUN pnpm install --frozen-lockfile --prod --ignore-scripts=false --enable-pre-post-scripts \ - && pnpm rebuild bcrypt --build-from-source || true + && echo "[production] Rebuilding bcrypt for Alpine Linux..." \ + && pnpm rebuild bcrypt --build-from-source \ + && echo "[production] Verifying bcrypt installation..." \ + && node -e "require('bcrypt')" \ + && echo "[production] Removing build toolchain to reduce image size..." \ + && apk del python3 make g++ -# Copy client build from context and server build from builder -COPY dist ./dist +# Copy built artifacts from builder +COPY --from=builder /app/dist ./dist COPY --from=builder /app/server-dist ./server-dist +# public wird aus dem Kontext kopiert COPY public ./public # Copy necessary runtime files @@ -62,12 +56,8 @@ RUN adduser -S nextjs -u 1001 # Make start script executable RUN chmod +x /app/start.sh -# Change ownership of the app directory (but keep root for .storage) -RUN chown -R nextjs:nodejs /app -RUN chown root:root /app/.storage 2>/dev/null || true - -# Don't switch to nextjs user here - the start script will handle it -# USER nextjs +# Change ownership of the app directory +RUN chown -R nextjs:nodejs /app || true # Expose port EXPOSE 3000 @@ -82,7 +72,7 @@ CMD ["/app/start.sh"] # Prebuilt runtime stage (used locally): copies prebuilt dist and server-dist from context FROM node:22-alpine AS production-prebuilt -RUN npm install -g pnpm ts-node \ +RUN npm install -g pnpm \ && apk add --no-cache su-exec curl python3 make g++ libc6-compat ENV npm_config_build_from_source=1 \ @@ -92,8 +82,10 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile --prod --ignore-scripts=false --enable-pre-post-scripts \ - && pnpm add --prod hono@^4.9.4 @hono/node-server@^1.19.5 \ - && pnpm rebuild bcrypt --build-from-source || true + && echo "[production-prebuilt] Rebuilding bcrypt for Alpine Linux..." \ + && pnpm rebuild bcrypt --build-from-source \ + && echo "[production-prebuilt] Verifying bcrypt installation..." \ + && node -e "require('bcrypt')" # Copy prebuilt artifacts from repository COPY dist ./dist @@ -103,8 +95,7 @@ COPY start.sh ./start.sh RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 \ && chmod +x /app/start.sh \ - && chown -R nextjs:nodejs /app \ - && chown root:root /app/.storage 2>/dev/null || true + && chown -R nextjs:nodejs /app || true EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/README.md b/README.md index f11635c..0d2d445 100644 --- a/README.md +++ b/README.md @@ -100,20 +100,51 @@ pnpm dev ## Docker Deployment -### Docker Build +### Lokale Entwicklung mit Docker + +Für schnelles lokales Testing mit vorgebauten Artefakten: ```bash -# Docker Image erstellen -docker build -t stargirlnails-booking . +# 1. Baue Client und Server lokal +pnpm install --frozen-lockfile +pnpm run build -# Container starten -docker run -d \ - --name stargirlnails-app \ - -p 3000:3000 \ - --env-file .env \ - stargirlnails-booking +# 2. Baue und starte Container (nutzt production-prebuilt Stage) +docker compose build +docker compose up -d ``` +### Production-Deployment + +Für Remote-Deployment (baut alles im Container): + +```bash +# Nutzt production Stage für vollständigen Build +docker-compose -f docker-compose-prod.yml up --build -d +``` + +**Wichtig:** `docker-compose-prod.yml` baut Client und Server im Container und benötigt keine lokalen Builds. + +### Docker Build-Stages + +Das Dockerfile definiert zwei relevante Workflows: +- `production`: Vollständiger Build (Client + Server) innerhalb von Docker – langsamer, ideal für CI/CD +- `production-prebuilt`: Lokaler Build der Artefakte (`dist/`, `server-dist/`) und Kopie in das Image – schneller für lokale Entwicklung + +Warum `dist/` und `server-dist/` nicht in `.dockerignore` gehören: Diese Verzeichnisse werden im `production-prebuilt`-Workflow benötigt, damit sie beim `docker compose build` in das Image kopiert werden können. + +Details siehe [`docs/docker-build-stages.md`](docs/docker-build-stages.md) + +### Native Module Hinweise (bcrypt) + +Die App verwendet das native Node-Modul `bcrypt`, das für Alpine Linux kompiliert werden muss. + +- Beim ersten Start oder nach Dependency-Updates kann ein Rebuild notwendig sein. +- In der `production` Stage wird `bcrypt` während des Docker-Builds kompiliert und verifiziert. +- In der `production-prebuilt` Stage erfolgt eine Verifikation/Rebuild beim Container-Start, und der Start bricht ab, wenn `bcrypt` fehlt. + +Siehe ausführliche Hinweise und Troubleshooting in [`docs/docker-build-stages.md`](docs/docker-build-stages.md). + ### Docker Compose (empfohlen) Erstelle eine `docker-compose.yml` Datei: @@ -170,6 +201,29 @@ chmod +x scripts/setup-caddy.sh - Überwache Container mit Health Checks - **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten +### Docker-Container startet nicht + +**Symptom:** Container startet mit "Cannot find module" Fehlern + +**Lösung:** +- Lokal: Stelle sicher, dass `pnpm build` vor `docker-compose up` ausgeführt wurde +- Production: Prüfe, dass `docker-compose-prod.yml` den `target: production` verwendet +- Siehe [`docs/docker-build-stages.md`](docs/docker-build-stages.md) für Details +- Nach Sicherheits- oder Dependency-Updates: `docker compose down && docker compose build --no-cache && docker compose up -d` +- Verifikation: `docker compose exec stargirlnails node -e "require('bcrypt')"` + +### Development-Workflow mit nativen Modulen + +Wenn du ein natives Modul wie `bcrypt` hinzufügst oder updatest, ist ein Container-Rebuild erforderlich. Nutze dafür die Scripts: + +```bash +# Linux/macOS +./scripts/rebuild-dev.sh + +# Windows +scripts\rebuild-dev.cmd +``` + ## Features ### Buchungssystem diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 71b8c26..13b785a 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -4,7 +4,9 @@ services: # Hauptanwendung stargirlnails: - build: . + build: + context: . + target: production container_name: stargirlnails-app env_file: - .env diff --git a/docker-compose.yml b/docker-compose.yml index 7dc8128..72107f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,3 +19,4 @@ services: environment: - NODE_ENV=production - DISABLE_DUPLICATE_CHECK=true + - ALLOW_RUNTIME_INSTALL=true diff --git a/docs/docker-build-stages.md b/docs/docker-build-stages.md new file mode 100644 index 0000000..685caea --- /dev/null +++ b/docs/docker-build-stages.md @@ -0,0 +1,181 @@ +# Docker Build Stages + +Das Dockerfile definiert drei Build-Stages für unterschiedliche Deployment-Szenarien. + +## Stages Übersicht + +### 1. `builder` +**Zweck:** Baut sowohl Client als auch Server-Artefakte + +**Verwendung:** Wird als Basis für `production` Stage verwendet + +**Prozess:** +- Installiert alle Dependencies (inkl. devDependencies) +- Baut Client mit Vite (`dist/`) +- Baut Server mit TypeScript (`server-dist/`) +- Kompiliert native Module (bcrypt) für Alpine Linux + +--- + +### 2. `production` +**Zweck:** Vollständiger Build für Remote-Deployment (CI/CD, Production-Server) + +**Verwendung:** +```yaml +# docker-compose-prod.yml +build: + context: . + target: production +``` + +**Prozess:** +1. Kopiert Source-Code (Client + Server) in Container +2. Baut Client mit Vite (`dist/`) +3. Baut Server mit TypeScript (`server-dist/`) +4. Installiert nur Production-Dependencies +5. Kompiliert native Module (bcrypt) für Alpine Linux +6. Verifiziert bcrypt Installation + +**Vorteile:** +- ✅ Vollständig autark (keine lokalen Builds nötig) +- ✅ Reproduzierbare Builds +- ✅ Ideal für CI/CD-Pipelines + +**Nachteile:** +- ⏱️ Längere Build-Zeit (Client + Server werden im Container gebaut) + +--- + +### 3. `production-prebuilt` +**Zweck:** Schnelles lokales Testing mit vorgebauten Artefakten + +**Verwendung:** +```yaml +# docker-compose.yml (lokal) +build: + context: . + target: production-prebuilt +``` + +**Voraussetzungen:** +- `dist/` und `server-dist/` müssen bereits existieren +- `.dockerignore` darf diese Verzeichnisse NICHT ausschließen +- Lokal ausführen: `pnpm run build` vor `docker compose build/up` + +**Prozess:** +1. Kopiert vorgebaute `dist/` und `server-dist/` aus Build-Context +2. Installiert nur Production-Dependencies +3. Kompiliert native Module (bcrypt) für Alpine Linux +4. Verifiziert bcrypt Installation +5. Bei Fehlern schlägt der Build fehl + +**Vorteile:** +- ⚡ Sehr schnelle Container-Builds (kein TypeScript/Vite-Build) +- 🔄 Ideal für lokale Entwicklung und Testing + +**Nachteile:** +- ❌ Benötigt lokale Builds vor Docker-Build +- ❌ Nicht geeignet für Remote-Deployment + +--- + +## Verwendungsempfehlungen + +### Lokale Entwicklung +```bash +# 1. Baue Artefakte lokal (Client + Server) +pnpm install --frozen-lockfile +pnpm run build # erzeugt dist/ und server-dist/ + +# 2. Baue & starte Container mit production-prebuilt +docker compose build +docker compose up -d +``` + +### Production-Deployment +```bash +# Nutzt production Stage (baut alles im Container) +docker-compose -f docker-compose-prod.yml up --build +``` + +### CI/CD-Pipeline +```bash +# Explizit production Stage verwenden +docker build --target production -t myapp:latest . +``` + +--- + +## Troubleshooting + +### Problem: "Cannot find module 'bcrypt'" +**Ursache:** Native Module (bcrypt) nicht korrekt für Alpine Linux kompiliert. + +**Lösung:** Der Build schlägt jetzt absichtlich fehl, wenn `bcrypt` nicht kompiliert werden kann. Prüfe die Build-Logs und stelle sicher, dass Build-Tools (python3, make, g++) installiert sind. Im `production-prebuilt`-Szenario erfolgt ein Rebuild beim Container-Start. + +### Problem: "dist/ not found" im production-prebuilt Stage +**Ursache:** Lokale Builds fehlen + +**Lösung:** Führe `pnpm build` vor `docker-compose up` aus + +### Problem: Container startet nicht in Production +**Ursache:** docker-compose-prod.yml nutzt falschen Stage + +**Lösung:** Setze explizit `target: production` in docker-compose-prod.yml + +--- + +## Native Module (bcrypt) + +bcrypt ist ein natives Node.js-Modul, das für die jeweilige Plattform kompiliert werden muss. + +**Im Dockerfile (Build & Verifikation):** +```dockerfile +# Build-Dependencies für native Module +RUN apk add --no-cache python3 make g++ libc6-compat + +# Nach pnpm install: bcrypt neu kompilieren (Fehler NICHT unterdrücken) und verifizieren +RUN echo "[production] Rebuilding bcrypt for Alpine Linux..." \ + && pnpm rebuild bcrypt --build-from-source \ + && echo "[production] Verifying bcrypt installation..." \ + && node -e "require('bcrypt')" +``` + +**Im Startup (production-prebuilt):** +Der Container verifiziert vor dem Start, dass `bcrypt` verfügbar ist, und bricht mit klarer Fehlermeldung ab, falls nicht. + +--- + +## Troubleshooting: Native Module (bcrypt) + +### Problem: bcrypt_lib.node nicht gefunden + +**Symptom**: Container startet mit Fehler: +``` +Error: Cannot find module '/app/node_modules/.pnpm/bcrypt@5.1.1/node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node' +``` + +**Ursache**: Native Module wie bcrypt müssen für die Zielplattform (Alpine Linux) kompiliert werden. Wenn lokal auf Windows/Mac gebaut wird, sind die Bindings inkompatibel. + +**Lösung**: +1. **Für lokale Entwicklung** (docker-compose.yml mit `production-prebuilt`): + - Der Container rebuildet bcrypt automatisch beim Start + - Prüfe Logs: `docker compose logs stargirlnails | grep bcrypt` + - Bei Fehlern: `docker compose down && docker compose build --no-cache` + +2. **Für Production** (docker-compose-prod.yml mit `production`): + - bcrypt wird während des Docker-Builds kompiliert + - Build-Tools (python3, make, g++) sind im Image vorhanden + - Bei Fehlern: Prüfe Build-Logs auf Compiler-Fehler + +3. **Manuelle Verifikation**: +```bash +docker compose exec stargirlnails node -e "require('bcrypt')" +``` +Sollte ohne Fehler durchlaufen. + +### Best Practices + +- Niemals `|| true` bei native Module Rebuilds verwenden +- Immer Build-Tools im Production-Image behalten (python3, make, g++) +- Testen nach jedem Dependency-Update mit `--no-cache` Build diff --git a/scripts/rebuild-dev.cmd b/scripts/rebuild-dev.cmd index 77cf5d5..6aac8b7 100644 --- a/scripts/rebuild-dev.cmd +++ b/scripts/rebuild-dev.cmd @@ -1,6 +1,29 @@ @echo off + docker compose -f docker-compose.yml down +if errorlevel 1 exit /b 1 + git pull +if errorlevel 1 exit /b 1 + +echo Building application locally... +pnpm install --frozen-lockfile +if errorlevel 1 exit /b 1 + +pnpm run build +if errorlevel 1 exit /b 1 + docker compose -f docker-compose.yml build +if errorlevel 1 exit /b 1 + docker compose -f docker-compose.yml up -d +if errorlevel 1 exit /b 1 + +echo Waiting for container to start... +@timeout /t 5 /nobreak >nul + +echo Verifying bcrypt installation in container... +docker compose -f docker-compose.yml exec stargirlnails node -e "require('bcrypt')" && echo bcrypt OK || echo bcrypt FAILED +if errorlevel 1 exit /b 1 + docker compose -f docker-compose.yml logs -f stargirlnails diff --git a/scripts/rebuild-dev.sh b/scripts/rebuild-dev.sh index 3778e0a..7e1e265 100755 --- a/scripts/rebuild-dev.sh +++ b/scripts/rebuild-dev.sh @@ -1,5 +1,17 @@ #! /bin/bash +set -e + +# Exit on error to catch build failures early docker compose -f docker-compose.yml down + +echo "Building application locally..." +pnpm install --frozen-lockfile || exit 1 +pnpm run build || exit 1 + docker compose -f docker-compose.yml build --no-cache docker compose -f docker-compose.yml up -d -# docker compose -f docker-compose.yml logs -f stargirlnails + +echo "Waiting for container to start..." +sleep 5 +echo "Verifying bcrypt installation in container..." +docker compose -f docker-compose.yml exec stargirlnails node -e "require('bcrypt')" && echo "✓ bcrypt OK" || echo "✗ bcrypt FAILED" diff --git a/scripts/rebuild-prod.sh b/scripts/rebuild-prod.sh index 2410f12..25108c5 100644 --- a/scripts/rebuild-prod.sh +++ b/scripts/rebuild-prod.sh @@ -20,7 +20,15 @@ echo "[3/7] Pull base images (e.g., caddy)" sudo docker compose -f "$COMPOSE_FILE" pull || true echo "[4/7] Build application image without cache" +# Falls docker-compose-prod.yml den target 'production-prebuilt' nutzt, vorher lokal bauen +if grep -q "target:\s*production-prebuilt" "$COMPOSE_FILE" 2>/dev/null; then + echo "Detected target production-prebuilt in $COMPOSE_FILE - building locally first..." + pnpm install --frozen-lockfile --prod || exit 1 + pnpm run build || exit 1 +fi + sudo docker compose -f "$COMPOSE_FILE" build --no-cache +echo "Build completed successfully. Native modules compiled for Alpine Linux." echo "[5/7] Start services in background" sudo docker compose -f "$COMPOSE_FILE" up -d @@ -54,6 +62,9 @@ for i in {1..12}; do sleep 5 done +echo "[6c/7] Verifying bcrypt installation in production container..." +sudo docker compose -f "$COMPOSE_FILE" exec stargirlnails node -e "require('bcrypt')" && echo "✓ bcrypt OK" || echo "✗ bcrypt FAILED" + 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=$! diff --git a/server-dist/index.js b/server-dist/index.js index fa4d17b..b5260ad 100644 --- a/server-dist/index.js +++ b/server-dist/index.js @@ -1,14 +1,59 @@ 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"; 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 = [ + `https://${domain}`, + isDev ? `http://${domain}` : null, + isDev ? 'http://localhost:5173' : null, + isDev ? 'http://localhost:3000' : null, +].filter((origin) => 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 app.get("/health", (c) => { diff --git a/server-dist/lib/auth.js b/server-dist/lib/auth.js index b5f1f71..d1a2f05 100644 --- a/server-dist/lib/auth.js +++ b/server-dist/lib/auth.js @@ -1,13 +1,83 @@ import { createKV } from "./create-kv.js"; +import { getCookie } from "hono/cookie"; +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; export const sessionsKV = createKV("sessions"); export const usersKV = createKV("users"); -export async function assertOwner(sessionId) { +// 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', + path: '/', + maxAge: 86400 // 24 hours +}; +// CSRF token generation +export function generateCSRFToken() { + return randomBytes(32).toString('hex'); +} +// Session extraction from cookies +export async function getSessionFromCookies(c) { + 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, sessionId) { + 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, userId) { + // 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 = { + 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) { + const session = await getSessionFromCookies(c); if (!session) throw new Error("Invalid session"); - if (new Date(session.expiresAt) < new Date()) - throw new Error("Session expired"); const user = await usersKV.getItem(session.userId); if (!user || user.role !== "owner") throw new Error("Forbidden"); + // Validate CSRF token for non-GET requests + const method = c.req.method; + if (method !== 'GET' && method !== 'HEAD') { + await validateCSRFToken(c, session.id); + } } diff --git a/server-dist/lib/email-templates.js b/server-dist/lib/email-templates.js index 89eb897..0c1ebd8 100644 --- a/server-dist/lib/email-templates.js +++ b/server-dist/lib/email-templates.js @@ -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"; // Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy @@ -56,12 +57,13 @@ async function renderBrandedEmail(title, bodyHtml) { } export async function renderBookingPendingHTML(params) { 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 = ` -
Hallo ${name},
+Hallo ${safeName},
wir haben deine Anfrage für ${formattedDate} um ${time} erhalten.
Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.
${statusUrl ? ` @@ -81,12 +83,13 @@ export async function renderBookingPendingHTML(params) { } export async function renderBookingConfirmedHTML(params) { 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 = ` -Hallo ${name},
+Hallo ${safeName},
wir haben deinen Termin am ${formattedDate} um ${time} bestätigt.
Wir freuen uns auf dich!
Hallo ${name},
+Hallo ${safeName},
dein Termin am ${formattedDate} um ${time} wurde abgesagt.
Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.
Hallo Admin,
@@ -143,12 +151,12 @@ export async function renderAdminBookingNotificationHTML(params) {📅 Buchungsdetails:
Hallo ${params.name},
+Hallo ${safeName},
wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:
📅 Übersicht
@@ -178,7 +188,7 @@ export async function renderBookingRescheduleProposalHTML(params) {Hallo Admin,
-der Kunde ${params.customerName} hat den Terminänderungsvorschlag abgelehnt.
+der Kunde ${safeCustomerName} hat den Terminänderungsvorschlag abgelehnt.
Hallo Admin,
-der Kunde ${params.customerName} hat den Terminänderungsvorschlag akzeptiert.
+der Kunde ${safeCustomerName} hat den Terminänderungsvorschlag akzeptiert.
${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.
⚠️ Abgelaufene Vorschläge:
- ${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 `Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.
Die ursprünglichen Termine bleiben bestehen.
diff --git a/server-dist/lib/rate-limiter.js b/server-dist/lib/rate-limiter.js index 5ae6958..f77d303 100644 --- a/server-dist/lib/rate-limiter.js +++ b/server-dist/lib/rate-limiter.js @@ -99,19 +99,127 @@ export function checkBookingRateLimit(params) { */ export function getClientIP(headers) { // Check common proxy headers - const forwardedFor = headers['x-forwarded-for']; + const get = (name) => { + if (typeof headers.get === 'function') { + // Headers interface + const v = headers.get(name); + return v === null ? undefined : v; + } + return headers[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; } // No IP found return undefined; } +/** + * Reset a rate limit entry immediately (e.g., after successful login) + */ +export function resetRateLimit(key) { + rateLimitStore.delete(key); +} +/** + * Convenience helper to reset login attempts for an IP + */ +export function resetLoginRateLimit(ip) { + if (!ip) + return; + resetRateLimit(`login:ip:${ip}`); +} +import { getSessionFromCookies } from "./auth.js"; +/** + * Enforce admin rate limiting by IP and user. Throws standardized German error on exceed. + */ +export async function enforceAdminRateLimit(context) { + const ip = getClientIP(context.req.raw.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) { + // 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 = { + 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) { + const { ip, userId } = params; + const userConfig = { + maxRequests: 30, + windowMs: 5 * 60 * 1000, // 5 Minuten + }; + const ipConfig = { + 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, + }; +} diff --git a/server-dist/lib/sanitize.js b/server-dist/lib/sanitize.js new file mode 100644 index 0000000..f9fc973 --- /dev/null +++ b/server-dist/lib/sanitize.js @@ -0,0 +1,34 @@ +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) { + 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) { + 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) { + const text = sanitizeText(input); + return text.replace(/[^0-9+\-()\s]/g, ""); +} diff --git a/server-dist/routes/caldav.js b/server-dist/routes/caldav.js index eeba9c1..72cb170 100644 --- a/server-dist/routes/caldav.js +++ b/server-dist/routes/caldav.js @@ -4,6 +4,7 @@ import { createKV } from "../lib/create-kv.js"; const bookingsKV = createKV("bookings"); const treatmentsKV = createKV("treatments"); const sessionsKV = createKV("sessions"); +const caldavTokensKV = createKV("caldavTokens"); export const caldavApp = new Hono(); // Helper-Funktionen für ICS-Format function formatDateTime(dateStr, timeStr) { @@ -13,6 +14,14 @@ function formatDateTime(dateStr, timeStr) { const date = new Date(year, month - 1, day, hours, minutes); 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, minutesToAdd) { + 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, treatments) { const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); let ics = `BEGIN:VCALENDAR @@ -31,7 +40,8 @@ X-WR-TIMEZONE:Europe/Berlin const treatmentName = treatment?.name || 'Unbekannte Behandlung'; 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`; // Status für ICS @@ -51,6 +61,56 @@ END:VEVENT ics += `END:VCALENDAR`; 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) { + // 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') { @@ -133,36 +193,46 @@ 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 = { "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); diff --git a/server-dist/routes/rpc.js b/server-dist/routes/rpc.js index 1d8a37d..29603e7 100644 --- a/server-dist/routes/rpc.js +++ b/server-dist/routes/rpc.js @@ -7,6 +7,7 @@ rpcApp.all("/*", async (c) => { try { const { matched, response } = await handler.handle(c.req.raw, { prefix: "/rpc", + context: c, }); if (matched) { return c.newResponse(response.body, response); diff --git a/server-dist/rpc/auth.js b/server-dist/rpc/auth.js index f8cbf13..db63d91 100644 --- a/server-dist/rpc/auth.js +++ b/server-dist/rpc/auth.js @@ -1,8 +1,11 @@ import { os } from "@orpc/server"; 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 } from "../lib/auth.js"; // Load environment variables from .env file config(); const UserSchema = z.object({ @@ -18,18 +21,63 @@ const SessionSchema = z.object({ userId: z.string(), expiresAt: z.string(), createdAt: z.string(), + csrfToken: z.string().optional(), }); -const usersKV = createKV("users"); -const sessionsKV = createKV("sessions"); -// Simple password hashing (in production, use bcrypt or similar) -const hashPassword = (password) => { - return Buffer.from(password).toString('base64'); +// Use shared KV stores from auth.ts to avoid duplication +// Password hashing using bcrypt +const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y +const isBase64Hash = (hash) => { + 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, hash) => { - return hashPassword(password) === hash; +const hashPassword = async (password) => { + return bcrypt.hash(password, 10); +}; +const verifyPassword = async (password, hash) => { + 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 () => { + 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, 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(); @@ -37,7 +85,12 @@ const initializeOwner = async () => { const ownerId = randomUUID(); // 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 = { id: ownerId, @@ -51,21 +104,48 @@ const initializeOwner = async () => { console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`); } }; -// 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.raw.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 }; + 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 const session = { @@ -73,10 +153,19 @@ 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, @@ -86,22 +175,24 @@ 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"); @@ -117,12 +208,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"); } @@ -130,14 +220,25 @@ const changePassword = os if (!user) { 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 }; }); export const router = { diff --git a/server-dist/rpc/bookings.js b/server-dist/rpc/bookings.js index 2d97b41..174a2d9 100644 --- a/server-dist/rpc/bookings.js +++ b/server-dist/rpc/bookings.js @@ -1,13 +1,19 @@ -import { call, os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "../lib/create-kv.js"; +import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js"; import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js"; +import { os as baseOs, call as baseCall } from "@orpc/server"; +const osAny = baseOs; +const os = (osAny.withContext ? osAny.withContext() : (osAny.context ? osAny.context() : baseOs)); +const call = baseCall; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; -import { checkBookingRateLimit } from "../lib/rate-limiter.js"; +import { checkBookingRateLimit, 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; const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` }); @@ -188,10 +194,21 @@ const create = os await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration); // Check for booking conflicts await checkBookingConflicts(input.appointmentDate, input.appointmentTime, 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, + 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", createdAt: new Date().toISOString() @@ -206,7 +223,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 @@ -214,7 +231,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(() => { }); })(); @@ -227,37 +244,37 @@ const create = os const treatment = allTreatments.find(t => t.id === input.treatmentId); 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.`; 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(() => { }); @@ -271,26 +288,16 @@ const create = os throw error; } }); -const sessionsKV = createKV("sessions"); -const usersKV = createKV("users"); -async function assertOwner(sessionId) { - 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"); -} +// Owner check reuse (simple inline version) 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); + // Admin Rate Limiting nach erfolgreicher Owner-Prüfung + await enforceAdminRateLimit(context); const booking = await kv.getItem(input.id); if (!booking) throw new Error("Booking not found"); @@ -323,7 +330,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, }, { @@ -343,7 +350,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, }); @@ -357,12 +364,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); + // Admin Rate Limiting nach erfolgreicher Owner-Prüfung + await enforceAdminRateLimit(context); const booking = await kv.getItem(input.id); if (!booking) throw new Error("Booking not found"); @@ -388,7 +396,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, }); @@ -402,7 +410,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(), @@ -411,9 +418,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); + // Admin Rate Limiting nach erfolgreicher Owner-Prüfung + await enforceAdminRateLimit(context); // Validate appointment time is on 15-minute grid const appointmentMinutes = parseTime(input.appointmentTime); if (appointmentMinutes % 15 !== 0) { @@ -441,16 +450,20 @@ const createManual = os await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration); // Check for booking conflicts await checkBookingConflicts(input.appointmentDate, input.appointmentTime, 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", createdAt: new Date().toISOString() @@ -467,7 +480,7 @@ const createManual = os const formattedDate = formatDateGerman(input.appointmentDate); const homepageUrl = generateUrl(); const html = await renderBookingConfirmedHTML({ - name: input.customerName, + name: sanitizedName, date: input.appointmentDate, time: input.appointmentTime, cancellationUrl: bookingUrl, @@ -476,13 +489,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 }); } @@ -537,13 +550,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); const booking = await kv.getItem(input.bookingId); if (!booking) throw new Error("Booking not found"); @@ -704,28 +716,28 @@ 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); + // Hole Session-Daten aus Cookies + const session = await getSessionFromCookies(context); if (!session) - throw new Error("Session nicht gefunden"); + 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("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, caldavUrl, @@ -733,15 +745,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