chore(docker): .dockerignore angepasst; lokale Build-Schritte in Rebuild-Skripten; Doku/README zu production vs production-prebuilt aktualisiert
This commit is contained in:
@@ -90,3 +90,7 @@ coverage
|
|||||||
# Temporary folders
|
# Temporary folders
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Build artifacts and runtime data
|
||||||
|
.pnpm-store/
|
||||||
|
.storage/
|
51
Dockerfile
51
Dockerfile
@@ -10,27 +10,15 @@ RUN pnpm config set enable-pre-post-scripts true
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install all deps for building server
|
# Copy full sources and build both client and server
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY . .
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts=false --enable-pre-post-scripts \
|
RUN pnpm install --frozen-lockfile --ignore-scripts=false --enable-pre-post-scripts \
|
||||||
&& pnpm rebuild bcrypt --build-from-source || true
|
&& pnpm run build
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
# Install pnpm, runtime tools and build deps for native modules present in prod deps
|
# 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
|
&& apk add --no-cache su-exec curl python3 make g++ libc6-compat
|
||||||
|
|
||||||
ENV npm_config_build_from_source=1 \
|
ENV npm_config_build_from_source=1 \
|
||||||
@@ -45,11 +33,17 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts=false --enable-pre-post-scripts \
|
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 built artifacts from builder
|
||||||
COPY dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/server-dist ./server-dist
|
COPY --from=builder /app/server-dist ./server-dist
|
||||||
|
# public wird aus dem Kontext kopiert
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
# Copy necessary runtime files
|
# Copy necessary runtime files
|
||||||
@@ -62,12 +56,8 @@ RUN adduser -S nextjs -u 1001
|
|||||||
# Make start script executable
|
# Make start script executable
|
||||||
RUN chmod +x /app/start.sh
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
# Change ownership of the app directory (but keep root for .storage)
|
# Change ownership of the app directory
|
||||||
RUN chown -R nextjs:nodejs /app
|
RUN chown -R nextjs:nodejs /app || true
|
||||||
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
|
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
@@ -82,7 +72,7 @@ CMD ["/app/start.sh"]
|
|||||||
# Prebuilt runtime stage (used locally): copies prebuilt dist and server-dist from context
|
# Prebuilt runtime stage (used locally): copies prebuilt dist and server-dist from context
|
||||||
FROM node:22-alpine AS production-prebuilt
|
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
|
&& apk add --no-cache su-exec curl python3 make g++ libc6-compat
|
||||||
|
|
||||||
ENV npm_config_build_from_source=1 \
|
ENV npm_config_build_from_source=1 \
|
||||||
@@ -92,8 +82,10 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts=false --enable-pre-post-scripts \
|
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 \
|
&& echo "[production-prebuilt] Rebuilding bcrypt for Alpine Linux..." \
|
||||||
&& pnpm rebuild bcrypt --build-from-source || true
|
&& pnpm rebuild bcrypt --build-from-source \
|
||||||
|
&& echo "[production-prebuilt] Verifying bcrypt installation..." \
|
||||||
|
&& node -e "require('bcrypt')"
|
||||||
|
|
||||||
# Copy prebuilt artifacts from repository
|
# Copy prebuilt artifacts from repository
|
||||||
COPY dist ./dist
|
COPY dist ./dist
|
||||||
@@ -103,8 +95,7 @@ COPY start.sh ./start.sh
|
|||||||
|
|
||||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 \
|
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 \
|
||||||
&& chmod +x /app/start.sh \
|
&& chmod +x /app/start.sh \
|
||||||
&& chown -R nextjs:nodejs /app \
|
&& chown -R nextjs:nodejs /app || true
|
||||||
&& chown root:root /app/.storage 2>/dev/null || true
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
72
README.md
72
README.md
@@ -100,20 +100,51 @@ pnpm dev
|
|||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
### Docker Build
|
### Lokale Entwicklung mit Docker
|
||||||
|
|
||||||
|
Für schnelles lokales Testing mit vorgebauten Artefakten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker Image erstellen
|
# 1. Baue Client und Server lokal
|
||||||
docker build -t stargirlnails-booking .
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
# Container starten
|
# 2. Baue und starte Container (nutzt production-prebuilt Stage)
|
||||||
docker run -d \
|
docker compose build
|
||||||
--name stargirlnails-app \
|
docker compose up -d
|
||||||
-p 3000:3000 \
|
|
||||||
--env-file .env \
|
|
||||||
stargirlnails-booking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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)
|
### Docker Compose (empfohlen)
|
||||||
|
|
||||||
Erstelle eine `docker-compose.yml` Datei:
|
Erstelle eine `docker-compose.yml` Datei:
|
||||||
@@ -170,6 +201,29 @@ chmod +x scripts/setup-caddy.sh
|
|||||||
- Überwache Container mit Health Checks
|
- Überwache Container mit Health Checks
|
||||||
- **Persistente Daten**: Der `.storage` Ordner wird als Volume gemountet, um Buchungen und Einstellungen zu erhalten
|
- **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
|
## Features
|
||||||
|
|
||||||
### Buchungssystem
|
### Buchungssystem
|
||||||
|
@@ -4,7 +4,9 @@
|
|||||||
services:
|
services:
|
||||||
# Hauptanwendung
|
# Hauptanwendung
|
||||||
stargirlnails:
|
stargirlnails:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
target: production
|
||||||
container_name: stargirlnails-app
|
container_name: stargirlnails-app
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
@@ -19,3 +19,4 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DISABLE_DUPLICATE_CHECK=true
|
- DISABLE_DUPLICATE_CHECK=true
|
||||||
|
- ALLOW_RUNTIME_INSTALL=true
|
||||||
|
181
docs/docker-build-stages.md
Normal file
181
docs/docker-build-stages.md
Normal file
@@ -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
|
@@ -1,6 +1,29 @@
|
|||||||
@echo off
|
@echo off
|
||||||
|
|
||||||
docker compose -f docker-compose.yml down
|
docker compose -f docker-compose.yml down
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
git pull
|
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
|
docker compose -f docker-compose.yml build
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
docker compose -f docker-compose.yml up -d
|
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
|
docker compose -f docker-compose.yml logs -f stargirlnails
|
||||||
|
@@ -1,5 +1,17 @@
|
|||||||
#! /bin/bash
|
#! /bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Exit on error to catch build failures early
|
||||||
docker compose -f docker-compose.yml down
|
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 build --no-cache
|
||||||
docker compose -f docker-compose.yml up -d
|
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"
|
||||||
|
@@ -20,7 +20,15 @@ echo "[3/7] Pull base images (e.g., caddy)"
|
|||||||
sudo docker compose -f "$COMPOSE_FILE" pull || true
|
sudo docker compose -f "$COMPOSE_FILE" pull || true
|
||||||
|
|
||||||
echo "[4/7] Build application image without cache"
|
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
|
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"
|
echo "[5/7] Start services in background"
|
||||||
sudo docker compose -f "$COMPOSE_FILE" up -d
|
sudo docker compose -f "$COMPOSE_FILE" up -d
|
||||||
@@ -54,6 +62,9 @@ for i in {1..12}; do
|
|||||||
sleep 5
|
sleep 5
|
||||||
done
|
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)"
|
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 &
|
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f stargirlnails &
|
||||||
APP_LOG_PID=$!
|
APP_LOG_PID=$!
|
||||||
|
@@ -1,14 +1,59 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
import { rpcApp } from "./routes/rpc.js";
|
import { rpcApp } from "./routes/rpc.js";
|
||||||
import { caldavApp } from "./routes/caldav.js";
|
import { caldavApp } from "./routes/caldav.js";
|
||||||
import { clientEntry } from "./routes/client-entry.js";
|
import { clientEntry } from "./routes/client-entry.js";
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
// Allow all hosts for Tailscale Funnel
|
// CORS Configuration
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
|
// Build allowed origins list
|
||||||
|
const allowedOrigins = [
|
||||||
|
`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) => {
|
app.use("*", async (c, next) => {
|
||||||
// Accept requests from any host
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
return next();
|
const directives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self'${isDev ? " 'unsafe-inline'" : ''}`,
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: https:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
];
|
||||||
|
const csp = directives.join('; ');
|
||||||
|
c.header('Content-Security-Policy', csp);
|
||||||
|
c.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
c.header('X-Frame-Options', 'DENY');
|
||||||
|
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
await next();
|
||||||
});
|
});
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get("/health", (c) => {
|
app.get("/health", (c) => {
|
||||||
|
@@ -1,13 +1,83 @@
|
|||||||
import { createKV } from "./create-kv.js";
|
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 sessionsKV = createKV("sessions");
|
||||||
export const usersKV = createKV("users");
|
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);
|
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)
|
if (!session)
|
||||||
throw new Error("Invalid 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);
|
const user = await usersKV.getItem(session.userId);
|
||||||
if (!user || user.role !== "owner")
|
if (!user || user.role !== "owner")
|
||||||
throw new Error("Forbidden");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
// 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) {
|
export async function renderBookingPendingHTML(params) {
|
||||||
const { name, date, time, statusUrl } = params;
|
const { name, date, time, statusUrl } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const legalUrl = `${protocol}://${domain}/legal`;
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||||
${statusUrl ? `
|
${statusUrl ? `
|
||||||
@@ -81,12 +83,13 @@ export async function renderBookingPendingHTML(params) {
|
|||||||
}
|
}
|
||||||
export async function renderBookingConfirmedHTML(params) {
|
export async function renderBookingConfirmedHTML(params) {
|
||||||
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
const { name, date, time, cancellationUrl, reviewUrl } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const legalUrl = `${protocol}://${domain}/legal`;
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
|
||||||
<p>Wir freuen uns auf dich!</p>
|
<p>Wir freuen uns auf dich!</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
@@ -118,12 +121,13 @@ export async function renderBookingConfirmedHTML(params) {
|
|||||||
}
|
}
|
||||||
export async function renderBookingCancelledHTML(params) {
|
export async function renderBookingCancelledHTML(params) {
|
||||||
const { name, date, time } = params;
|
const { name, date, time } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const legalUrl = `${protocol}://${domain}/legal`;
|
const legalUrl = `${protocol}://${domain}/legal`;
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
|
||||||
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
@@ -136,6 +140,10 @@ export async function renderBookingCancelledHTML(params) {
|
|||||||
}
|
}
|
||||||
export async function renderAdminBookingNotificationHTML(params) {
|
export async function renderAdminBookingNotificationHTML(params) {
|
||||||
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
||||||
|
const safeName = sanitizeText(name);
|
||||||
|
const safeTreatment = sanitizeText(treatment);
|
||||||
|
const safePhone = sanitizePhone(phone);
|
||||||
|
const safeNotes = sanitizeHtml(notes);
|
||||||
const formattedDate = formatDateGerman(date);
|
const formattedDate = formatDateGerman(date);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
@@ -143,12 +151,12 @@ export async function renderAdminBookingNotificationHTML(params) {
|
|||||||
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
|
||||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||||
<li><strong>Name:</strong> ${name}</li>
|
<li><strong>Name:</strong> ${safeName}</li>
|
||||||
<li><strong>Telefon:</strong> ${phone}</li>
|
<li><strong>Telefon:</strong> ${safePhone}</li>
|
||||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Datum:</strong> ${formattedDate}</li>
|
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||||
<li><strong>Uhrzeit:</strong> ${time}</li>
|
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||||
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
${safeNotes ? `<li><strong>Notizen:</strong> ${safeNotes}</li>` : ''}
|
||||||
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,12 +166,14 @@ export async function renderAdminBookingNotificationHTML(params) {
|
|||||||
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
||||||
}
|
}
|
||||||
export async function renderBookingRescheduleProposalHTML(params) {
|
export async function renderBookingRescheduleProposalHTML(params) {
|
||||||
|
const safeName = sanitizeText(params.name);
|
||||||
|
const safeTreatment = sanitizeText(params.treatmentName);
|
||||||
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||||
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||||
const expiryDate = new Date(params.expiresAt);
|
const expiryDate = new Date(params.expiresAt);
|
||||||
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo ${params.name},</p>
|
<p>Hallo ${safeName},</p>
|
||||||
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||||
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||||
@@ -178,7 +188,7 @@ export async function renderBookingRescheduleProposalHTML(params) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||||
<td style="padding:6px 0;">${params.treatmentName}</td>
|
<td style="padding:6px 0;">${safeTreatment}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,15 +208,19 @@ export async function renderBookingRescheduleProposalHTML(params) {
|
|||||||
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||||
}
|
}
|
||||||
export async function renderAdminRescheduleDeclinedHTML(params) {
|
export async function renderAdminRescheduleDeclinedHTML(params) {
|
||||||
|
const safeCustomerName = sanitizeText(params.customerName);
|
||||||
|
const safeTreatment = sanitizeText(params.treatmentName);
|
||||||
|
const safeEmail = params.customerEmail ? sanitizeText(params.customerEmail) : undefined;
|
||||||
|
const safePhone = params.customerPhone ? sanitizeText(params.customerPhone) : undefined;
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||||
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||||
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -216,13 +230,15 @@ export async function renderAdminRescheduleDeclinedHTML(params) {
|
|||||||
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||||
}
|
}
|
||||||
export async function renderAdminRescheduleAcceptedHTML(params) {
|
export async function renderAdminRescheduleAcceptedHTML(params) {
|
||||||
|
const safeCustomerName = sanitizeText(params.customerName);
|
||||||
|
const safeTreatment = sanitizeText(params.treatmentName);
|
||||||
const inner = `
|
const inner = `
|
||||||
<p>Hallo Admin,</p>
|
<p>Hallo Admin,</p>
|
||||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||||
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
<li><strong>Kunde:</strong> ${safeCustomerName}</li>
|
||||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||||
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -237,19 +253,25 @@ export async function renderAdminRescheduleExpiredHTML(params) {
|
|||||||
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||||
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||||
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||||
${params.expiredProposals.map(proposal => `
|
${params.expiredProposals.map(proposal => {
|
||||||
|
const safeName = sanitizeText(proposal.customerName);
|
||||||
|
const safeTreatment = sanitizeText(proposal.treatmentName);
|
||||||
|
const safeEmail = proposal.customerEmail ? sanitizeText(proposal.customerEmail) : undefined;
|
||||||
|
const safePhone = proposal.customerPhone ? sanitizeText(proposal.customerPhone) : undefined;
|
||||||
|
return `
|
||||||
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
<li><strong>Kunde:</strong> ${safeName}</li>
|
||||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
|
||||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
|
||||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
<li><strong>Behandlung:</strong> ${safeTreatment}</li>
|
||||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||||
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||||
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`;
|
||||||
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||||
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||||
|
@@ -99,19 +99,127 @@ export function checkBookingRateLimit(params) {
|
|||||||
*/
|
*/
|
||||||
export function getClientIP(headers) {
|
export function getClientIP(headers) {
|
||||||
// Check common proxy 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) {
|
if (forwardedFor) {
|
||||||
// x-forwarded-for can contain multiple IPs, take the first one
|
// x-forwarded-for can contain multiple IPs, take the first one
|
||||||
return forwardedFor.split(',')[0].trim();
|
return forwardedFor.split(',')[0].trim();
|
||||||
}
|
}
|
||||||
const realIP = headers['x-real-ip'];
|
const realIP = get('x-real-ip');
|
||||||
if (realIP) {
|
if (realIP) {
|
||||||
return realIP;
|
return realIP;
|
||||||
}
|
}
|
||||||
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
|
||||||
if (cfConnectingIP) {
|
if (cfConnectingIP) {
|
||||||
return cfConnectingIP;
|
return cfConnectingIP;
|
||||||
}
|
}
|
||||||
// No IP found
|
// No IP found
|
||||||
return undefined;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
34
server-dist/lib/sanitize.js
Normal file
34
server-dist/lib/sanitize.js
Normal file
@@ -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, "");
|
||||||
|
}
|
@@ -4,6 +4,7 @@ import { createKV } from "../lib/create-kv.js";
|
|||||||
const bookingsKV = createKV("bookings");
|
const bookingsKV = createKV("bookings");
|
||||||
const treatmentsKV = createKV("treatments");
|
const treatmentsKV = createKV("treatments");
|
||||||
const sessionsKV = createKV("sessions");
|
const sessionsKV = createKV("sessions");
|
||||||
|
const caldavTokensKV = createKV("caldavTokens");
|
||||||
export const caldavApp = new Hono();
|
export const caldavApp = new Hono();
|
||||||
// Helper-Funktionen für ICS-Format
|
// Helper-Funktionen für ICS-Format
|
||||||
function formatDateTime(dateStr, timeStr) {
|
function formatDateTime(dateStr, timeStr) {
|
||||||
@@ -13,6 +14,14 @@ function formatDateTime(dateStr, timeStr) {
|
|||||||
const date = new Date(year, month - 1, day, hours, minutes);
|
const date = new Date(year, month - 1, day, hours, minutes);
|
||||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
}
|
}
|
||||||
|
// Helper to add minutes to an HH:MM time string and return HH:MM
|
||||||
|
function addMinutesToTime(timeStr, 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) {
|
function generateICSContent(bookings, treatments) {
|
||||||
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
let ics = `BEGIN:VCALENDAR
|
let ics = `BEGIN:VCALENDAR
|
||||||
@@ -31,7 +40,8 @@ X-WR-TIMEZONE:Europe/Berlin
|
|||||||
const treatmentName = treatment?.name || 'Unbekannte Behandlung';
|
const treatmentName = treatment?.name || 'Unbekannte Behandlung';
|
||||||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||||
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
||||||
const endTime = formatDateTime(booking.appointmentDate, `${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)
|
// UID für jeden Termin (eindeutig)
|
||||||
const uid = `booking-${booking.id}@stargirlnails.de`;
|
const uid = `booking-${booking.id}@stargirlnails.de`;
|
||||||
// Status für ICS
|
// Status für ICS
|
||||||
@@ -51,6 +61,56 @@ END:VEVENT
|
|||||||
ics += `END:VCALENDAR`;
|
ics += `END:VCALENDAR`;
|
||||||
return ics;
|
return ics;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Extract and validate CalDAV token from Authorization header or query parameter (legacy)
|
||||||
|
* @param c Hono context
|
||||||
|
* @returns { token: string; source: 'bearer'|'basic'|'query' } | null
|
||||||
|
*/
|
||||||
|
function extractCalDAVToken(c) {
|
||||||
|
// UUID v4 pattern for hardening (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
|
||||||
|
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
// Prefer Authorization header (new secure methods: Bearer or Basic)
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (authHeader) {
|
||||||
|
// Bearer
|
||||||
|
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (bearerMatch) {
|
||||||
|
const token = bearerMatch[1].trim();
|
||||||
|
if (!uuidV4Regex.test(token)) {
|
||||||
|
console.warn('CalDAV: Bearer token does not match UUID v4 format.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { token, source: 'bearer' };
|
||||||
|
}
|
||||||
|
// Basic (use username or password as token)
|
||||||
|
const basicMatch = authHeader.match(/^Basic\s+(.+)$/i);
|
||||||
|
if (basicMatch) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(basicMatch[1], 'base64').toString('utf8');
|
||||||
|
// Format: username:password (password optional)
|
||||||
|
const [username, password] = decoded.split(':');
|
||||||
|
const candidate = (username && username.trim().length > 0)
|
||||||
|
? username.trim()
|
||||||
|
: (password ? password.trim() : '');
|
||||||
|
if (candidate && uuidV4Regex.test(candidate)) {
|
||||||
|
return { token: candidate, source: 'basic' };
|
||||||
|
}
|
||||||
|
console.warn('CalDAV: Basic auth credential does not contain a valid UUID v4 token.');
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.warn('CalDAV: Failed to decode Basic auth header');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to query parameter (legacy, will be deprecated)
|
||||||
|
const queryToken = c.req.query('token');
|
||||||
|
if (queryToken) {
|
||||||
|
console.warn('CalDAV: Token passed via query parameter (deprecated). Please use Authorization header.');
|
||||||
|
return { token: queryToken, source: 'query' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// CalDAV Discovery (PROPFIND auf Root)
|
// CalDAV Discovery (PROPFIND auf Root)
|
||||||
caldavApp.all("/", async (c) => {
|
caldavApp.all("/", async (c) => {
|
||||||
if (c.req.method !== 'PROPFIND') {
|
if (c.req.method !== 'PROPFIND') {
|
||||||
@@ -133,36 +193,46 @@ caldavApp.all("/calendar/events.ics", async (c) => {
|
|||||||
// GET Calendar Data (ICS-Datei)
|
// GET Calendar Data (ICS-Datei)
|
||||||
caldavApp.get("/calendar/events.ics", async (c) => {
|
caldavApp.get("/calendar/events.ics", async (c) => {
|
||||||
try {
|
try {
|
||||||
// Authentifizierung über Token im Query-Parameter
|
// Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
|
||||||
const token = c.req.query('token');
|
const tokenResult = extractCalDAVToken(c);
|
||||||
if (!token) {
|
if (!tokenResult) {
|
||||||
return c.text('Unauthorized - Token required', 401);
|
return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, {
|
||||||
|
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Token validieren
|
// Validate token against caldavTokens KV store
|
||||||
const tokenData = await sessionsKV.getItem(token);
|
const tokenData = await caldavTokensKV.getItem(tokenResult.token);
|
||||||
if (!tokenData) {
|
if (!tokenData) {
|
||||||
return c.text('Unauthorized - Invalid token', 401);
|
return c.text('Unauthorized - Invalid or expired token', 401, {
|
||||||
|
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
|
// Check token expiration
|
||||||
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
|
|
||||||
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
|
|
||||||
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
|
|
||||||
return c.text('Unauthorized - Token expired', 401);
|
|
||||||
}
|
|
||||||
// Token-Ablaufzeit prüfen
|
|
||||||
if (new Date(tokenData.expiresAt) < new Date()) {
|
if (new Date(tokenData.expiresAt) < new Date()) {
|
||||||
return c.text('Unauthorized - Token expired', 401);
|
// Clean up expired token
|
||||||
|
await caldavTokensKV.removeItem(tokenResult.token);
|
||||||
|
return c.text('Unauthorized - Token expired', 401, {
|
||||||
|
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// Note: Token is valid for 24 hours from creation.
|
||||||
|
// Expired tokens are cleaned up on access attempt.
|
||||||
const bookings = await bookingsKV.getAllItems();
|
const bookings = await bookingsKV.getAllItems();
|
||||||
const treatments = await treatmentsKV.getAllItems();
|
const treatments = await treatmentsKV.getAllItems();
|
||||||
const icsContent = generateICSContent(bookings, treatments);
|
const icsContent = generateICSContent(bookings, treatments);
|
||||||
return c.text(icsContent, 200, {
|
const headers = {
|
||||||
"Content-Type": "text/calendar; charset=utf-8",
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
"Expires": "0",
|
"Expires": "0",
|
||||||
});
|
};
|
||||||
|
// If legacy query token was used, inform clients about deprecation
|
||||||
|
if (tokenResult.source === 'query') {
|
||||||
|
headers["Deprecation"] = "true";
|
||||||
|
headers["Warning"] = "299 - \"Query parameter token authentication is deprecated. Use Authorization header (Bearer or Basic).\"";
|
||||||
|
}
|
||||||
|
return c.text(icsContent, 200, headers);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("CalDAV GET error:", error);
|
console.error("CalDAV GET error:", error);
|
||||||
|
@@ -7,6 +7,7 @@ rpcApp.all("/*", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const { matched, response } = await handler.handle(c.req.raw, {
|
const { matched, response } = await handler.handle(c.req.raw, {
|
||||||
prefix: "/rpc",
|
prefix: "/rpc",
|
||||||
|
context: c,
|
||||||
});
|
});
|
||||||
if (matched) {
|
if (matched) {
|
||||||
return c.newResponse(response.body, response);
|
return c.newResponse(response.body, response);
|
||||||
|
@@ -1,8 +1,11 @@
|
|||||||
import { os } from "@orpc/server";
|
import { os } from "@orpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { setCookie } from "hono/cookie";
|
||||||
|
import { checkLoginRateLimit, getClientIP, resetLoginRateLimit } from "../lib/rate-limiter.js";
|
||||||
|
import { generateCSRFToken, getSessionFromCookies, validateCSRFToken, rotateSession, COOKIE_OPTIONS, SESSION_COOKIE_NAME, CSRF_COOKIE_NAME, sessionsKV, usersKV } from "../lib/auth.js";
|
||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
config();
|
config();
|
||||||
const UserSchema = z.object({
|
const UserSchema = z.object({
|
||||||
@@ -18,18 +21,63 @@ const SessionSchema = z.object({
|
|||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
expiresAt: z.string(),
|
expiresAt: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
csrfToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
const usersKV = createKV("users");
|
// Use shared KV stores from auth.ts to avoid duplication
|
||||||
const sessionsKV = createKV("sessions");
|
// Password hashing using bcrypt
|
||||||
// Simple password hashing (in production, use bcrypt or similar)
|
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
|
||||||
const hashPassword = (password) => {
|
const isBase64Hash = (hash) => {
|
||||||
return Buffer.from(password).toString('base64');
|
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) => {
|
const hashPassword = async (password) => {
|
||||||
return hashPassword(password) === hash;
|
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 hashPassword for external use (e.g., generating hashes for .env)
|
||||||
export const generatePasswordHash = hashPassword;
|
export const generatePasswordHash = hashPassword;
|
||||||
|
// Migrate all legacy Base64 password hashes to bcrypt on server startup
|
||||||
|
const migrateLegacyHashesOnStartup = async () => {
|
||||||
|
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
|
// Initialize default owner account
|
||||||
const initializeOwner = async () => {
|
const initializeOwner = async () => {
|
||||||
const existingUsers = await usersKV.getAllItems();
|
const existingUsers = await usersKV.getAllItems();
|
||||||
@@ -37,7 +85,12 @@ const initializeOwner = async () => {
|
|||||||
const ownerId = randomUUID();
|
const ownerId = randomUUID();
|
||||||
// Get admin credentials from environment variables
|
// Get admin credentials from environment variables
|
||||||
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||||
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123");
|
||||||
|
// If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt
|
||||||
|
if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) {
|
||||||
|
const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8');
|
||||||
|
adminPasswordHash = await hashPassword(plaintext);
|
||||||
|
}
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||||
const owner = {
|
const owner = {
|
||||||
id: ownerId,
|
id: ownerId,
|
||||||
@@ -51,21 +104,48 @@ const initializeOwner = async () => {
|
|||||||
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Initialize on module load
|
// Initialize on module load: first migrate legacy hashes, then ensure owner exists
|
||||||
initializeOwner();
|
(async () => {
|
||||||
|
try {
|
||||||
|
await migrateLegacyHashesOnStartup();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await initializeOwner();
|
||||||
|
}
|
||||||
|
})();
|
||||||
const login = os
|
const login = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
const users = await usersKV.getAllItems();
|
const users = await usersKV.getAllItems();
|
||||||
const user = users.find(u => u.username === input.username);
|
const user = users.find(u => u.username === input.username);
|
||||||
if (!user || !verifyPassword(input.password, user.passwordHash)) {
|
if (!user) {
|
||||||
|
const rl = checkLoginRateLimit(ip);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
throw new Error("Invalid credentials");
|
throw new Error("Invalid credentials");
|
||||||
}
|
}
|
||||||
// Create session
|
const isValid = await verifyPassword(input.password, user.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
const rl = checkLoginRateLimit(ip);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
// Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt
|
||||||
|
if (isBase64Hash(user.passwordHash)) {
|
||||||
|
const migratedHash = await hashPassword(input.password);
|
||||||
|
const migratedUser = { ...user, passwordHash: migratedHash };
|
||||||
|
await usersKV.setItem(user.id, migratedUser);
|
||||||
|
}
|
||||||
|
// Create session with CSRF token
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
const csrfToken = generateCSRFToken();
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
||||||
const session = {
|
const session = {
|
||||||
@@ -73,10 +153,19 @@ const login = os
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
csrfToken,
|
||||||
};
|
};
|
||||||
await sessionsKV.setItem(sessionId, session);
|
await sessionsKV.setItem(sessionId, session);
|
||||||
|
// Optional: Reset login attempts on successful login
|
||||||
|
resetLoginRateLimit(ip);
|
||||||
|
// Set cookies in response
|
||||||
|
setCookie(context, SESSION_COOKIE_NAME, sessionId, COOKIE_OPTIONS);
|
||||||
|
setCookie(context, CSRF_COOKIE_NAME, csrfToken, {
|
||||||
|
...COOKIE_OPTIONS,
|
||||||
|
httpOnly: false, // CSRF token needs to be readable by JavaScript
|
||||||
|
});
|
||||||
|
// Return only user object (no sessionId in response)
|
||||||
return {
|
return {
|
||||||
sessionId,
|
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -86,22 +175,24 @@ const login = os
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const logout = os
|
const logout = os
|
||||||
.input(z.string()) // sessionId
|
.input(z.object({})) // No input needed - session comes from cookies
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ context }) => {
|
||||||
await sessionsKV.removeItem(input);
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
await sessionsKV.removeItem(session.id);
|
||||||
|
}
|
||||||
|
// Clear both cookies with correct options
|
||||||
|
setCookie(context, SESSION_COOKIE_NAME, '', { ...COOKIE_OPTIONS, maxAge: 0 });
|
||||||
|
setCookie(context, CSRF_COOKIE_NAME, '', { ...COOKIE_OPTIONS, httpOnly: false, maxAge: 0 });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
const verifySession = os
|
const verifySession = os
|
||||||
.input(z.string()) // sessionId
|
.input(z.object({})) // No input needed - session comes from cookies
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ context }) => {
|
||||||
const session = await sessionsKV.getItem(input);
|
const session = await getSessionFromCookies(context);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("Invalid session");
|
throw new Error("Invalid session");
|
||||||
}
|
}
|
||||||
if (new Date(session.expiresAt) < new Date()) {
|
|
||||||
await sessionsKV.removeItem(input);
|
|
||||||
throw new Error("Session expired");
|
|
||||||
}
|
|
||||||
const user = await usersKV.getItem(session.userId);
|
const user = await usersKV.getItem(session.userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("User not found");
|
throw new Error("User not found");
|
||||||
@@ -117,12 +208,11 @@ const verifySession = os
|
|||||||
});
|
});
|
||||||
const changePassword = os
|
const changePassword = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
currentPassword: z.string(),
|
currentPassword: z.string(),
|
||||||
newPassword: z.string(),
|
newPassword: z.string(),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
const session = await sessionsKV.getItem(input.sessionId);
|
const session = await getSessionFromCookies(context);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("Invalid session");
|
throw new Error("Invalid session");
|
||||||
}
|
}
|
||||||
@@ -130,14 +220,25 @@ const changePassword = os
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("User not found");
|
throw new Error("User not found");
|
||||||
}
|
}
|
||||||
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
|
// Validate CSRF token for password change
|
||||||
|
await validateCSRFToken(context, session.id);
|
||||||
|
const currentOk = await verifyPassword(input.currentPassword, user.passwordHash);
|
||||||
|
if (!currentOk) {
|
||||||
throw new Error("Current password is incorrect");
|
throw new Error("Current password is incorrect");
|
||||||
}
|
}
|
||||||
const updatedUser = {
|
const updatedUser = {
|
||||||
...user,
|
...user,
|
||||||
passwordHash: hashPassword(input.newPassword),
|
passwordHash: await hashPassword(input.newPassword),
|
||||||
};
|
};
|
||||||
await usersKV.setItem(user.id, updatedUser);
|
await usersKV.setItem(user.id, updatedUser);
|
||||||
|
// Implement session rotation after password change
|
||||||
|
const newSession = await rotateSession(session.id, user.id);
|
||||||
|
// Set new session and CSRF cookies
|
||||||
|
setCookie(context, SESSION_COOKIE_NAME, newSession.id, COOKIE_OPTIONS);
|
||||||
|
setCookie(context, CSRF_COOKIE_NAME, newSession.csrfToken, {
|
||||||
|
...COOKIE_OPTIONS,
|
||||||
|
httpOnly: false,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
export const router = {
|
export const router = {
|
||||||
|
@@ -1,13 +1,19 @@
|
|||||||
import { call, os } from "@orpc/server";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
|
||||||
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||||
|
import { 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 { createORPCClient } from "@orpc/client";
|
||||||
import { RPCLink } from "@orpc/client/fetch";
|
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 { validateEmail } from "../lib/email-validator.js";
|
||||||
|
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||||
|
// Using centrally typed os and call from rpc/index
|
||||||
// Create a server-side client to call other RPC endpoints
|
// Create a server-side client to call other RPC endpoints
|
||||||
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||||
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
|
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
|
||||||
@@ -188,10 +194,21 @@ const create = os
|
|||||||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||||
// Check for booking conflicts
|
// Check for booking conflicts
|
||||||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
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 id = randomUUID();
|
||||||
const booking = {
|
const booking = {
|
||||||
id,
|
id,
|
||||||
...input,
|
treatmentId: input.treatmentId,
|
||||||
|
customerName: sanitizedName,
|
||||||
|
customerEmail: input.customerEmail,
|
||||||
|
customerPhone: sanitizedPhone,
|
||||||
|
appointmentDate: input.appointmentDate,
|
||||||
|
appointmentTime: input.appointmentTime,
|
||||||
|
notes: sanitizedNotes,
|
||||||
|
inspirationPhoto: input.inspirationPhoto,
|
||||||
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||||||
status: "pending",
|
status: "pending",
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
@@ -206,7 +223,7 @@ const create = os
|
|||||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
const html = await renderBookingPendingHTML({
|
const html = await renderBookingPendingHTML({
|
||||||
name: input.customerName,
|
name: sanitizedName,
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
statusUrl: bookingUrl
|
statusUrl: bookingUrl
|
||||||
@@ -214,7 +231,7 @@ const create = os
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: input.customerEmail,
|
to: input.customerEmail,
|
||||||
subject: "Deine Terminanfrage ist eingegangen",
|
subject: "Deine Terminanfrage ist eingegangen",
|
||||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
})();
|
})();
|
||||||
@@ -227,37 +244,37 @@ const create = os
|
|||||||
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
||||||
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||||
const adminHtml = await renderAdminBookingNotificationHTML({
|
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||||
name: input.customerName,
|
name: sanitizedName,
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
treatment: treatmentName,
|
treatment: treatmentName,
|
||||||
phone: input.customerPhone || "Nicht angegeben",
|
phone: sanitizedPhone || "Nicht angegeben",
|
||||||
notes: input.notes,
|
notes: sanitizedNotes,
|
||||||
hasInspirationPhoto: !!input.inspirationPhoto
|
hasInspirationPhoto: !!input.inspirationPhoto
|
||||||
});
|
});
|
||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||||
`Name: ${input.customerName}\n` +
|
`Name: ${sanitizedName}\n` +
|
||||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
|
||||||
`Behandlung: ${treatmentName}\n` +
|
`Behandlung: ${treatmentName}\n` +
|
||||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||||
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
`${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` +
|
||||||
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||||
`Zur Website: ${homepageUrl}\n\n` +
|
`Zur Website: ${homepageUrl}\n\n` +
|
||||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||||
if (input.inspirationPhoto) {
|
if (input.inspirationPhoto) {
|
||||||
await sendEmailWithInspirationPhoto({
|
await sendEmailWithInspirationPhoto({
|
||||||
to: process.env.ADMIN_EMAIL,
|
to: process.env.ADMIN_EMAIL,
|
||||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||||
text: adminText,
|
text: adminText,
|
||||||
html: adminHtml,
|
html: adminHtml,
|
||||||
}, input.inspirationPhoto, input.customerName).catch(() => { });
|
}, input.inspirationPhoto, sanitizedName).catch(() => { });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: process.env.ADMIN_EMAIL,
|
to: process.env.ADMIN_EMAIL,
|
||||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||||
text: adminText,
|
text: adminText,
|
||||||
html: adminHtml,
|
html: adminHtml,
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
@@ -271,26 +288,16 @@ const create = os
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const sessionsKV = createKV("sessions");
|
// Owner check reuse (simple inline version)
|
||||||
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");
|
|
||||||
}
|
|
||||||
const updateStatus = os
|
const updateStatus = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
const booking = await kv.getItem(input.id);
|
const booking = await kv.getItem(input.id);
|
||||||
if (!booking)
|
if (!booking)
|
||||||
throw new Error("Booking not found");
|
throw new Error("Booking not found");
|
||||||
@@ -323,7 +330,7 @@ const updateStatus = os
|
|||||||
await sendEmailWithAGBAndCalendar({
|
await sendEmailWithAGBAndCalendar({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
text: `Hallo ${sanitizeText(booking.customerName)},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
}, {
|
}, {
|
||||||
@@ -343,7 +350,7 @@ const updateStatus = os
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde abgesagt",
|
subject: "Dein Termin wurde abgesagt",
|
||||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
});
|
});
|
||||||
@@ -357,12 +364,13 @@ const updateStatus = os
|
|||||||
});
|
});
|
||||||
const remove = os
|
const remove = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
sendEmail: z.boolean().optional().default(false)
|
sendEmail: z.boolean().optional().default(false)
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
const booking = await kv.getItem(input.id);
|
const booking = await kv.getItem(input.id);
|
||||||
if (!booking)
|
if (!booking)
|
||||||
throw new Error("Booking not found");
|
throw new Error("Booking not found");
|
||||||
@@ -388,7 +396,7 @@ const remove = os
|
|||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde abgesagt",
|
subject: "Dein Termin wurde abgesagt",
|
||||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||||
});
|
});
|
||||||
@@ -402,7 +410,6 @@ const remove = os
|
|||||||
// Admin-only manual booking creation (immediately confirmed)
|
// Admin-only manual booking creation (immediately confirmed)
|
||||||
const createManual = os
|
const createManual = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
treatmentId: z.string(),
|
treatmentId: z.string(),
|
||||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||||
@@ -411,9 +418,11 @@ const createManual = os
|
|||||||
appointmentTime: z.string(),
|
appointmentTime: z.string(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
// Admin authentication
|
// Admin authentication
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
// Validate appointment time is on 15-minute grid
|
// Validate appointment time is on 15-minute grid
|
||||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||||
if (appointmentMinutes % 15 !== 0) {
|
if (appointmentMinutes % 15 !== 0) {
|
||||||
@@ -441,16 +450,20 @@ const createManual = os
|
|||||||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||||
// Check for booking conflicts
|
// Check for booking conflicts
|
||||||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
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 id = randomUUID();
|
||||||
const booking = {
|
const booking = {
|
||||||
id,
|
id,
|
||||||
treatmentId: input.treatmentId,
|
treatmentId: input.treatmentId,
|
||||||
customerName: input.customerName,
|
customerName: sanitizedName,
|
||||||
customerEmail: input.customerEmail,
|
customerEmail: input.customerEmail,
|
||||||
customerPhone: input.customerPhone,
|
customerPhone: sanitizedPhone,
|
||||||
appointmentDate: input.appointmentDate,
|
appointmentDate: input.appointmentDate,
|
||||||
appointmentTime: input.appointmentTime,
|
appointmentTime: input.appointmentTime,
|
||||||
notes: input.notes,
|
notes: sanitizedNotes,
|
||||||
bookedDurationMinutes: treatment.duration,
|
bookedDurationMinutes: treatment.duration,
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
@@ -467,7 +480,7 @@ const createManual = os
|
|||||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
const html = await renderBookingConfirmedHTML({
|
const html = await renderBookingConfirmedHTML({
|
||||||
name: input.customerName,
|
name: sanitizedName,
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
cancellationUrl: bookingUrl,
|
cancellationUrl: bookingUrl,
|
||||||
@@ -476,13 +489,13 @@ const createManual = os
|
|||||||
await sendEmailWithAGBAndCalendar({
|
await sendEmailWithAGBAndCalendar({
|
||||||
to: input.customerEmail,
|
to: input.customerEmail,
|
||||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||||
html,
|
html,
|
||||||
}, {
|
}, {
|
||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
durationMinutes: treatment.duration,
|
durationMinutes: treatment.duration,
|
||||||
customerName: input.customerName,
|
customerName: sanitizedName,
|
||||||
treatmentName: treatment.name
|
treatmentName: treatment.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -537,13 +550,12 @@ export const router = {
|
|||||||
// Admin proposes a reschedule for a confirmed booking
|
// Admin proposes a reschedule for a confirmed booking
|
||||||
proposeReschedule: os
|
proposeReschedule: os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
bookingId: z.string(),
|
bookingId: z.string(),
|
||||||
proposedDate: z.string(),
|
proposedDate: z.string(),
|
||||||
proposedTime: z.string(),
|
proposedTime: z.string(),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const booking = await kv.getItem(input.bookingId);
|
const booking = await kv.getItem(input.bookingId);
|
||||||
if (!booking)
|
if (!booking)
|
||||||
throw new Error("Booking not found");
|
throw new Error("Booking not found");
|
||||||
@@ -704,28 +716,28 @@ export const router = {
|
|||||||
}),
|
}),
|
||||||
// CalDAV Token für Admin generieren
|
// CalDAV Token für Admin generieren
|
||||||
generateCalDAVToken: os
|
generateCalDAVToken: os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
// Generiere einen sicheren Token für CalDAV-Zugriff
|
// Generiere einen sicheren Token für CalDAV-Zugriff
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
// Hole Session-Daten für Token-Erstellung
|
// Hole Session-Daten aus Cookies
|
||||||
const session = await sessionsKV.getItem(input.sessionId);
|
const session = await getSessionFromCookies(context);
|
||||||
if (!session)
|
if (!session)
|
||||||
throw new Error("Session nicht gefunden");
|
throw new Error("Invalid session");
|
||||||
// Speichere Token mit Ablaufzeit (24 Stunden)
|
// Speichere Token mit Ablaufzeit (24 Stunden)
|
||||||
const tokenData = {
|
const tokenData = {
|
||||||
id: token,
|
id: token,
|
||||||
sessionId: input.sessionId,
|
userId: session.userId,
|
||||||
userId: session.userId, // Benötigt für Session-Typ
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
// Verwende den sessionsKV Store für Token-Speicherung
|
// Dedizierten KV-Store für CalDAV-Token verwenden
|
||||||
await sessionsKV.setItem(token, tokenData);
|
const caldavTokensKV = createKV("caldavTokens");
|
||||||
|
await caldavTokensKV.setItem(token, tokenData);
|
||||||
const domain = process.env.DOMAIN || 'localhost:3000';
|
const domain = process.env.DOMAIN || 'localhost:3000';
|
||||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
|
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
caldavUrl,
|
caldavUrl,
|
||||||
@@ -733,15 +745,44 @@ export const router = {
|
|||||||
instructions: {
|
instructions: {
|
||||||
title: "CalDAV-Kalender abonnieren",
|
title: "CalDAV-Kalender abonnieren",
|
||||||
steps: [
|
steps: [
|
||||||
"Kopiere die CalDAV-URL unten",
|
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
|
||||||
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
|
"",
|
||||||
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
|
"📋 Dein CalDAV-Token (kopieren):",
|
||||||
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
|
token,
|
||||||
"- Apple Calendar: Abonnement → Neue Abonnements",
|
"",
|
||||||
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
|
"🔗 CalDAV-URL (ohne Token):",
|
||||||
"Der Kalender wird automatisch aktualisiert"
|
caldavUrl,
|
||||||
|
"",
|
||||||
|
"📱 Einrichtung nach Kalender-App:",
|
||||||
|
"",
|
||||||
|
"🍎 Apple Calendar (macOS/iOS):",
|
||||||
|
"- Leider keine native Unterstützung für Authorization-Header",
|
||||||
|
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
|
||||||
|
"",
|
||||||
|
"📧 Outlook:",
|
||||||
|
"- Datei → Kontoeinstellungen → Internetkalender",
|
||||||
|
"- URL eingeben (ohne Token)",
|
||||||
|
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
|
||||||
|
" Authorization: Bearer <DEIN_TOKEN>",
|
||||||
|
"",
|
||||||
|
"🌐 Google Calendar:",
|
||||||
|
"- Andere Kalender → Von URL hinzufügen",
|
||||||
|
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
|
||||||
|
"- Alternative: Verwende Google Apps Script oder importiere manuell",
|
||||||
|
"",
|
||||||
|
"🦅 Thunderbird:",
|
||||||
|
"- Kalender → Neuer Kalender → Im Netzwerk",
|
||||||
|
"- Format: CalDAV",
|
||||||
|
"- URL eingeben",
|
||||||
|
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
|
||||||
|
"",
|
||||||
|
"💻 cURL-Beispiel zum Testen:",
|
||||||
|
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
|
||||||
|
"",
|
||||||
|
"⏰ Token-Gültigkeit: 24 Stunden",
|
||||||
|
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
|
||||||
],
|
],
|
||||||
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
|
note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
import { assertOwner } from "../lib/auth.js";
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
import { enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||||
// Schema Definition
|
// Schema Definition
|
||||||
const GalleryPhotoSchema = z.object({
|
const GalleryPhotoSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -18,15 +19,16 @@ const galleryPhotosKV = createKV("galleryPhotos");
|
|||||||
// CRUD Endpoints
|
// CRUD Endpoints
|
||||||
const uploadPhoto = os
|
const uploadPhoto = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
base64Data: z
|
base64Data: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
||||||
title: z.string().optional().default(""),
|
title: z.string().optional().default(""),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const existing = await galleryPhotosKV.getAllItems();
|
const existing = await galleryPhotosKV.getAllItems();
|
||||||
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||||
@@ -48,9 +50,11 @@ const uploadPhoto = os
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const setCoverPhoto = os
|
const setCoverPhoto = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
const all = await galleryPhotosKV.getAllItems();
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
let updatedCover = null;
|
let updatedCover = null;
|
||||||
for (const p of all) {
|
for (const p of all) {
|
||||||
@@ -63,18 +67,21 @@ const setCoverPhoto = os
|
|||||||
return updatedCover;
|
return updatedCover;
|
||||||
});
|
});
|
||||||
const deletePhoto = os
|
const deletePhoto = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
await galleryPhotosKV.removeItem(input.id);
|
await galleryPhotosKV.removeItem(input.id);
|
||||||
});
|
});
|
||||||
const updatePhotoOrder = os
|
const updatePhotoOrder = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
const updated = [];
|
const updated = [];
|
||||||
for (const { id, order } of input.photoOrders) {
|
for (const { id, order } of input.photoOrders) {
|
||||||
const existing = await galleryPhotosKV.getItem(id);
|
const existing = await galleryPhotosKV.getItem(id);
|
||||||
@@ -92,9 +99,9 @@ const listPhotos = os.handler(async () => {
|
|||||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
});
|
});
|
||||||
const adminListPhotos = os
|
const adminListPhotos = os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const all = await galleryPhotosKV.getAllItems();
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
});
|
});
|
||||||
@@ -107,9 +114,9 @@ const live = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
adminListPhotos: os
|
adminListPhotos: os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async function* ({ input, signal }) {
|
.handler(async function* ({ context, signal }) {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const all = await galleryPhotosKV.getAllItems();
|
const all = await galleryPhotosKV.getAllItems();
|
||||||
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||||
yield sorted;
|
yield sorted;
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { demo } from "./demo/index.js";
|
import { demo } from "./demo/index.js";
|
||||||
|
import { os as baseOs, call as baseCall } from "@orpc/server";
|
||||||
import { router as treatments } from "./treatments.js";
|
import { router as treatments } from "./treatments.js";
|
||||||
import { router as bookings } from "./bookings.js";
|
import { router as bookings } from "./bookings.js";
|
||||||
import { router as auth } from "./auth.js";
|
import { router as auth } from "./auth.js";
|
||||||
@@ -18,3 +19,7 @@ export const router = {
|
|||||||
gallery,
|
gallery,
|
||||||
reviews,
|
reviews,
|
||||||
};
|
};
|
||||||
|
// Export centrally typed oRPC helpers so all modules share the same Hono Context typing
|
||||||
|
const osAny = baseOs;
|
||||||
|
export const os = osAny.withContext?.() ?? osAny.context?.() ?? baseOs;
|
||||||
|
export const call = baseCall;
|
||||||
|
@@ -2,7 +2,8 @@ import { call, os } from "@orpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
import { assertOwner } from "../lib/auth.js";
|
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||||
|
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||||
// Datenmodelle
|
// Datenmodelle
|
||||||
const RecurringRuleSchema = z.object({
|
const RecurringRuleSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -67,14 +68,22 @@ function detectOverlappingRules(newRule, existingRules) {
|
|||||||
// CRUD-Endpoints für Recurring Rules
|
// CRUD-Endpoints für Recurring Rules
|
||||||
const createRule = os
|
const createRule = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
dayOfWeek: z.number().int().min(0).max(6),
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
}).passthrough())
|
}).passthrough())
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Validierung: startTime < endTime
|
// Validierung: startTime < endTime
|
||||||
const startMinutes = parseTime(input.startTime);
|
const startMinutes = parseTime(input.startTime);
|
||||||
const endMinutes = parseTime(input.endTime);
|
const endMinutes = parseTime(input.endTime);
|
||||||
@@ -106,9 +115,18 @@ const createRule = os
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const updateRule = os
|
const updateRule = os
|
||||||
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
|
.input(RecurringRuleSchema.passthrough())
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Validierung: startTime < endTime
|
// Validierung: startTime < endTime
|
||||||
const startMinutes = parseTime(input.startTime);
|
const startMinutes = parseTime(input.startTime);
|
||||||
const endMinutes = parseTime(input.endTime);
|
const endMinutes = parseTime(input.endTime);
|
||||||
@@ -122,20 +140,38 @@ const updateRule = os
|
|||||||
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||||
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||||
}
|
}
|
||||||
const { sessionId, ...rule } = input;
|
const rule = input;
|
||||||
await recurringRulesKV.setItem(rule.id, rule);
|
await recurringRulesKV.setItem(rule.id, rule);
|
||||||
return rule;
|
return rule;
|
||||||
});
|
});
|
||||||
const deleteRule = os
|
const deleteRule = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
await recurringRulesKV.removeItem(input.id);
|
await recurringRulesKV.removeItem(input.id);
|
||||||
});
|
});
|
||||||
const toggleRuleActive = os
|
const toggleRuleActive = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const rule = await recurringRulesKV.getItem(input.id);
|
const rule = await recurringRulesKV.getItem(input.id);
|
||||||
if (!rule)
|
if (!rule)
|
||||||
throw new Error("Regel nicht gefunden.");
|
throw new Error("Regel nicht gefunden.");
|
||||||
@@ -152,9 +188,9 @@ const listRules = os.handler(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const adminListRules = os
|
const adminListRules = os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const allRules = await recurringRulesKV.getAllItems();
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
return allRules.sort((a, b) => {
|
return allRules.sort((a, b) => {
|
||||||
if (a.dayOfWeek !== b.dayOfWeek)
|
if (a.dayOfWeek !== b.dayOfWeek)
|
||||||
@@ -165,14 +201,15 @@ const adminListRules = os
|
|||||||
// CRUD-Endpoints für Time-Off Periods
|
// CRUD-Endpoints für Time-Off Periods
|
||||||
const createTimeOff = os
|
const createTimeOff = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
reason: z.string(),
|
reason: z.string(),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting direkt nach Owner-Check
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
// Validierung: startDate <= endDate
|
// Validierung: startDate <= endDate
|
||||||
if (input.startDate > input.endDate) {
|
if (input.startDate > input.endDate) {
|
||||||
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||||
@@ -194,21 +231,25 @@ const createTimeOff = os
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const updateTimeOff = os
|
const updateTimeOff = os
|
||||||
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
|
.input(TimeOffPeriodSchema.passthrough())
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting direkt nach Owner-Check
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
// Validierung: startDate <= endDate
|
// Validierung: startDate <= endDate
|
||||||
if (input.startDate > input.endDate) {
|
if (input.startDate > input.endDate) {
|
||||||
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||||
}
|
}
|
||||||
const { sessionId, ...timeOff } = input;
|
const timeOff = input;
|
||||||
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
|
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
|
||||||
return timeOff;
|
return timeOff;
|
||||||
});
|
});
|
||||||
const deleteTimeOff = os
|
const deleteTimeOff = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting direkt nach Owner-Check
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
await timeOffPeriodsKV.removeItem(input.id);
|
await timeOffPeriodsKV.removeItem(input.id);
|
||||||
});
|
});
|
||||||
const listTimeOff = os.handler(async () => {
|
const listTimeOff = os.handler(async () => {
|
||||||
@@ -216,9 +257,9 @@ const listTimeOff = os.handler(async () => {
|
|||||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
});
|
});
|
||||||
const adminListTimeOff = os
|
const adminListTimeOff = os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ context }) => {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
});
|
});
|
||||||
@@ -341,9 +382,9 @@ const live = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
adminListRules: os
|
adminListRules: os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async function* ({ input, signal }) {
|
.handler(async function* ({ context, signal }) {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const allRules = await recurringRulesKV.getAllItems();
|
const allRules = await recurringRulesKV.getAllItems();
|
||||||
const sortedRules = allRules.sort((a, b) => {
|
const sortedRules = allRules.sort((a, b) => {
|
||||||
if (a.dayOfWeek !== b.dayOfWeek)
|
if (a.dayOfWeek !== b.dayOfWeek)
|
||||||
@@ -362,9 +403,9 @@ const live = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
adminListTimeOff: os
|
adminListTimeOff: os
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({}))
|
||||||
.handler(async function* ({ input, signal }) {
|
.handler(async function* ({ context, signal }) {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||||
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
yield sortedTimeOff;
|
yield sortedTimeOff;
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
import { call, os } from "@orpc/server";
|
import { call, os } from "@orpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
import { assertOwner, sessionsKV } from "../lib/auth.js";
|
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||||
|
import { checkAdminRateLimit, getClientIP } from "../lib/rate-limiter.js";
|
||||||
// Schema Definition
|
// Schema Definition
|
||||||
const ReviewSchema = z.object({
|
const ReviewSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -75,20 +76,29 @@ const submitReview = os
|
|||||||
});
|
});
|
||||||
// Admin Endpoint: approveReview
|
// Admin Endpoint: approveReview
|
||||||
const approveReview = os
|
const approveReview = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const review = await reviewsKV.getItem(input.id);
|
const review = await reviewsKV.getItem(input.id);
|
||||||
if (!review) {
|
if (!review) {
|
||||||
throw new Error("Bewertung nicht gefunden");
|
throw new Error("Bewertung nicht gefunden");
|
||||||
}
|
}
|
||||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
const session2 = await getSessionFromCookies(context);
|
||||||
const updatedReview = {
|
const updatedReview = {
|
||||||
...review,
|
...review,
|
||||||
status: "approved",
|
status: "approved",
|
||||||
reviewedAt: new Date().toISOString(),
|
reviewedAt: new Date().toISOString(),
|
||||||
reviewedBy: session?.userId || review.reviewedBy,
|
reviewedBy: session2?.userId || review.reviewedBy,
|
||||||
};
|
};
|
||||||
await reviewsKV.setItem(input.id, updatedReview);
|
await reviewsKV.setItem(input.id, updatedReview);
|
||||||
return updatedReview;
|
return updatedReview;
|
||||||
@@ -100,20 +110,29 @@ const approveReview = os
|
|||||||
});
|
});
|
||||||
// Admin Endpoint: rejectReview
|
// Admin Endpoint: rejectReview
|
||||||
const rejectReview = os
|
const rejectReview = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const review = await reviewsKV.getItem(input.id);
|
const review = await reviewsKV.getItem(input.id);
|
||||||
if (!review) {
|
if (!review) {
|
||||||
throw new Error("Bewertung nicht gefunden");
|
throw new Error("Bewertung nicht gefunden");
|
||||||
}
|
}
|
||||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
const session2 = await getSessionFromCookies(context);
|
||||||
const updatedReview = {
|
const updatedReview = {
|
||||||
...review,
|
...review,
|
||||||
status: "rejected",
|
status: "rejected",
|
||||||
reviewedAt: new Date().toISOString(),
|
reviewedAt: new Date().toISOString(),
|
||||||
reviewedBy: session?.userId || review.reviewedBy,
|
reviewedBy: session2?.userId || review.reviewedBy,
|
||||||
};
|
};
|
||||||
await reviewsKV.setItem(input.id, updatedReview);
|
await reviewsKV.setItem(input.id, updatedReview);
|
||||||
return updatedReview;
|
return updatedReview;
|
||||||
@@ -125,10 +144,19 @@ const rejectReview = os
|
|||||||
});
|
});
|
||||||
// Admin Endpoint: deleteReview
|
// Admin Endpoint: deleteReview
|
||||||
const deleteReview = os
|
const deleteReview = os
|
||||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
const ip = getClientIP(context.req.raw.headers);
|
||||||
|
const session = await getSessionFromCookies(context);
|
||||||
|
if (session) {
|
||||||
|
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
await reviewsKV.removeItem(input.id);
|
await reviewsKV.removeItem(input.id);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -160,12 +188,11 @@ const listPublishedReviews = os.handler(async () => {
|
|||||||
// Admin Endpoint: adminListReviews
|
// Admin Endpoint: adminListReviews
|
||||||
const adminListReviews = os
|
const adminListReviews = os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||||
}))
|
}))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
try {
|
try {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const allReviews = await reviewsKV.getAllItems();
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
const filtered = input.statusFilter === "all"
|
const filtered = input.statusFilter === "all"
|
||||||
? allReviews
|
? allReviews
|
||||||
@@ -188,11 +215,10 @@ const live = {
|
|||||||
}),
|
}),
|
||||||
adminListReviews: os
|
adminListReviews: os
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
sessionId: z.string(),
|
|
||||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||||
}))
|
}))
|
||||||
.handler(async function* ({ input, signal }) {
|
.handler(async function* ({ input, context, signal }) {
|
||||||
await assertOwner(input.sessionId);
|
await assertOwner(context);
|
||||||
const allReviews = await reviewsKV.getAllItems();
|
const allReviews = await reviewsKV.getAllItems();
|
||||||
const filtered = input.statusFilter === "all"
|
const filtered = input.statusFilter === "all"
|
||||||
? allReviews
|
? allReviews
|
||||||
|
@@ -2,6 +2,8 @@ import { call, os } from "@orpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
|
import { assertOwner } from "../lib/auth.js";
|
||||||
|
import { enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||||
const TreatmentSchema = z.object({
|
const TreatmentSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -13,7 +15,10 @@ const TreatmentSchema = z.object({
|
|||||||
const kv = createKV("treatments");
|
const kv = createKV("treatments");
|
||||||
const create = os
|
const create = os
|
||||||
.input(TreatmentSchema.omit({ id: true }))
|
.input(TreatmentSchema.omit({ id: true }))
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const treatment = { id, ...input };
|
const treatment = { id, ...input };
|
||||||
await kv.setItem(id, treatment);
|
await kv.setItem(id, treatment);
|
||||||
@@ -21,11 +26,17 @@ const create = os
|
|||||||
});
|
});
|
||||||
const update = os
|
const update = os
|
||||||
.input(TreatmentSchema)
|
.input(TreatmentSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input, context }) => {
|
||||||
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
await kv.setItem(input.id, input);
|
await kv.setItem(input.id, input);
|
||||||
return input;
|
return input;
|
||||||
});
|
});
|
||||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
const remove = os.input(z.string()).handler(async ({ input, context }) => {
|
||||||
|
await assertOwner(context);
|
||||||
|
// Admin Rate Limiting
|
||||||
|
await enforceAdminRateLimit(context);
|
||||||
await kv.removeItem(input);
|
await kv.removeItem(input);
|
||||||
});
|
});
|
||||||
const list = os.handler(async () => {
|
const list = os.handler(async () => {
|
||||||
|
30
start.sh
30
start.sh
@@ -7,15 +7,33 @@ mkdir -p /app/.storage/treatments
|
|||||||
mkdir -p /app/.storage/availability
|
mkdir -p /app/.storage/availability
|
||||||
mkdir -p /app/.storage/cancellation-tokens
|
mkdir -p /app/.storage/cancellation-tokens
|
||||||
|
|
||||||
# Change ownership to nextjs user
|
# Ensure permissive group-write permissions and correct ownership
|
||||||
chown -R nextjs:nodejs /app/.storage
|
umask 002
|
||||||
|
chown -R nextjs:nodejs /app/.storage 2>/dev/null || true
|
||||||
|
chmod -R 775 /app/.storage 2>/dev/null || true
|
||||||
|
|
||||||
# Ensure runtime dependencies exist (for prebuilt images)
|
# Ensure runtime dependencies exist (for prebuilt images)
|
||||||
if [ ! -d "/app/node_modules/hono" ]; then
|
echo "[startup] Checking runtime dependencies..."
|
||||||
echo "[startup] Installing runtime dependencies (prebuilt safeguard)..."
|
if [ ! -d "/app/node_modules/hono" ] || ! node -e "require('bcrypt')" 2>/dev/null; then
|
||||||
pnpm install --frozen-lockfile --prod --ignore-scripts=false || pnpm install --prod || true
|
if [ "$ALLOW_RUNTIME_INSTALL" = "true" ]; then
|
||||||
pnpm rebuild bcrypt || true
|
echo "[startup] Installing/rebuilding runtime dependencies..."
|
||||||
|
pnpm install --frozen-lockfile --prod --ignore-scripts=false || pnpm install --prod
|
||||||
|
echo "[startup] Rebuilding bcrypt native bindings..."
|
||||||
|
pnpm rebuild bcrypt --build-from-source
|
||||||
|
echo "[startup] Verifying bcrypt installation..."
|
||||||
|
node -e "require('bcrypt')" || { echo "[startup] ERROR: bcrypt verification failed!"; exit 1; }
|
||||||
|
echo "[startup] bcrypt successfully installed and verified."
|
||||||
|
else
|
||||||
|
echo "[startup] ERROR: Runtime dependencies missing and ALLOW_RUNTIME_INSTALL not set!"
|
||||||
|
echo "[startup] This should only happen in prebuilt scenarios. Check your setup."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final verification before starting app
|
||||||
|
echo "[startup] Final bcrypt verification before starting app..."
|
||||||
|
node -e "require('bcrypt')" || { echo "[startup] FATAL: bcrypt not available!"; exit 1; }
|
||||||
|
echo "[startup] All checks passed. Starting application..."
|
||||||
|
|
||||||
# Start the application as nextjs user
|
# Start the application as nextjs user
|
||||||
exec su-exec nextjs node server-dist/index.js
|
exec su-exec nextjs node server-dist/index.js
|
||||||
|
Reference in New Issue
Block a user