commit cb30223fd4d6430a7ae2b089874a98779085735c Author: Tronax Date: Tue Aug 25 18:50:28 2026 +0200 feat: add WannPassts booking app with Go backend and Vue frontend Initial project setup for a privacy-focused, self-hosted scheduling app: - Go backend with JWT auth, SQLite storage, and chi router - Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links - Public booking pages with configurable slots and booking requests - AES-256-GCM encryption for stored provider tokens - Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router - Docs, .gitignore, and .env.example for local development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0561533 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# ─── Node / Frontend ──────────────────────────────────────────────── +node_modules/ +frontend/dist/ +frontend/node_modules/ +*.local +npm-debug.log* +.vite/ + +# ─── Go / Backend ─────────────────────────────────────────────────── +backend/cmd/server/server +backend/server +backend/wannpassts-server +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +coverage.out + +# ─── SQLite-Datenbanken (Nutzerdaten!) ────────────────────────────── +*.db +*.db-wal +*.db-shm + +# ─── Geheimnisse & Konfiguration ──────────────────────────────────── +# .env enthält Secrets – .env.example bleibt versioniert. +.env +.env.* +!.env.example + +# ─── Editoren & Betriebssysteme ───────────────────────────────────── +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ─── Sonstiges ────────────────────────────────────────────────────── +logs/ +*.log +tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3cded8b --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# WannPassts + +**Deine freien Zeiten – geteilt per Link. Ohne deine Termine preiszugeben.** + +WannPassts ist eine Self-Hosted-Buchungs-App (à la Cal.com / Calendly, aber privat): +Du verbindest deine Kalender, teilst einen Link, und andere sehen nur **frei / belegt** +und stellen Buchungsanfragen. Termintitel, Orte und Beschreibungen verlassen deine +Kalender nie. + +## Features + +- 🔐 **Registrierung/Login** (JWT, bcrypt-Passwort-Hashes) +- 📆 **Kalender-Connect:** + - **Google Kalender** per OAuth – nutzt die FreeBusy-API, die *strukturell* keine Termindetails liefert + - **iCloud** & andere (Fastmail, Nextcloud, …) per **CalDAV** – iCloud mit App-spezifischem Passwort + - **Beliebige ICS-/webcal-Links** (z. B. Google-„Private Adresse“, Ferien-/Schichtpläne) +- 🔗 **Öffentliche Buchungsseite** (`/b/`): freie Slots im von dir definierten Zeitfenster, Wochentage, Slot-Raster und Dauern – **niemals Termindetails** +- 📨 **Buchungsanfragen** annehmen/ablehnen; angenommene Zeiten werden belegt und verhindern Doppelbuchungen +- ⚙️ Buchungsregeln: Zeitfenster, Slot-Raster (10–60 Min), Dauern, Wochentage, Horizont (1–120 Tage), Zeitzone +- 🧊 **Liquid-Glass-Dark-UI** (Glassmorphism, animierte Orbs), komplett auf Deutsch +- 🔒 Provider-Tokens werden **AES-256-GCM-verschlüsselt** in der SQLite-DB gespeichert + +## Tech Stack + +| Teil | Technologie | +| -------- | ------------------------------------------------------------------ | +| Frontend | Vue 3 + TypeScript, Vite, Pinia, Vue Router (kein UI-Framework) | +| Backend | Go, chi, JWT, SQLite (modernc, CGO-frei), x/oauth2, go-webdav | + +## Schnellstart (Entwicklung) + +```bash +# Terminal 1 – Backend (http://localhost:8080) +cd backend +cp .env.example .env # optional anpassen; ohne .env läuft es mit Defaults +go run ./cmd/server + +# Terminal 2 – Frontend (http://localhost:5173, Proxied /api → 8080) +cd frontend +npm install +npm run dev +``` + +Dann http://localhost:5173 öffnen, Konto erstellen, Kalender verbinden, Link teilen. 🎉 + +## Produktion (ein einziger Go-Prozess) + +```bash +cd frontend && npm run build && cd .. +cd backend +STATIC_DIR=../frontend/dist \ +APP_URL=https://deine-domain.de \ +FRONTEND_URL=https://deine-domain.de \ +JWT_SECRET=$(openssl rand -hex 32) \ +ENCRYPTION_KEY=$(openssl rand -hex 32) \ +go run ./cmd/server +``` + +Der Go-Server liefert dann das Frontend (`dist/`) **und** die API über einen Port. + +## Kalender-Anbindung einrichten + +### Google +1. [Google Cloud Console](https://console.cloud.google.com): Projekt anlegen, **Google Calendar API** aktivieren. +2. OAuth-Zustimmungsbildschirm (External) einrichten, Scope `calendar.readonly`. +3. **OAuth-Client-ID** (Webanwendung) erstellen; als autorisierte Redirect-URI + `{APP_URL}/api/calendars/google/callback` eintragen (lokal: `http://localhost:8080/api/calendars/google/callback`). +4. `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` in `backend/.env` setzen. + +Beim Verbinden wird nur der **Primärkalender** angebunden; gelesen wird ausschließlich Free/Busy. + +### iCloud +1. Auf [appleid.apple.com](https://appleid.apple.com/account/manage) → *Anmeldung und Sicherheit* → **App-spezifisches Passwort** erzeugen. +2. In WannPassts: *Kalender → iCloud / CalDAV*: + - Server: `https://caldav.icloud.com` + - Benutzername: Apple-ID (E-Mail) + - Passwort: das App-spezifische Passwort (nicht das normale!) +3. Der erste Kalender mit Terminen wird automatisch erkannt (Kalender-Pfad ist optional manuell überschreibbar). + +### Andere (Fastmail, Nextcloud, GMX, …) +Gleiche CalDAV-Maske mit dem jeweiligen CalDAV-Server des Anbieters, oder beliebige **ICS-Links** abonnieren. + +## Datenschutz-Design + +- Besucher sehen ausschließlich **Start/Ende von busy-Zeiträumen** – die API hat gar keine Felder für Titel & Co. +- Google: FreeBusy-API liefert von sich aus nur Belegungszeiträume. +- CalDAV/ICS: Termine werden serverseitig auf `DTSTART/DTEND` reduziert, abgesagte und „Verfügbar“-Termine (TRANSPARENT) werden ignoriert. +- Tokens/Passwörter der Verbindungen liegen verschlüsselt (AES-GCM aus `ENCRYPTION_KEY`) in der DB. +- Sync erfolgt bedarfsgesteuert (max. alle 5 Min) beim Aufruf der Buchungsseite plus manueller Button. + +## Bekannte Grenzen (MVP) + +- **Kein E-Mail-Versand** – Anfragen/Freigaben erscheinen nur im Dashboard (Mail-Versand via SMTP wäre der nächste Ausbauschritt). +- **ICS-Abos** expandieren Serientermine nicht**; CalDAV und Google tun dies serverseitig korrekt. +- **Ausstehende (pending) Anfragen blockieren noch keine Zeit** – erst angenommene. Bei zwei parallelen Anfragen für denselben Slot gewinnt, wer zuerst angenommen wird (Kollision wird beim Annehmen geprüft). +- Ein Google-Konto verbindet aktuell den Primärkalender; weitere Kalender lassen sich als ICS-Abo hinzufügen. + +## Projektstruktur + +``` +backend/ + cmd/server/ # Einstieg + internal/config/ # Env-/Secret-Handling + internal/db/ # SQLite-Schema & Queries + internal/crypto/ # AES-GCM für gespeicherte Provider-Tokens + internal/calendar/ # Provider: google.go, caldav.go, ics.go (+ Syncer) + internal/httpapi/ # Router, Middleware, Handler (auth, calendars, bookings, public) +frontend/ + src/lib/ # api-Client, Typen, Zeitzonen-Helfer + src/stores/ # Pinia: auth, toast + src/views/ # Landing, Login, Register, Dashboard, Booking (/b/:slug) + src/components/ # GlassCard, ShareLink, Sektionen (Kalender/Anfragen/Einstellungen) + src/styles/main.css # Liquid-Glass-Dark-Designsystem +``` + +## API-Überblick + +| Methode | Pfad | Auth | Zweck | +| ------------------- | --------------------------------- | ---- | ---------------------------------------- | +| POST | `/api/auth/register` \| `/login` | – | Konto / JWT | +| GET/PATCH | `/api/me` | ✅ | Profil & Buchungsregeln | +| POST | `/api/me/slug` | ✅ | Neuen Buchungslink erzeugen | +| GET/DELETE | `/api/calendars…` | ✅ | Verbinden (google/caldav/ics), verwalten, sync | +| GET | `/api/public/{slug}` | – | Buchungsseite: Regeln + busy-Zeiträume | +| POST | `/api/public/{slug}/bookings` | – | Buchungsanfrage stellen | +| GET | `/api/bookings`, `…/accept\|decline` | ✅ | Anfragen verwalten | + +## Tests + +```bash +cd backend && go test ./... # ICS-Parser, Dauer-Handling, Interval-Clamping +``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..54f1eeb --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,33 @@ +# ─── WannPassts Backend Konfiguration ──────────────────────────────────────── +# Diese Datei nach backend/.env kopieren und anpassen. + +# HTTP-Port des Backends +PORT=8080 + +# SQLite-Datenbankdatei (relativ zum backend-Verzeichnis) +DB_PATH=wannpassts.db + +# Öffentliche URL des Backends (wichtig für den Google-OAuth-Redirect!) +APP_URL=http://localhost:8080 + +# URL des Frontends (dev: Vite, prod: gleiche Origin wie StaticDir) +FRONTEND_URL=http://localhost:5173 + +# Statische Secrets – in Produktion zwingend setzen (z. B. `openssl rand -hex 32`) +# JWT_SECRET sichert Logins, ENCRYPTION_KEY verschlüsselt gespeicherte Kalender-Tokens. +JWT_SECRET= +ENCRYPTION_KEY= + +# ─── Google Kalender (OAuth) ───────────────────────────────────────────────── +# 1. https://console.cloud.google.com → Projekt anlegen +# 2. APIs & Services → „Google Calendar API“ aktivieren +# 3. OAuth-Zustimmungsbildschirm: External, Scope calendar.readonly +# 4. Credentials → OAuth-Client-ID (Webanwendung) +# Autorisierte Redirect-URI: {APP_URL}/api/calendars/google/callback +# (lokal also http://localhost:8080/api/calendars/google/callback) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# ─── Optional: Frontend-Build direkt ausliefern (Produktion) ──────────────── +# cd frontend && npm run build → STATIC_DIR=../frontend/dist +STATIC_DIR= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..de3be6f --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "net/http" + + "wannpassts/internal/calendar" + "wannpassts/internal/config" + "wannpassts/internal/db" + "wannpassts/internal/httpapi" +) + +func main() { + cfg := config.Load() + + database, err := db.Open(cfg.DBPath) + if err != nil { + log.Fatalf("Datenbank konnte nicht geöffnet werden: %v", err) + } + defer database.Close() + + syncer := &calendar.Syncer{DB: database, Cfg: &cfg} + srv := httpapi.New(database, &cfg, syncer) + + addr := ":" + cfg.Port + log.Printf("WannPassts Backend läuft auf http://localhost%s", addr) + if err := http.ListenAndServe(addr, srv.Router()); err != nil { + log.Fatal(err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..999d068 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,29 @@ +module wannpassts + +go 1.25.0 + +require ( + github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 + github.com/emersion/go-webdav v0.7.0 + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.55.0 + golang.org/x/oauth2 v0.36.0 + modernc.org/sqlite v1.57.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/teambition/rrule-go v1.8.2 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..03bb08b --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,72 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY= +github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= +github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/calendar/caldav.go b/backend/internal/calendar/caldav.go new file mode 100644 index 0000000..57b6f90 --- /dev/null +++ b/backend/internal/calendar/caldav.go @@ -0,0 +1,126 @@ +package calendar + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav/caldav" +) + +// CalDAVConnConfig deckt iCloud (https://caldav.icloud.com mit App-spezifischem +// Passwort) sowie Fastmail, Nextcloud und andere CalDAV-Server ab. +type CalDAVConnConfig struct { + ServerURL string `json:"server_url"` + Username string `json:"username"` + Password string `json:"password"` + CalendarPath string `json:"calendar_path"` +} + +type CalDAVProvider struct { + cfg CalDAVConnConfig +} + +func NewCalDAVProvider(cfg CalDAVConnConfig) *CalDAVProvider { + return &CalDAVProvider{cfg: cfg} +} + +type basicAuthTransport struct { + username, password string +} + +func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.SetBasicAuth(t.username, t.password) + return http.DefaultTransport.RoundTrip(clone) +} + +func newCalDAVClient(cfg CalDAVConnConfig) (*caldav.Client, error) { + hc := &http.Client{Transport: &basicAuthTransport{cfg.Username, cfg.Password}} + return caldav.NewClient(hc, cfg.ServerURL) +} + +// DiscoverCalDAV findet automatisch den ersten Kalender mit Terminen (VEVENT) +// auf dem Server. Für iCloud: Apple-ID als Benutzername und ein im Apple-ID- +// Konto erzeugtes App-spezifisches Passwort. +func DiscoverCalDAV(ctx context.Context, cfg CalDAVConnConfig) (path, name string, err error) { + c, err := newCalDAVClient(cfg) + if err != nil { + return "", "", fmt.Errorf("CalDAV-Server-URL ungültig: %w", err) + } + principal, err := c.FindCurrentUserPrincipal(ctx) + if err != nil { + return "", "", fmt.Errorf("CalDAV: Anmeldung/Principal fehlgeschlagen (Zugangsdaten prüfen; iCloud braucht ein App-spezifisches Passwort): %w", err) + } + homeSet, err := c.FindCalendarHomeSet(ctx, principal) + if err != nil { + return "", "", fmt.Errorf("CalDAV: Kalender-Verzeichnis nicht gefunden: %w", err) + } + cals, err := c.FindCalendars(ctx, homeSet) + if err != nil { + return "", "", fmt.Errorf("CalDAV: Kalender konnten nicht geladen werden: %w", err) + } + for _, cal := range cals { + for _, comp := range cal.SupportedComponentSet { + if strings.EqualFold(comp, "VEVENT") { + return cal.Path, cal.Name, nil + } + } + } + return "", "", fmt.Errorf("CalDAV: kein Kalender mit Terminen gefunden") +} + +func (p *CalDAVProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) { + c, err := newCalDAVClient(p.cfg) + if err != nil { + return nil, err + } + // Der Zeitfilter auf dem Server expandiert auch Serientermine (RFC 4791). + query := &caldav.CalendarQuery{ + CompRequest: caldav.CalendarCompRequest{ + Name: "VCALENDAR", + Comps: []caldav.CalendarCompRequest{{Name: "VEVENT"}}, + }, + CompFilter: caldav.CompFilter{ + Name: "VCALENDAR", + Comps: []caldav.CompFilter{{ + Name: "VEVENT", + Start: from, + End: to, + }}, + }, + } + objects, err := c.QueryCalendar(ctx, p.cfg.CalendarPath, query) + if err != nil { + return nil, fmt.Errorf("CalDAV-Abfrage fehlgeschlagen: %w", err) + } + + var out []Interval + for _, obj := range objects { + if obj.Data == nil { + continue + } + for _, ev := range obj.Data.Events() { + if prop := ev.Props.Get(ical.PropStatus); prop != nil && strings.EqualFold(prop.Value, "CANCELLED") { + continue + } + // Transparente Termine ("Verfügbar") blockieren keine Zeit. + if prop := ev.Props.Get("TRANSP"); prop != nil && strings.EqualFold(prop.Value, "TRANSPARENT") { + continue + } + start, err := ev.DateTimeStart(time.UTC) + if err != nil { + continue + } + end, err := ev.DateTimeEnd(time.UTC) + if err != nil || !end.After(start) { + continue + } + out = append(out, Interval{Start: start, End: end}) + } + } + return out, nil +} diff --git a/backend/internal/calendar/google.go b/backend/internal/calendar/google.go new file mode 100644 index 0000000..f0680ca --- /dev/null +++ b/backend/internal/calendar/google.go @@ -0,0 +1,151 @@ +package calendar + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// GoogleConnConfig ist der verschlüsselt gespeicherte Zustand einer +// Google-Kalenderverbindung. +type GoogleConnConfig struct { + Token *oauth2.Token `json:"token"` + CalendarID string `json:"calendar_id"` +} + +// GoogleOAuthConfig baut die OAuth2-Konfiguration. Die FreeBusy-API von Google +// liefert ausschließlich busy-Zeiträume – gar keine Termindetails. +func GoogleOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"https://www.googleapis.com/auth/calendar.readonly"}, + Endpoint: google.Endpoint, + } +} + +type GoogleProvider struct { + conf *oauth2.Config + cfg GoogleConnConfig + onTokenSaved func(*oauth2.Token) // wird aufgerufen, wenn das Token erneuert wurde +} + +func NewGoogleProvider(conf *oauth2.Config, cfg GoogleConnConfig, onTokenSaved func(*oauth2.Token)) *GoogleProvider { + return &GoogleProvider{conf: conf, cfg: cfg, onTokenSaved: onTokenSaved} +} + +type googleFreeBusyRequest struct { + TimeMin string `json:"timeMin"` + TimeMax string `json:"timeMax"` + Items []googleFreeBusyRequestItem `json:"items"` +} + +type googleFreeBusyRequestItem struct { + ID string `json:"id"` +} + +type googleFreeBusyResponse struct { + Calendars map[string]struct { + Busy []struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + } `json:"busy"` + Errors []struct { + Reason string `json:"reason"` + } `json:"errors"` + } `json:"calendars"` +} + +func (p *GoogleProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) { + ts := p.conf.TokenSource(ctx, p.cfg.Token) + tok, err := ts.Token() + if err != nil { + return nil, fmt.Errorf("Google-Zugriff ungültig (neu verbinden): %w", err) + } + if tok.AccessToken != p.cfg.Token.AccessToken { + saved := *tok + p.cfg.Token = &saved + if p.onTokenSaved != nil { + p.onTokenSaved(&saved) + } + } + + body, err := json.Marshal(googleFreeBusyRequest{ + TimeMin: from.UTC().Format(time.RFC3339), + TimeMax: to.UTC().Format(time.RFC3339), + Items: []googleFreeBusyRequestItem{{ID: p.cfg.CalendarID}}, + }) + if err != nil { + return nil, err + } + + client := oauth2.NewClient(ctx, ts) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://www.googleapis.com/calendar/v3/freeBusy", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Google FreeBusy-Anfrage fehlgeschlagen: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Google FreeBusy-Fehler (HTTP %d): %.200s", resp.StatusCode, string(raw)) + } + + var out googleFreeBusyResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("Google FreeBusy-Antwort unlesbar: %w", err) + } + + cal, ok := out.Calendars[p.cfg.CalendarID] + if !ok { + return nil, fmt.Errorf("Google: Kalender %s nicht in Antwort", p.cfg.CalendarID) + } + if len(cal.Errors) > 0 { + return nil, fmt.Errorf("Google: %s", cal.Errors[0].Reason) + } + intervals := make([]Interval, 0, len(cal.Busy)) + for _, b := range cal.Busy { + intervals = append(intervals, Interval{Start: b.Start, End: b.End}) + } + return intervals, nil +} + +// GooglePrimaryCalendar liefert ID und Namen des Primärkalenders – genutzt +// direkt nach dem OAuth-Flow. +func GooglePrimaryCalendar(ctx context.Context, client *http.Client) (id, summary string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "https://www.googleapis.com/calendar/v3/calendars/primary", nil) + if err != nil { + return "", "", err + } + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("Google: Primärkalender nicht lesbar (HTTP %d): %.200s", resp.StatusCode, string(raw)) + } + var cal struct { + ID string `json:"id"` + Summary string `json:"summary"` + } + if err := json.Unmarshal(raw, &cal); err != nil { + return "", "", err + } + return cal.ID, cal.Summary, nil +} diff --git a/backend/internal/calendar/ics.go b/backend/internal/calendar/ics.go new file mode 100644 index 0000000..98b294e --- /dev/null +++ b/backend/internal/calendar/ics.go @@ -0,0 +1,270 @@ +package calendar + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// ICSConnConfig: Abo eines beliebigen Kalenders per ICS-/webcal-Link +// (z. B. die "private Adresse im iCal-Format" von Google oder Outlook-Feeds). +type ICSConnConfig struct { + URL string `json:"url"` +} + +type ICSProvider struct { + URL string + // DefaultTZ wird für "floating" Zeiten (ohne TZID/UTC) genutzt, + // typischerweise die Zeitzone des Kalenderbesitzers. + DefaultTZ string +} + +func NewICSProvider(cfg ICSConnConfig, defaultTZ string) *ICSProvider { + return &ICSProvider{URL: cfg.URL, DefaultTZ: defaultTZ} +} + +type icsEvent struct { + start time.Time + hasStart bool + allDay bool + end time.Time + cancelled bool + transparent bool +} + +func (p *ICSProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "WannPassts/1.0 (+calendar-sync)") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("ICS-Link nicht abrufbar: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ICS-Link antwortet mit HTTP %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, fmt.Errorf("ICS-Antwort nicht lesbar: %w", err) + } + + defLoc := time.UTC + if p.DefaultTZ != "" { + if loc, err := time.LoadLocation(p.DefaultTZ); err == nil { + defLoc = loc + } + } + + events := parseICS(string(raw), defLoc) + var out []Interval + for _, ev := range events { + if ev.cancelled || ev.transparent || !ev.hasStart { + continue + } + end := ev.end + if end.IsZero() || !end.After(ev.start) { + if ev.allDay { + end = ev.start.Add(24 * time.Hour) + } else { + continue + } + } + out = append(out, Interval{Start: ev.start, End: end}) + } + return clampIntervals(out, from, to), nil +} + +// parseICS liest VEVENTs aus ICS-Daten. Zeilen werden entfaltet (RFC 5545), +// Parameter wie TZID/VALUE=DATE ausgewertet. Serientermine werden als ihre +// Basis-Instanz übernommen – ICS-Abos sind damit der "best effort"-Weg, +// CalDAV und Google expandieren Serien serverseitig korrekt. +func parseICS(input string, defLoc *time.Location) []icsEvent { + input = strings.ReplaceAll(input, "\r\n", "\n") + rawLines := strings.Split(input, "\n") + lines := make([]string, 0, len(rawLines)) + for _, l := range rawLines { + if strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t") { + if len(lines) > 0 { + lines[len(lines)-1] += l[1:] + } + continue + } + lines = append(lines, strings.TrimSuffix(l, "\r")) + } + + var events []icsEvent + var cur *icsEvent + for _, line := range lines { + upper := strings.ToUpper(strings.TrimSpace(line)) + switch { + case upper == "BEGIN:VEVENT": + cur = &icsEvent{} + continue + case upper == "END:VEVENT": + if cur != nil { + events = append(events, *cur) + cur = nil + } + continue + } + if cur == nil { + continue + } + name, params, value := splitICSLine(line) + switch name { + case "DTSTART": + if t, allDay, ok := parseICSDateTime(value, params, defLoc); ok { + cur.start, cur.hasStart, cur.allDay = t, true, allDay + } + case "DTEND": + if t, _, ok := parseICSDateTime(value, params, defLoc); ok { + cur.end = t + } + case "DURATION": + if cur.hasStart { + if d, ok := parseISODuration(value); ok { + cur.end = cur.start.Add(d) + } + } + case "STATUS": + if strings.EqualFold(value, "CANCELLED") { + cur.cancelled = true + } + case "TRANSP": + if strings.EqualFold(value, "TRANSPARENT") { + cur.transparent = true + } + } + } + return events +} + +// splitICSLine zerlegt "NAME;PARAM=WERT;...:value" am ersten Doppelpunkt. +func splitICSLine(line string) (name string, params map[string]string, value string) { + params = map[string]string{} + i := strings.IndexByte(line, ':') + if i == -1 { + return strings.ToUpper(strings.TrimSpace(line)), params, "" + } + head, value := line[:i], strings.TrimSpace(line[i+1:]) + parts := strings.Split(head, ";") + name = strings.ToUpper(strings.TrimSpace(parts[0])) + for _, p := range parts[1:] { + if eq := strings.IndexByte(p, '='); eq > 0 { + key := strings.ToUpper(strings.TrimSpace(p[:eq])) + params[key] = strings.Trim(strings.TrimSpace(p[eq+1:]), `"`) + } + } + return name, params, value +} + +func parseICSDateTime(value string, params map[string]string, defLoc *time.Location) (t time.Time, allDay, ok bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false, false + } + if params["VALUE"] == "DATE" || (len(value) == 8 && allDigits(value)) { + y, err1 := strconv.Atoi(value[0:4]) + m, err2 := strconv.Atoi(value[4:6]) + d, err3 := strconv.Atoi(value[6:8]) + if err1 != nil || err2 != nil || err3 != nil { + return time.Time{}, false, false + } + return time.Date(y, time.Month(m), d, 0, 0, 0, 0, defLoc), true, true + } + layouts := []struct { + layout string + utc bool + }{ + {"20060102T150405Z", true}, + {"20060102T150405", false}, + {"2006-01-02T15:04:05Z07:00", true}, + {"2006-01-02T15:04:05", false}, + } + for _, l := range layouts { + if parsed, err := time.Parse(l.layout, value); err == nil { + if l.utc { + return parsed.UTC(), false, true + } + // Wanduhrzeit in der Zielzone interpretieren (nicht instant-konvertieren) + loc := defLoc + if tzid := params["TZID"]; tzid != "" { + if loaded, err := time.LoadLocation(tzid); err == nil { + loc = loaded + } + } + y, mo, d := parsed.Date() + hh, mm, ss := parsed.Clock() + return time.Date(y, mo, d, hh, mm, ss, 0, loc), false, true + } + } + return time.Time{}, false, false +} + +func allDigits(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 +} + +// parseISODuration versteht die üblichen ICS-Dauerformen: PT15M, PT1H30M, +// P1D, P2W, PT45S … +func parseISODuration(s string) (time.Duration, bool) { + s = strings.TrimSpace(strings.ToUpper(s)) + if len(s) < 2 || s[0] != 'P' || strings.Contains(s, "-") { + return 0, false + } + body := s[1:] + if strings.HasSuffix(body, "W") { + v, err := strconv.ParseFloat(strings.TrimSuffix(body, "W"), 64) + if err != nil { + return 0, false + } + return time.Duration(v * float64(7*24*time.Hour)), true + } + if strings.HasPrefix(body, "T") { + body = body[1:] + } + units := map[byte]time.Duration{ + 'D': 24 * time.Hour, + 'H': time.Hour, + 'M': time.Minute, + 'S': time.Second, + } + var total time.Duration + num := "" + flush := func(unit time.Duration) bool { + if num == "" { + return false + } + v, err := strconv.ParseFloat(num, 64) + if err != nil { + return false + } + total += time.Duration(v * float64(unit)) + num = "" + return true + } + for i := 0; i < len(body); i++ { + c := body[i] + if (c >= '0' && c <= '9') || c == '.' { + num += string(c) + continue + } + unit, known := units[c] + if !known || !flush(unit) { + return 0, false + } + } + return total, num == "" +} diff --git a/backend/internal/calendar/ics_test.go b/backend/internal/calendar/ics_test.go new file mode 100644 index 0000000..d013a78 --- /dev/null +++ b/backend/internal/calendar/ics_test.go @@ -0,0 +1,145 @@ +package calendar + +import ( + "testing" + "time" +) + +const sampleICS = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +BEGIN:STANDARD +DTSTART:19701025T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:1 +DTSTART;TZID=Europe/Berlin:20260825T090000 +DTEND;TZID=Europe/Berlin:20260825T100000 +SUMMARY:Zahnarzt +END:VEVENT +BEGIN:VEVENT +UID:2 +DTSTART:20260826T120000Z +DURATION:PT45M +SUMMARY:Call +END:VEVENT +BEGIN:VEVENT +UID:3 +DTSTART;VALUE=DATE:20260827 +SUMMARY:Ganztägig +END:VEVENT +BEGIN:VEVENT +UID:4 +DTSTART:20260828T080000Z +DTEND:20260828T090000Z +STATUS:CANCELLED +SUMMARY:Abgesagt +END:VEVENT +BEGIN:VEVENT +UID:5 +DTSTART:20260828T100000Z +DTEND:20260828T110000Z +TRANSP:TRANSPARENT +SUMMARY:Verfügbar +END:VEVENT +BEGIN:VEVENT +UID:6 +DTSTART:20260829T080000Z +DTEND:202609 + 01T080000Z +SUMMARY:Gefaltet +END:VEVENT +END:VCALENDAR +` + +func TestParseICS(t *testing.T) { + berlin, err := time.LoadLocation("Europe/Berlin") + if err != nil { + t.Fatal(err) + } + events := parseICS(sampleICS, berlin) + if len(events) != 6 { + t.Fatalf("6 Events erwartet, got %d", len(events)) + } + + // TZID-Event + if !events[0].start.Equal(time.Date(2026, 8, 25, 9, 0, 0, 0, berlin)) { + t.Errorf("TZID-Start falsch: %v", events[0].start) + } + if !events[0].end.Equal(time.Date(2026, 8, 25, 10, 0, 0, 0, berlin)) { + t.Errorf("TZID-Ende falsch: %v", events[0].end) + } + + // UTC + DURATION + if !events[1].end.Equal(events[1].start.Add(45*time.Minute)) { + t.Errorf("DURATION nicht angewandt: %v", events[1].end) + } + + // Ganztägig + if !events[2].allDay { + t.Error("VALUE=DATE nicht erkannt") + } + + // Cancelled / Transparent markiert + if !events[3].cancelled { + t.Error("CANCELLED nicht erkannt") + } + if !events[4].transparent { + t.Error("TRANSPARENT nicht erkannt") + } + + // Gefaltete Zeile (DTEND über zwei Zeilen) + if !events[5].end.Equal(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)) { + t.Errorf("Folding nicht aufgelöst, Ende: %v", events[5].end) + } +} + +func TestParseISODuration(t *testing.T) { + cases := map[string]time.Duration{ + "PT15M": 15 * time.Minute, + "PT1H30M": 90 * time.Minute, + "P1D": 24 * time.Hour, + "P2W": 14 * 24 * time.Hour, + "PT45S": 45 * time.Second, + "": 0, + "X": 0, + "P-1D": 0, + } + for in, want := range cases { + got, ok := parseISODuration(in) + if in == "" || in == "X" || in == "P-1D" { + if ok { + t.Errorf("%q sollte ungültig sein", in) + } + continue + } + if !ok || got != want { + t.Errorf("parseISODuration(%q) = %v (ok=%v), want %v", in, got, ok, want) + } + } +} + +func TestClampIntervals(t *testing.T) { + from := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC) + to := from.Add(48 * time.Hour) + in := []Interval{ + {Start: from.Add(-2 * time.Hour), End: from.Add(time.Hour)}, + {Start: from.Add(3 * time.Hour), End: from.Add(2 * time.Hour)}, // ungültig + {Start: from.Add(10 * time.Hour), End: to.Add(time.Hour)}, + } + out := clampIntervals(in, from, to) + if len(out) != 2 { + t.Fatalf("2 Intervalle erwartet, got %d", len(out)) + } + if !out[0].Start.Equal(from) || !out[0].End.Equal(from.Add(time.Hour)) { + t.Errorf("Intervall 1 nicht geklemmt: %v–%v", out[0].Start, out[0].End) + } + if !out[1].End.Equal(to) { + t.Errorf("Intervall 2 nicht geklemmt: %v", out[1].End) + } +} diff --git a/backend/internal/calendar/provider.go b/backend/internal/calendar/provider.go new file mode 100644 index 0000000..dfcaa0c --- /dev/null +++ b/backend/internal/calendar/provider.go @@ -0,0 +1,41 @@ +package calendar + +import ( + "context" + "sort" + "time" +) + +// Interval ist ein "beschäftigt"-Zeitraum. Enthält bewusst KEINE Termindetails, +// nur Start und Ende – mehr verlässt den Kalender des Nutzers nie. +type Interval struct { + Start time.Time + End time.Time +} + +type Provider interface { + // FetchBusy liefert alle busy-Zeiträume im Fenster [from, to). + FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) +} + +// clampIntervals schneidet Intervalle aufs Fenster zurecht, verwirft ungültige +// und sortiert das Ergebnis. +func clampIntervals(in []Interval, from, to time.Time) []Interval { + out := make([]Interval, 0, len(in)) + for _, iv := range in { + if !iv.End.After(iv.Start) { + continue + } + if iv.Start.Before(from) { + iv.Start = from + } + if iv.End.After(to) { + iv.End = to + } + if iv.End.After(iv.Start) { + out = append(out, iv) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) }) + return out +} diff --git a/backend/internal/calendar/sync.go b/backend/internal/calendar/sync.go new file mode 100644 index 0000000..5274abd --- /dev/null +++ b/backend/internal/calendar/sync.go @@ -0,0 +1,139 @@ +package calendar + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "golang.org/x/oauth2" + + "wannpassts/internal/config" + "wannpassts/internal/crypto" + "wannpassts/internal/db" +) + +const syncStaleAfter = 5 * time.Minute + +type Syncer struct { + DB *db.DB + Cfg *config.Config +} + +// NeedsSync meldet, ob eine der Verbindungen älter als syncStaleAfter ist. +func NeedsSync(conns []*db.Connection, now time.Time) bool { + for _, c := range conns { + if !c.LastSyncedTs.Valid || now.Unix()-c.LastSyncedTs.Int64 > int64(syncStaleAfter.Seconds()) { + return true + } + } + return false +} + +// SyncUser aktualisiert die busy-Zeiträume aller Verbindungen eines Nutzers +// für das Buchungsfenster [jetzt, jetzt+horizon]. Fehler einzelner Provider +// werden pro Verbindung gespeichert und werfen den Gesamtsync nicht ab. +func (s *Syncer) SyncUser(ctx context.Context, user *db.User) { + conns, err := s.DB.ConnectionsForUser(user.ID) + if err != nil { + log.Printf("sync: Verbindungen von User %d nicht lesbar: %v", user.ID, err) + return + } + if len(conns) == 0 { + return + } + from := time.Now().Add(-time.Hour) + to := time.Now().Add(time.Duration(user.HorizonDays) * 24 * time.Hour) + + for _, conn := range conns { + s.syncConnection(ctx, conn, user, from, to) + } +} + +func (s *Syncer) syncConnection(ctx context.Context, conn *db.Connection, user *db.User, from, to time.Time) { + prov, err := s.provider(conn) + if err != nil { + _ = s.DB.SetConnectionState(conn.ID, false, err.Error()) + return + } + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + busy, err := prov.FetchBusy(cctx, from, to) + if err != nil { + _ = s.DB.SetConnectionState(conn.ID, false, cleanErr(err.Error())) + return + } + slots := make([]db.BusySlot, 0, len(busy)) + for _, iv := range busy { + slots = append(slots, db.BusySlot{StartTs: iv.Start.Unix(), EndTs: iv.End.Unix()}) + } + if err := s.DB.ReplaceBusyForConnection(conn.ID, user.ID, slots); err != nil { + _ = s.DB.SetConnectionState(conn.ID, false, "Speichern fehlgeschlagen: "+err.Error()) + return + } + _ = s.DB.SetConnectionState(conn.ID, true, "") +} + +// SyncInBackground startet den Sync ohne den Aufrufer zu blocken. +func (s *Syncer) SyncInBackground(user *db.User) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + s.SyncUser(ctx, user) + }() +} + +func (s *Syncer) provider(conn *db.Connection) (Provider, error) { + plain, err := crypto.Decrypt(s.Cfg.EncryptionKey, conn.ConfigEnc) + if err != nil { + return nil, fmt.Errorf("gespeicherte Verbindung nicht entschlüsselbar (ENCRYPTION_KEY geändert?): %w", err) + } + switch conn.Provider { + case "google": + var cfg GoogleConnConfig + if err := json.Unmarshal(plain, &cfg); err != nil { + return nil, fmt.Errorf("Google-Konfiguration unlesbar: %w", err) + } + oconf := GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret, + s.Cfg.AppURL+"/api/calendars/google/callback") + calendarID := cfg.CalendarID + return NewGoogleProvider(oconf, cfg, func(t *oauth2.Token) { + s.saveGoogleToken(conn.ID, calendarID, t) + }), nil + case "caldav": + var cfg CalDAVConnConfig + if err := json.Unmarshal(plain, &cfg); err != nil { + return nil, fmt.Errorf("CalDAV-Konfiguration unlesbar: %w", err) + } + return NewCalDAVProvider(cfg), nil + case "ics": + var cfg ICSConnConfig + if err := json.Unmarshal(plain, &cfg); err != nil { + return nil, fmt.Errorf("ICS-Konfiguration unlesbar: %w", err) + } + return NewICSProvider(cfg, ""), nil + } + return nil, fmt.Errorf("unbekannter Provider %q", conn.Provider) +} + +func (s *Syncer) saveGoogleToken(connID int64, calendarID string, t *oauth2.Token) { + data, err := json.Marshal(GoogleConnConfig{Token: t, CalendarID: calendarID}) + if err != nil { + return + } + enc, err := crypto.Encrypt(s.Cfg.EncryptionKey, data) + if err != nil { + return + } + _ = s.DB.UpdateConnectionConfig(connID, enc) +} + +func cleanErr(msg string) string { + msg = strings.TrimSpace(msg) + if len(msg) > 300 { + msg = msg[:300] + "…" + } + return msg +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1f8f7e0 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "log" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + Port string + DBPath string + AppURL string + FrontendURL string + JWTSecret string + EncryptionKey string + GoogleClientID string + GoogleClientSecret string + StaticDir string +} + +// Load liest die Konfiguration aus Umgebungsvariablen (.env wird unterstützt). +// Fehlende Secrets werden nur für die Entwicklung zufällig erzeugt. +func Load() Config { + _ = godotenv.Load() + + cfg := Config{ + Port: env("PORT", "8080"), + DBPath: env("DB_PATH", "wannpassts.db"), + AppURL: env("APP_URL", "http://localhost:8080"), + FrontendURL: env("FRONTEND_URL", "http://localhost:5173"), + JWTSecret: os.Getenv("JWT_SECRET"), + EncryptionKey: os.Getenv("ENCRYPTION_KEY"), + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + StaticDir: env("STATIC_DIR", ""), + } + if cfg.JWTSecret == "" { + cfg.JWTSecret = randomHex(32) + log.Println("WARNUNG: JWT_SECRET nicht gesetzt – temporäres Secret erzeugt (Sessions verfallen bei Neustart).") + } + if cfg.EncryptionKey == "" { + cfg.EncryptionKey = randomHex(32) + log.Println("WARNUNG: ENCRYPTION_KEY nicht gesetzt – temporärer Schlüssel erzeugt (Kalender-Tokens verfallen bei Neustart).") + } + return cfg +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + log.Fatalf("Zufallszahl nicht verfügbar: %v", err) + } + return hex.EncodeToString(b) +} diff --git a/backend/internal/crypto/secret.go b/backend/internal/crypto/secret.go new file mode 100644 index 0000000..1f60b71 --- /dev/null +++ b/backend/internal/crypto/secret.go @@ -0,0 +1,52 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" +) + +// Encrypt verschlüsselt plaintext mit AES-256-GCM (Key = SHA256(secret)) +// und liefert base64(nonce + ciphertext). +func Encrypt(secret string, plaintext []byte) (string, error) { + k := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(k[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.StdEncoding.EncodeToString(sealed), nil +} + +// Decrypt macht Encrypt rückgängig. +func Decrypt(secret, s string) ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + k := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(k[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(data) < gcm.NonceSize() { + return nil, errors.New("ciphertext zu kurz") + } + return gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil) +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..ddb6d02 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,354 @@ +package db + +import ( + "database/sql" + "errors" + "time" + + _ "modernc.org/sqlite" +) + +var ErrNotFound = errors.New("nicht gefunden") + +type DB struct { + *sql.DB +} + +type User struct { + ID int64 + Email string + Name string + PasswordHash string + Slug string + Timezone string + SlotMinutes int + DayStartMin int + DayEndMin int + HorizonDays int + Durations string + Weekdays string + CreatedTs int64 +} + +type Connection struct { + ID int64 + UserID int64 + Provider string // google | caldav | ics + DisplayName string + ConfigEnc string + LastSyncedTs sql.NullInt64 + LastError sql.NullString + CreatedTs int64 +} + +type BusySlot struct { + ID int64 + UserID int64 + ConnectionID sql.NullInt64 + StartTs int64 + EndTs int64 + BookingID sql.NullInt64 +} + +type Booking struct { + ID int64 + UserID int64 + StartTs int64 + EndTs int64 + RequesterName string + RequesterEmail string + Message string + Status string // pending | accepted | declined + CreatedTs int64 +} + +func Open(path string) (*DB, error) { + d, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + // SQLite verträgt parallele Writer nicht gut – ein Writer reicht hier. + d.SetMaxOpenConns(1) + if err := d.Ping(); err != nil { + return nil, err + } + dbase := &DB{d} + if err := dbase.migrate(); err != nil { + return nil, err + } + return dbase, nil +} + +func (d *DB) migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + timezone TEXT NOT NULL DEFAULT 'Europe/Berlin', + slot_minutes INTEGER NOT NULL DEFAULT 30, + day_start_min INTEGER NOT NULL DEFAULT 540, + day_end_min INTEGER NOT NULL DEFAULT 1080, + horizon_days INTEGER NOT NULL DEFAULT 21, + durations TEXT NOT NULL DEFAULT '15,30,60,120', + weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5', + created_ts INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS calendar_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + display_name TEXT NOT NULL, + config_enc TEXT NOT NULL, + last_synced_ts INTEGER, + last_error TEXT, + created_ts INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS busy_slots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + connection_id INTEGER REFERENCES calendar_connections(id) ON DELETE CASCADE, + booking_id INTEGER, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_busy_user_time ON busy_slots(user_id, start_ts)`, + `CREATE INDEX IF NOT EXISTS idx_busy_booking ON busy_slots(booking_id)`, + `CREATE TABLE IF NOT EXISTS booking_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + requester_name TEXT NOT NULL, + requester_email TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_ts INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_bookings_user ON booking_requests(user_id, created_ts)`, + } + for _, s := range stmts { + if _, err := d.Exec(s); err != nil { + return err + } + } + return nil +} + +// --- Users --- + +func (d *DB) CreateUser(u *User) error { + return d.QueryRow( + `INSERT INTO users (email, name, password_hash, slug, timezone, created_ts) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, + u.Email, u.Name, u.PasswordHash, u.Slug, u.Timezone, time.Now().Unix(), + ).Scan(&u.ID) +} + +const userCols = `id, email, name, password_hash, slug, timezone, slot_minutes, day_start_min, day_end_min, horizon_days, durations, weekdays, created_ts` + +func scanUser(row interface{ Scan(...any) error }) (*User, error) { + u := &User{} + err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Slug, &u.Timezone, + &u.SlotMinutes, &u.DayStartMin, &u.DayEndMin, &u.HorizonDays, &u.Durations, &u.Weekdays, &u.CreatedTs) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return u, nil +} + +func (d *DB) UserByEmail(email string) (*User, error) { + return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ?`, email)) +} + +func (d *DB) UserByID(id int64) (*User, error) { + return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id)) +} + +func (d *DB) UserBySlug(slug string) (*User, error) { + return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE slug = ?`, slug)) +} + +func (d *DB) SlugExists(slug string) (bool, error) { + var one int + err := d.QueryRow(`SELECT 1 FROM users WHERE slug = ?`, slug).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +func (d *DB) UpdateUserSettings(u *User) error { + _, err := d.Exec(`UPDATE users SET name=?, timezone=?, slot_minutes=?, day_start_min=?, day_end_min=?, horizon_days=?, durations=?, weekdays=? WHERE id=?`, + u.Name, u.Timezone, u.SlotMinutes, u.DayStartMin, u.DayEndMin, u.HorizonDays, u.Durations, u.Weekdays, u.ID) + return err +} + +func (d *DB) UpdateUserSlug(id int64, slug string) error { + _, err := d.Exec(`UPDATE users SET slug=? WHERE id=?`, slug, id) + return err +} + +// --- Connections --- + +func (d *DB) CreateConnection(userID int64, provider, displayName, configEnc string) (int64, error) { + var id int64 + err := d.QueryRow( + `INSERT INTO calendar_connections (user_id, provider, display_name, config_enc, created_ts) + VALUES (?, ?, ?, ?, ?) RETURNING id`, + userID, provider, displayName, configEnc, time.Now().Unix(), + ).Scan(&id) + return id, err +} + +const connCols = `id, user_id, provider, display_name, config_enc, last_synced_ts, last_error, created_ts` + +func (d *DB) ConnectionsForUser(userID int64) ([]*Connection, error) { + rows, err := d.Query(`SELECT `+connCols+` FROM calendar_connections WHERE user_id = ? ORDER BY created_ts`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Connection + for rows.Next() { + c := &Connection{} + if err := rows.Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (d *DB) ConnectionByID(id, userID int64) (*Connection, error) { + c := &Connection{} + err := d.QueryRow(`SELECT `+connCols+` FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID). + Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return c, nil +} + +func (d *DB) DeleteConnection(id, userID int64) error { + _, err := d.Exec(`DELETE FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID) + return err +} + +func (d *DB) SetConnectionState(id int64, synced bool, errMsg string) error { + var syncedTs any + if synced { + syncedTs = time.Now().Unix() + } + _, err := d.Exec(`UPDATE calendar_connections SET last_synced_ts = ?, last_error = ? WHERE id = ?`, syncedTs, errMsg, id) + return err +} + +func (d *DB) UpdateConnectionConfig(id int64, configEnc string) error { + _, err := d.Exec(`UPDATE calendar_connections SET config_enc = ? WHERE id = ?`, configEnc, id) + return err +} + +// --- Busy slots --- + +func (d *DB) ReplaceBusyForConnection(connID, userID int64, slots []BusySlot) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM busy_slots WHERE connection_id = ?`, connID); err != nil { + return err + } + for _, s := range slots { + if _, err := tx.Exec(`INSERT INTO busy_slots (user_id, connection_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`, + userID, connID, s.StartTs, s.EndTs); err != nil { + return err + } + } + return tx.Commit() +} + +func (d *DB) BusyForUser(userID, fromTs, toTs int64) ([]BusySlot, error) { + rows, err := d.Query(`SELECT id, user_id, connection_id, start_ts, end_ts, booking_id FROM busy_slots + WHERE user_id = ? AND end_ts > ? AND start_ts < ? ORDER BY start_ts`, userID, fromTs, toTs) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BusySlot + for rows.Next() { + var s BusySlot + if err := rows.Scan(&s.ID, &s.UserID, &s.ConnectionID, &s.StartTs, &s.EndTs, &s.BookingID); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// --- Bookings --- + +func (d *DB) CreateBooking(b *Booking) error { + return d.QueryRow( + `INSERT INTO booking_requests (user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) RETURNING id`, + b.UserID, b.StartTs, b.EndTs, b.RequesterName, b.RequesterEmail, b.Message, time.Now().Unix(), + ).Scan(&b.ID) +} + +const bookingCols = `id, user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts` + +func (d *DB) BookingsForUser(userID int64) ([]*Booking, error) { + rows, err := d.Query(`SELECT `+bookingCols+` FROM booking_requests WHERE user_id = ? ORDER BY (status='pending') DESC, start_ts ASC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Booking + for rows.Next() { + b := &Booking{} + if err := rows.Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +func (d *DB) BookingByID(id, userID int64) (*Booking, error) { + b := &Booking{} + err := d.QueryRow(`SELECT `+bookingCols+` FROM booking_requests WHERE id = ? AND user_id = ?`, id, userID). + Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return b, nil +} + +func (d *DB) SetBookingStatus(id int64, status string) error { + _, err := d.Exec(`UPDATE booking_requests SET status = ? WHERE id = ?`, status, id) + return err +} + +func (d *DB) InsertBusyForBooking(b *Booking) error { + _, err := d.Exec(`INSERT INTO busy_slots (user_id, booking_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`, + b.UserID, b.ID, b.StartTs, b.EndTs) + return err +} + +func (d *DB) DeleteBusyByBooking(bookingID int64) error { + _, err := d.Exec(`DELETE FROM busy_slots WHERE booking_id = ?`, bookingID) + return err +} diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go new file mode 100644 index 0000000..6ee7ea5 --- /dev/null +++ b/backend/internal/httpapi/auth.go @@ -0,0 +1,289 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + + "wannpassts/internal/db" +) + +const tokenTTL = 7 * 24 * time.Hour + +func (s *Server) Register(w http.ResponseWriter, r *http.Request) { + var in struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Timezone string `json:"timezone"` + } + if err := decodeBody(r, &in); err != nil { + jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") + return + } + in.Email = strings.ToLower(strings.TrimSpace(in.Email)) + in.Name = strings.TrimSpace(in.Name) + if !validEmail(in.Email) { + jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben") + return + } + if len(in.Password) < 8 || len(in.Password) > 200 { + jsonError(w, http.StatusBadRequest, "Das Passwort muss 8–200 Zeichen lang sein") + return + } + if in.Name == "" || len(in.Name) > 80 { + jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben") + return + } + tz := in.Timezone + if tz == "" { + tz = "Europe/Berlin" + } + if _, err := time.LoadLocation(tz); err != nil { + jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone") + return + } + if _, err := s.DB.UserByEmail(in.Email); err == nil { + jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert") + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden") + return + } + slug, err := s.uniqueSlug() + if err != nil { + jsonError(w, http.StatusInternalServerError, "Interner Fehler") + return + } + u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz} + if err := s.DB.CreateUser(u); err != nil { + jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden") + return + } + token, err := s.signJWT(u.ID, tokenTTL) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)}) +} + +func (s *Server) Login(w http.ResponseWriter, r *http.Request) { + var in struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := decodeBody(r, &in); err != nil { + jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") + return + } + u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email))) + if err != nil { + jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch") + return + } + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil { + jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch") + return + } + token, err := s.signJWT(u.ID, tokenTTL) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden") + return + } + writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)}) +} + +func (s *Server) Me(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))}) +} + +func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) { + u := userFrom(r) + var in struct { + Name *string `json:"name"` + Timezone *string `json:"timezone"` + SlotMinutes *int `json:"slot_minutes"` + DayStartMin *int `json:"day_start_min"` + DayEndMin *int `json:"day_end_min"` + HorizonDays *int `json:"horizon_days"` + Durations []int `json:"durations"` + Weekdays []int `json:"weekdays"` + } + if err := decodeBody(r, &in); err != nil { + jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") + return + } + if in.Name != nil { + name := strings.TrimSpace(*in.Name) + if name == "" || len(name) > 80 { + jsonError(w, http.StatusBadRequest, "Name muss 1–80 Zeichen haben") + return + } + u.Name = name + } + if in.Timezone != nil { + if _, err := time.LoadLocation(*in.Timezone); err != nil { + jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone") + return + } + u.Timezone = *in.Timezone + } + if in.SlotMinutes != nil { + if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 { + jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5–180 Minuten sein") + return + } + u.SlotMinutes = *in.SlotMinutes + } + if in.DayStartMin != nil { + if *in.DayStartMin < 0 || *in.DayStartMin > 1439 { + jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig") + return + } + u.DayStartMin = *in.DayStartMin + } + if in.DayEndMin != nil { + if *in.DayEndMin < 1 || *in.DayEndMin > 1440 { + jsonError(w, http.StatusBadRequest, "Tagesende ungültig") + return + } + u.DayEndMin = *in.DayEndMin + } + if u.DayStartMin >= u.DayEndMin { + jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen") + return + } + if in.HorizonDays != nil { + if *in.HorizonDays < 1 || *in.HorizonDays > 120 { + jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1–120 Tage sein") + return + } + u.HorizonDays = *in.HorizonDays + } + if in.Durations != nil { + durations, err := cleanDurations(in.Durations) + if err != nil { + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + u.Durations = intsCSV(durations) + } + if in.Weekdays != nil { + weekdays, err := cleanWeekdays(in.Weekdays) + if err != nil { + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + u.Weekdays = intsCSV(weekdays) + } + if err := s.DB.UpdateUserSettings(u); err != nil { + jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)}) +} + +func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) { + u := userFrom(r) + slug, err := s.uniqueSlug() + if err != nil { + jsonError(w, http.StatusInternalServerError, "Interner Fehler") + return + } + if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil { + jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") + return + } + u.Slug = slug + writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)}) +} + +func (s *Server) uniqueSlug() (string, error) { + for i := 0; i < 8; i++ { + b := make([]byte, 9) + if _, err := rand.Read(b); err != nil { + return "", err + } + slug := base64.RawURLEncoding.EncodeToString(b) + exists, err := s.DB.SlugExists(slug) + if err != nil { + return "", err + } + if !exists { + return slug, nil + } + } + return "", http.ErrBodyNotAllowed +} + +func validEmail(s string) bool { + if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 { + return false + } + at := strings.IndexByte(s, '@') + local, domain := s[:at], s[at+1:] + if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + return false + } + return !strings.ContainsAny(s, " \t") +} + +func cleanDurations(list []int) ([]int, error) { + if len(list) == 0 || len(list) > 8 { + return nil, errNew("1–8 Dauerangaben benötigt") + } + seen := map[int]bool{} + var out []int + for _, v := range list { + if v < 5 || v > 480 { + return nil, errNew("Dauern müssen 5–480 Minuten sein") + } + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil +} + +func cleanWeekdays(list []int) ([]int, error) { + if len(list) == 0 || len(list) > 7 { + return nil, errNew("Mindestens ein Buchungstag nötig") + } + seen := map[int]bool{} + var out []int + for _, v := range list { + if v < 1 || v > 7 { + return nil, errNew("Wochentage müssen 1 (Mo) – 7 (So) sein") + } + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil +} + +func errNew(msg string) error { return &simpleError{msg} } + +type simpleError struct{ msg string } + +func (e *simpleError) Error() string { return e.msg } diff --git a/backend/internal/httpapi/bookings.go b/backend/internal/httpapi/bookings.go new file mode 100644 index 0000000..7536515 --- /dev/null +++ b/backend/internal/httpapi/bookings.go @@ -0,0 +1,93 @@ +package httpapi + +import ( + "net/http" +) + +func (s *Server) ListBookings(w http.ResponseWriter, r *http.Request) { + bookings, err := s.DB.BookingsForUser(userFrom(r).ID) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Anfragen nicht lesbar") + return + } + out := make([]BookingJSON, 0, len(bookings)) + for _, b := range bookings { + out = append(out, bookingToJSON(b)) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) AcceptBooking(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + id, err := pathID(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "Ungültige ID") + return + } + booking, err := s.DB.BookingByID(id, user.ID) + if err != nil { + jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden") + return + } + if booking.Status != "pending" { + jsonError(w, http.StatusConflict, "Diese Anfrage wurde bereits bearbeitet") + return + } + // Kollision mit bestehenden busy-Zeiträumen prüfen (angommene Buchungen + // und Kalendertermine liegen beide in busy_slots). + busy, err := s.DB.BusyForUser(user.ID, booking.StartTs-1, booking.EndTs+1) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar") + return + } + for _, b := range busy { + if b.BookingID.Valid && b.BookingID.Int64 == booking.ID { + continue + } + if b.StartTs < booking.EndTs && booking.StartTs < b.EndTs { + jsonError(w, http.StatusConflict, "Der Zeitraum kollidiert inzwischen mit einem anderen Termin") + return + } + } + if err := s.DB.InsertBusyForBooking(booking); err != nil { + jsonError(w, http.StatusInternalServerError, "Termin konnte nicht eingetragen werden") + return + } + if err := s.DB.SetBookingStatus(booking.ID, "accepted"); err != nil { + jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden") + return + } + booking.Status = "accepted" + writeJSON(w, http.StatusOK, bookingToJSON(booking)) +} + +func (s *Server) DeclineBooking(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + id, err := pathID(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "Ungültige ID") + return + } + booking, err := s.DB.BookingByID(id, user.ID) + if err != nil { + jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden") + return + } + if booking.Status == "pending" { + if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil { + jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden") + return + } + } else if booking.Status == "accepted" { + if err := s.DB.DeleteBusyByBooking(booking.ID); err != nil { + jsonError(w, http.StatusInternalServerError, "Termin konnte nicht entfernt werden") + return + } + if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil { + jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden") + return + } + } + booking.Status = "declined" + writeJSON(w, http.StatusOK, bookingToJSON(booking)) +} diff --git a/backend/internal/httpapi/calendars.go b/backend/internal/httpapi/calendars.go new file mode 100644 index 0000000..41d4c4c --- /dev/null +++ b/backend/internal/httpapi/calendars.go @@ -0,0 +1,247 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "wannpassts/internal/calendar" + "wannpassts/internal/crypto" +) + +func (s *Server) ListCalendars(w http.ResponseWriter, r *http.Request) { + conns, err := s.DB.ConnectionsForUser(userFrom(r).ID) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Verbindungen nicht lesbar") + return + } + out := make([]ConnectionJSON, 0, len(conns)) + for _, c := range conns { + out = append(out, connToJSON(c)) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) DeleteCalendar(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "Ungültige ID") + return + } + if err := s.DB.DeleteConnection(id, userFrom(r).ID); err != nil { + jsonError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, nil) +} + +func (s *Server) ConnectCalDAV(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + var in struct { + ServerURL string `json:"server_url"` + Username string `json:"username"` + Password string `json:"password"` + CalendarPath string `json:"calendar_path"` + DisplayName string `json:"display_name"` + } + if err := decodeBody(r, &in); err != nil { + jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") + return + } + in.ServerURL = strings.TrimSuffix(strings.TrimSpace(in.ServerURL), "/") + if !strings.HasPrefix(in.ServerURL, "http://") && !strings.HasPrefix(in.ServerURL, "https://") { + jsonError(w, http.StatusBadRequest, "Server-URL muss mit http:// oder https:// beginnen") + return + } + if in.Username == "" || in.Password == "" { + jsonError(w, http.StatusBadRequest, "Benutzername und Passwort werden benötigt (iCloud: App-spezifisches Passwort)") + return + } + + cfg := calendar.CalDAVConnConfig{ + ServerURL: in.ServerURL, + Username: in.Username, + Password: in.Password, + CalendarPath: strings.TrimSpace(in.CalendarPath), + } + if cfg.CalendarPath == "" { + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + path, name, err := calendar.DiscoverCalDAV(ctx, cfg) + if err != nil { + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + cfg.CalendarPath = path + if in.DisplayName == "" { + in.DisplayName = name + } + } + if in.DisplayName == "" { + if u, err := url.Parse(in.ServerURL); err == nil { + in.DisplayName = u.Host + } else { + in.DisplayName = "CalDAV-Kalender" + } + } + + enc, err := s.encryptConfig(cfg) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") + return + } + id, err := s.DB.CreateConnection(user.ID, "caldav", in.DisplayName, enc) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden") + return + } + s.Syncer.SyncInBackground(user) + writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "caldav", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()}) +} + +func (s *Server) ConnectICS(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + var in struct { + URL string `json:"url"` + DisplayName string `json:"display_name"` + } + if err := decodeBody(r, &in); err != nil { + jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") + return + } + rawURL := strings.TrimSpace(in.URL) + if strings.HasPrefix(rawURL, "webcal://") { + rawURL = "https://" + strings.TrimPrefix(rawURL, "webcal://") + } + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + jsonError(w, http.StatusBadRequest, "Der Link muss mit http://, https:// oder webcal:// beginnen") + return + } + + // Einmal direkt abrufen, um den Link zu validieren. + prov := calendar.NewICSProvider(calendar.ICSConnConfig{URL: rawURL}, user.Timezone) + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + if _, err := prov.FetchBusy(ctx, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)); err != nil { + jsonError(w, http.StatusBadRequest, "ICS-Link prüfen: "+err.Error()) + return + } + + if in.DisplayName == "" { + if u, err := url.Parse(rawURL); err == nil { + in.DisplayName = u.Host + } else { + in.DisplayName = "ICS-Kalender" + } + } + enc, err := s.encryptConfig(calendar.ICSConnConfig{URL: rawURL}) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") + return + } + id, err := s.DB.CreateConnection(user.ID, "ics", in.DisplayName, enc) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden") + return + } + s.Syncer.SyncInBackground(user) + writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "ics", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()}) +} + +func (s *Server) GoogleConnectStart(w http.ResponseWriter, r *http.Request) { + if s.Cfg.GoogleClientID == "" || s.Cfg.GoogleClientSecret == "" { + jsonError(w, http.StatusServiceUnavailable, "Google-Anmeldung ist nicht konfiguriert (GOOGLE_CLIENT_ID und GOOGLE_CLIENT_SECRET in .env setzen)") + return + } + state, err := s.signJWT(userFrom(r).ID, 10*time.Minute) + if err != nil { + jsonError(w, http.StatusInternalServerError, "State konnte nicht erzeugt werden") + return + } + conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret, + s.Cfg.AppURL+"/api/calendars/google/callback") + authURL := conf.AuthCodeURL(state, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("prompt", "consent select_account"), + ) + writeJSON(w, http.StatusOK, map[string]string{"url": authURL}) +} + +func (s *Server) GoogleConnectCallback(w http.ResponseWriter, r *http.Request) { + fail := func(reason string) { + http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=error&reason="+url.QueryEscape(reason), http.StatusFound) + } + q := r.URL.Query() + if reason := q.Get("error"); reason != "" { + fail("Google-Anmeldung abgelehnt: " + reason) + return + } + uid, err := s.verifyJWT(q.Get("state")) + if err != nil { + fail("Ungültiger or abgelaufener Verbindungsversuch – bitte erneut versuchen") + return + } + user, err := s.DB.UserByID(uid) + if err != nil { + fail("Konto nicht gefunden") + return + } + conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret, + s.Cfg.AppURL+"/api/calendars/google/callback") + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + tok, err := conf.Exchange(ctx, q.Get("code")) + if err != nil { + fail("Google-Token-Austausch fehlgeschlagen: "+cleanReason(err.Error())) + return + } + calID, summary, err := calendar.GooglePrimaryCalendar(ctx, conf.Client(ctx, tok)) + if err != nil { + fail("Google-Kalender nicht lesbar: "+cleanReason(err.Error())) + return + } + name := summary + if name == "" { + name = "Google Kalender" + } + enc, err := s.encryptConfig(calendar.GoogleConnConfig{Token: tok, CalendarID: calID}) + if err != nil { + fail("Speichern fehlgeschlagen") + return + } + if _, err := s.DB.CreateConnection(user.ID, "google", name, enc); err != nil { + fail("Verbindung konnte nicht gespeichert werden") + return + } + s.Syncer.SyncInBackground(user) + http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=ok", http.StatusFound) +} + +func (s *Server) SyncNow(w http.ResponseWriter, r *http.Request) { + user := userFrom(r) + ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second) + defer cancel() + s.Syncer.SyncUser(ctx, user) + s.ListCalendars(w, r) +} + +func (s *Server) encryptConfig(cfg any) (string, error) { + data, err := json.Marshal(cfg) + if err != nil { + return "", err + } + return crypto.Encrypt(s.Cfg.EncryptionKey, data) +} + +func cleanReason(msg string) string { + msg = strings.TrimSpace(msg) + if len(msg) > 200 { + msg = msg[:200] + "…" + } + return msg +} diff --git a/backend/internal/httpapi/public.go b/backend/internal/httpapi/public.go new file mode 100644 index 0000000..3f4daa7 --- /dev/null +++ b/backend/internal/httpapi/public.go @@ -0,0 +1,201 @@ +package httpapi + +import ( + "context" + "net/http" + "regexp" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "wannpassts/internal/calendar" + "wannpassts/internal/db" +) + +var emailRX = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +type publicInterval struct { + Start string `json:"start"` + End string `json:"end"` +} + +type publicInfo struct { + Name string `json:"name"` + Timezone string `json:"timezone"` + SlotMinutes int `json:"slot_minutes"` + DayStartMin int `json:"day_start_min"` + DayEndMin int `json:"day_end_min"` + HorizonDays int `json:"horizon_days"` + Durations []int `json:"durations"` + Weekdays []int `json:"weekdays"` + Busy []publicInterval `json:"busy"` +} + +// PublicInfo liefert die Buchungsseite: Name, Buchungsregeln und ausschließlich +// busy-Zeiträume – niemals Termintitel, Beschreibungen oder Teilnehmer. +func (s *Server) PublicInfo(w http.ResponseWriter, r *http.Request) { + user, err := s.DB.UserBySlug(chi.URLParam(r, "slug")) + if err != nil { + jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden") + return + } + now := time.Now() + + if conns, err := s.DB.ConnectionsForUser(user.ID); err == nil && calendar.NeedsSync(conns, now) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + s.Syncer.SyncUser(ctx, user) + cancel() + } + + horizonEnd := now.Add(time.Duration(user.HorizonDays+1) * 24 * time.Hour) + busy, err := s.DB.BusyForUser(user.ID, now.Add(-time.Hour).Unix(), horizonEnd.Unix()) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Belegung nicht lesbar") + return + } + + resp := publicInfo{ + Name: user.Name, Timezone: user.Timezone, + SlotMinutes: user.SlotMinutes, DayStartMin: user.DayStartMin, DayEndMin: user.DayEndMin, + HorizonDays: user.HorizonDays, + Durations: csvInts(user.Durations), Weekdays: csvInts(user.Weekdays), + Busy: make([]publicInterval, 0, len(busy)), + } + for _, iv := range mergeBusy(busy) { + resp.Busy = append(resp.Busy, publicInterval{ + Start: time.Unix(iv.StartTs, 0).UTC().Format(time.RFC3339), + End: time.Unix(iv.EndTs, 0).UTC().Format(time.RFC3339), + }) + } + writeJSON(w, http.StatusOK, resp) +} + +func mergeBusy(in []db.BusySlot) []db.BusySlot { + var out []db.BusySlot + for _, s := range in { + if n := len(out); n > 0 && s.StartTs <= out[n-1].EndTs { + if s.EndTs > out[n-1].EndTs { + out[n-1].EndTs = s.EndTs + } + continue + } + out = append(out, s) + } + return out +} + +// CreateBooking nimmt eine Buchungsanfrage an: Validiert gegen Buchungsregeln +// und Belegung, legt die Anfrage mit Status "pending" an. +func (s *Server) CreateBooking(w http.ResponseWriter, r *http.Request) { + user, err := s.DB.UserBySlug(chi.URLParam(r, "slug")) + if err != nil { + jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden") + return + } + var in struct { + Start string `json:"start"` + DurationMinutes int `json:"duration_minutes"` + Name string `json:"name"` + Email string `json:"email"` + Message string `json:"message"` + } + if err := decodeBody(r, &in); err != nil { + jsonError(w, http.StatusBadRequest, "Anfrage unlesbar") + return + } + in.Name = strings.TrimSpace(in.Name) + in.Email = strings.TrimSpace(in.Email) + in.Message = strings.TrimSpace(in.Message) + if in.Name == "" || len(in.Name) > 100 { + jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 100 Zeichen) angeben") + return + } + if !emailRX.MatchString(in.Email) || len(in.Email) > 254 { + jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben") + return + } + if len(in.Message) > 2000 { + jsonError(w, http.StatusBadRequest, "Nachricht zu lang (max. 2000 Zeichen)") + return + } + + allowed := false + for _, d := range csvInts(user.Durations) { + if d == in.DurationMinutes { + allowed = true + break + } + } + if !allowed { + jsonError(w, http.StatusBadRequest, "Ungültige Dauer") + return + } + + start, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Start)) + if err != nil { + jsonError(w, http.StatusBadRequest, "Ungültiger Startzeitpunkt") + return + } + end := start.Add(time.Duration(in.DurationMinutes) * time.Minute) + now := time.Now() + + if start.Unix() < now.Unix()-120 { + jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt in der Vergangenheit") + return + } + if start.After(now.Add(time.Duration(user.HorizonDays) * 24 * time.Hour)) { + jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt außerhalb des Buchungszeitraums") + return + } + + loc, _ := time.LoadLocation(user.Timezone) + if loc == nil { + loc = time.UTC + } + local := start.In(loc) + isoWeekday := (int(local.Weekday())+6)%7 + 1 // Mo=1 … So=7 + if !containsInt(csvInts(user.Weekdays), isoWeekday) { + jsonError(w, http.StatusBadRequest, "An diesem Tag werden keine Buchungen angenommen") + return + } + minuteOfDay := local.Hour()*60 + local.Minute() + if minuteOfDay < user.DayStartMin-2 || minuteOfDay+in.DurationMinutes > user.DayEndMin+2 { + jsonError(w, http.StatusBadRequest, "Außerhalb der Buchungszeiten") + return + } + + busy, err := s.DB.BusyForUser(user.ID, start.Unix()-1, end.Unix()+1) + if err != nil { + jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar") + return + } + for _, b := range busy { + if b.StartTs < end.Unix() && start.Unix() < b.EndTs { + jsonError(w, http.StatusConflict, "Dieser Zeitraum ist leider bereits belegt") + return + } + } + + booking := &db.Booking{ + UserID: user.ID, StartTs: start.Unix(), EndTs: end.Unix(), + RequesterName: in.Name, RequesterEmail: in.Email, Message: in.Message, + } + if err := s.DB.CreateBooking(booking); err != nil { + jsonError(w, http.StatusInternalServerError, "Anfrage konnte nicht gespeichert werden") + return + } + booking.Status = "pending" + writeJSON(w, http.StatusCreated, map[string]any{ + "id": booking.ID, "status": booking.Status, + }) +} + +func containsInt(list []int, v int) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go new file mode 100644 index 0000000..b884008 --- /dev/null +++ b/backend/internal/httpapi/server.go @@ -0,0 +1,280 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/golang-jwt/jwt/v5" + + "wannpassts/internal/calendar" + "wannpassts/internal/config" + "wannpassts/internal/db" +) + +type Server struct { + DB *db.DB + Cfg *config.Config + Syncer *calendar.Syncer +} + +func New(database *db.DB, cfg *config.Config, syncer *calendar.Syncer) *Server { + return &Server{DB: database, Cfg: cfg, Syncer: syncer} +} + +type ctxKey int + +const ctxUser ctxKey = 1 + +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins(s.Cfg.FrontendURL), + AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + MaxAge: 300, + })) + r.Get("/api/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + r.Route("/api", func(api chi.Router) { + api.Post("/auth/register", s.Register) + api.Post("/auth/login", s.Login) + + api.Get("/calendars/google/callback", s.GoogleConnectCallback) + api.Get("/public/{slug}", s.PublicInfo) + api.Post("/public/{slug}/bookings", s.CreateBooking) + + api.Group(func(prt chi.Router) { + prt.Use(s.AuthMiddleware) + prt.Get("/me", s.Me) + prt.Patch("/me", s.UpdateMe) + prt.Post("/me/slug", s.RegenSlug) + prt.Get("/calendars", s.ListCalendars) + prt.Delete("/calendars/{id}", s.DeleteCalendar) + prt.Post("/calendars/caldav", s.ConnectCalDAV) + prt.Post("/calendars/ics", s.ConnectICS) + prt.Get("/calendars/google/start", s.GoogleConnectStart) + prt.Post("/calendars/sync", s.SyncNow) + prt.Get("/bookings", s.ListBookings) + prt.Post("/bookings/{id}/accept", s.AcceptBooking) + prt.Post("/bookings/{id}/decline", s.DeclineBooking) + }) + }) + + if s.Cfg.StaticDir != "" { + if st, err := os.Stat(s.Cfg.StaticDir); err == nil && st.IsDir() { + static := s.Cfg.StaticDir + r.Get("/*", func(w http.ResponseWriter, req *http.Request) { + p := filepath.Join(static, filepath.Clean(req.URL.Path)) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + http.ServeFile(w, req, p) + return + } + http.ServeFile(w, req, filepath.Join(static, "index.html")) + }) + } + } + return r +} + +func allowedOrigins(frontendURL string) []string { + origins := []string{"http://localhost:5173", "http://127.0.0.1:5173"} + for _, o := range strings.Split(frontendURL, ",") { + o = strings.TrimSpace(o) + if o != "" && !contains(origins, o) { + origins = append(origins, o) + } + } + return origins +} + +func contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// --- Auth Middleware & JWT --- + +func (s *Server) signJWT(userID int64, ttl time.Duration) (string, error) { + claims := jwt.RegisteredClaims{ + Subject: strconv.FormatInt(userID, 10), + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return tok.SignedString([]byte(s.Cfg.JWTSecret)) +} + +func (s *Server) verifyJWT(tokenStr string) (int64, error) { + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unerwartete Signaturmethode") + } + return []byte(s.Cfg.JWTSecret), nil + }) + if err != nil || !token.Valid { + return 0, errors.New("Token ungültig oder abgelaufen") + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return 0, errors.New("Claims unlesbar") + } + sub, _ := claims["sub"].(string) + id, err := strconv.ParseInt(sub, 10, 64) + if err != nil { + return 0, errors.New("Subject unlesbar") + } + return id, nil +} + +func (s *Server) AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + jsonError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + uid, err := s.verifyJWT(strings.TrimSpace(h[len("Bearer "):])) + if err != nil { + jsonError(w, http.StatusUnauthorized, "Sitzung ungültig oder abgelaufen") + return + } + user, err := s.DB.UserByID(uid) + if err != nil { + jsonError(w, http.StatusUnauthorized, "Konto nicht gefunden") + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user))) + }) +} + +func userFrom(r *http.Request) *db.User { + return r.Context().Value(ctxUser).(*db.User) +} + +// --- JSON helpers --- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } +} + +func jsonError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func decodeBody(r *http.Request, v any) error { + defer r.Body.Close() + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) + return dec.Decode(v) +} + +func pathID(r *http.Request) (int64, error) { + return strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) +} + +// --- JSON Darstellungen --- + +type UserJSON struct { + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Slug string `json:"slug"` + Timezone string `json:"timezone"` + SlotMinutes int `json:"slot_minutes"` + DayStartMin int `json:"day_start_min"` + DayEndMin int `json:"day_end_min"` + HorizonDays int `json:"horizon_days"` + Durations []int `json:"durations"` + Weekdays []int `json:"weekdays"` + CreatedTs int64 `json:"created_ts"` +} + +func userToJSON(u *db.User) UserJSON { + return UserJSON{ + ID: u.ID, Email: u.Email, Name: u.Name, Slug: u.Slug, Timezone: u.Timezone, + SlotMinutes: u.SlotMinutes, DayStartMin: u.DayStartMin, DayEndMin: u.DayEndMin, + HorizonDays: u.HorizonDays, Durations: csvInts(u.Durations), Weekdays: csvInts(u.Weekdays), + CreatedTs: u.CreatedTs, + } +} + +func csvInts(csv string) []int { + var out []int + for _, p := range strings.Split(csv, ",") { + if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + out = append(out, v) + } + } + return out +} + +func intsCSV(list []int) string { + parts := make([]string, len(list)) + for i, v := range list { + parts[i] = strconv.Itoa(v) + } + return strings.Join(parts, ",") +} + +type ConnectionJSON struct { + ID int64 `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + LastSyncedTs *int64 `json:"last_synced_ts"` + LastError *string `json:"last_error"` + CreatedTs int64 `json:"created_ts"` +} + +func connToJSON(c *db.Connection) ConnectionJSON { + out := ConnectionJSON{ID: c.ID, Provider: c.Provider, DisplayName: c.DisplayName, CreatedTs: c.CreatedTs} + if c.LastSyncedTs.Valid { + v := c.LastSyncedTs.Int64 + out.LastSyncedTs = &v + } + if c.LastError.Valid && c.LastError.String != "" { + v := c.LastError.String + out.LastError = &v + } + return out +} + +type BookingJSON struct { + ID int64 `json:"id"` + Start string `json:"start"` + End string `json:"end"` + RequesterName string `json:"requester_name"` + RequesterEmail string `json:"requester_email"` + Message string `json:"message"` + Status string `json:"status"` + CreatedTs int64 `json:"created_ts"` +} + +func bookingToJSON(b *db.Booking) BookingJSON { + return BookingJSON{ + ID: b.ID, + Start: time.Unix(b.StartTs, 0).UTC().Format(time.RFC3339), + End: time.Unix(b.EndTs, 0).UTC().Format(time.RFC3339), + RequesterName: b.RequesterName, RequesterEmail: b.RequesterEmail, + Message: b.Message, Status: b.Status, CreatedTs: b.CreatedTs, + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6727ee7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,24 @@ + + + + + + + + + WannPassts + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5b7d82a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1659 @@ +{ + "name": "wannpassts-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wannpassts-frontend", + "version": "1.0.0", + "dependencies": { + "pinia": "^3.0.2", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "~5.8.3", + "vite": "^6.3.5", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/copy-anything": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.0.tgz", + "integrity": "sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..44fd48b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "wannpassts-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "pinia": "^3.0.2", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "~5.8.3", + "vite": "^6.3.5", + "vue-tsc": "^2.2.10" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..4120d30 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..e9490c7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/GlassCard.vue b/frontend/src/components/GlassCard.vue new file mode 100644 index 0000000..57ec1ee --- /dev/null +++ b/frontend/src/components/GlassCard.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/components/LogoMark.vue b/frontend/src/components/LogoMark.vue new file mode 100644 index 0000000..77cee93 --- /dev/null +++ b/frontend/src/components/LogoMark.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/SectionConnections.vue b/frontend/src/components/SectionConnections.vue new file mode 100644 index 0000000..9717f36 --- /dev/null +++ b/frontend/src/components/SectionConnections.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/frontend/src/components/SectionRequests.vue b/frontend/src/components/SectionRequests.vue new file mode 100644 index 0000000..dce4966 --- /dev/null +++ b/frontend/src/components/SectionRequests.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/components/SectionSettings.vue b/frontend/src/components/SectionSettings.vue new file mode 100644 index 0000000..100df00 --- /dev/null +++ b/frontend/src/components/SectionSettings.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/components/ShareLinkCard.vue b/frontend/src/components/ShareLinkCard.vue new file mode 100644 index 0000000..3a6639a --- /dev/null +++ b/frontend/src/components/ShareLinkCard.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..a456aa5 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,109 @@ +import type { Booking, Connection, PublicInfo, User } from './types' + +const TOKEN_KEY = 'wannpassts_token' + +export class ApiError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.status = status + } +} + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token) +} +export function clearToken() { + localStorage.removeItem(TOKEN_KEY) +} + +async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = {} + if (body !== undefined) headers['Content-Type'] = 'application/json' + const token = getToken() + if (token) headers['Authorization'] = `Bearer ${token}` + + let res: Response + try { + res = await fetch('/api' + path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + } catch { + throw new ApiError('Server nicht erreichbar – läuft das Backend?', 0) + } + + if (res.status === 204) return undefined as T + let data: any = null + try { + data = await res.json() + } catch { + /* leere Antwort */ + } + if (!res.ok) { + throw new ApiError(data?.error ?? `Fehler ${res.status}`, res.status) + } + return data as T +} + +export const api = { + // Auth + register(payload: { email: string; password: string; name: string; timezone?: string }) { + return request<{ token: string; user: User }>('POST', '/auth/register', payload) + }, + login(payload: { email: string; password: string }) { + return request<{ token: string; user: User }>('POST', '/auth/login', payload) + }, + me() { + return request<{ user: User }>('GET', '/me') + }, + updateMe(payload: Partial> & { durations?: number[]; weekdays?: number[] }) { + return request<{ user: User }>('PATCH', '/me', payload) + }, + regenSlug() { + return request<{ user: User }>('POST', '/me/slug') + }, + + // Kalender + connections() { + return request('GET', '/calendars') + }, + deleteConnection(id: number) { + return request('DELETE', `/calendars/${id}`) + }, + connectCalDAV(payload: { server_url: string; username: string; password: string; calendar_path?: string; display_name?: string }) { + return request('POST', '/calendars/caldav', payload) + }, + connectICS(payload: { url: string; display_name?: string }) { + return request('POST', '/calendars/ics', payload) + }, + googleStart() { + return request<{ url: string }>('GET', '/calendars/google/start') + }, + syncNow() { + return request('POST', '/calendars/sync') + }, + + // Anfragen + bookings() { + return request('GET', '/bookings') + }, + acceptBooking(id: number) { + return request('POST', `/bookings/${id}/accept`) + }, + declineBooking(id: number) { + return request('POST', `/bookings/${id}/decline`) + }, + + // Öffentliche Buchungsseite + publicInfo(slug: string) { + return request('GET', `/public/${slug}`) + }, + createBooking(slug: string, payload: { start: string; duration_minutes: number; name: string; email: string; message?: string }) { + return request<{ id: number; status: string }>('POST', `/public/${slug}/bookings`, payload) + }, +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..d92d754 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,46 @@ +export interface User { + id: number + email: string + name: string + slug: string + timezone: string + slot_minutes: number + day_start_min: number + day_end_min: number + horizon_days: number + durations: number[] + weekdays: number[] + created_ts: number +} + +export interface Connection { + id: number + provider: 'google' | 'caldav' | 'ics' + display_name: string + last_synced_ts: number | null + last_error: string | null + created_ts: number +} + +export interface Booking { + id: number + start: string + end: string + requester_name: string + requester_email: string + message: string + status: 'pending' | 'accepted' | 'declined' + created_ts: number +} + +export interface PublicInfo { + name: string + timezone: string + slot_minutes: number + day_start_min: number + day_end_min: number + horizon_days: number + durations: number[] + weekdays: number[] + busy: { start: string; end: string }[] +} diff --git a/frontend/src/lib/tz.ts b/frontend/src/lib/tz.ts new file mode 100644 index 0000000..da8dfdb --- /dev/null +++ b/frontend/src/lib/tz.ts @@ -0,0 +1,90 @@ +// Zeitzonen-Helfer: Wanduhrzeiten in einer Zielzone ↔ UTC-Epoch-Millisekunden. + +const partsCache = new Map() + +function tzPartsFmt(tz: string): Intl.DateTimeFormat { + let f = partsCache.get(tz) + if (!f) { + f = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + weekday: 'short', + }) + partsCache.set(tz, f) + } + return f +} + +export interface ZonedParts { + year: number + month: number // 1–12 + day: number + hour: number + minute: number + weekday: number // ISO: Mo=1 … So=7 + minuteOfDay: number +} + +const weekdayMap: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 } + +export function partsInTz(date: Date, tz: string): ZonedParts { + const parts = tzPartsFmt(tz).formatToParts(date) + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0' + const hour = +get('hour') % 24 + const minute = +get('minute') + return { + year: +get('year'), + month: +get('month'), + day: +get('day'), + hour, + minute, + weekday: weekdayMap[get('weekday')] ?? 1, + minuteOfDay: hour * 60 + minute, + } +} + +/** Offset der Zone zum Zeitpunkt (ms), z. B. +2h für MESZ. */ +export function tzOffsetMs(date: Date, tz: string): number { + const p = partsInTz(date, tz) + const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, date.getUTCSeconds()) + return asUtc - date.getTime() +} + +/** Wanduhrzeit (y, mo 1–12, d, h, min) in Zone tz → UTC-Epoch-Millisekunden. */ +export function zonedToUtc(year: number, month: number, day: number, hour: number, minute: number, tz: string): number { + const naive = Date.UTC(year, month - 1, day, hour, minute, 0) + let ts = naive + for (let i = 0; i < 2; i++) { + ts = naive - tzOffsetMs(new Date(ts), tz) + } + return ts +} + +export function formatTime(tsMs: number, tz: string): string { + return new Intl.DateTimeFormat('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(new Date(tsMs)) +} + +export function formatDayShort(tsMs: number, tz: string): string { + return new Intl.DateTimeFormat('de-DE', { timeZone: tz, weekday: 'short', day: '2-digit', month: 'short' }).format(new Date(tsMs)) +} + +export function formatRange(startISO: string, endISO: string, tz: string): string { + const day = formatDayShort(new Date(startISO).getTime(), tz) + const t1 = formatTime(new Date(startISO).getTime(), tz) + const t2 = formatTime(new Date(endISO).getTime(), tz) + return `${day} · ${t1}–${t2} Uhr` +} + +export function guessTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin' + } catch { + return 'Europe/Berlin' + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..1c85907 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './styles/main.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..300618b --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,30 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { getToken } from '../lib/api' +import LandingView from '../views/LandingView.vue' +import LoginView from '../views/LoginView.vue' +import RegisterView from '../views/RegisterView.vue' +import DashboardView from '../views/DashboardView.vue' +import BookingView from '../views/BookingView.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'landing', component: LandingView }, + { path: '/login', name: 'login', component: LoginView }, + { path: '/register', name: 'register', component: RegisterView }, + { path: '/app', name: 'dashboard', component: DashboardView, meta: { requiresAuth: true } }, + { path: '/b/:slug', name: 'booking', component: BookingView }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) + +router.beforeEach((to) => { + if (to.meta.requiresAuth && !getToken()) { + return { name: 'login', query: { redirect: to.fullPath } } + } + if ((to.name === 'login' || to.name === 'register' || to.name === 'landing') && getToken()) { + return { name: 'dashboard' } + } +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..63f54cb --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,47 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { api, clearToken, getToken, setToken } from '../lib/api' +import type { User } from '../lib/types' + +export const useAuth = defineStore('auth', () => { + const user = ref(null) + const ready = ref(false) + + async function init() { + if (!getToken()) { + ready.value = true + return + } + try { + const res = await api.me() + user.value = res.user + } catch { + clearToken() + } finally { + ready.value = true + } + } + + async function login(email: string, password: string) { + const res = await api.login({ email, password }) + setToken(res.token) + user.value = res.user + } + + async function register(email: string, password: string, name: string, timezone: string) { + const res = await api.register({ email, password, name, timezone }) + setToken(res.token) + user.value = res.user + } + + function setUser(u: User) { + user.value = u + } + + function logout() { + clearToken() + user.value = null + } + + return { user, ready, init, login, register, setUser, logout } +}) diff --git a/frontend/src/stores/toast.ts b/frontend/src/stores/toast.ts new file mode 100644 index 0000000..827bb74 --- /dev/null +++ b/frontend/src/stores/toast.ts @@ -0,0 +1,27 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export interface Toast { + id: number + message: string + kind: 'ok' | 'error' | 'info' +} + +let nextId = 1 + +export const useToast = defineStore('toast', () => { + const toasts = ref([]) + + function push(message: string, kind: Toast['kind'] = 'info') { + const id = nextId++ + toasts.value.push({ id, message, kind }) + setTimeout(() => { + toasts.value = toasts.value.filter((t) => t.id !== id) + }, 4500) + } + + const ok = (m: string) => push(m, 'ok') + const error = (m: string) => push(m, 'error') + + return { toasts, push, ok, error } +}) diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..abff517 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,324 @@ +/* ─── WannPassts · Liquid Glass Dark ──────────────────────────────────────── */ + +:root { + --bg-0: #05070c; + --bg-1: #0a0e18; + --text: #eef1f8; + --text-muted: rgba(238, 241, 248, 0.58); + --text-faint: rgba(238, 241, 248, 0.34); + --border: rgba(255, 255, 255, 0.13); + --border-soft: rgba(255, 255, 255, 0.08); + --accent: #7db0ff; + --accent-2: #b78cff; + --accent-3: #6fe3c4; + --danger: #ff7a8a; + --ok: #46e0a5; + --radius-lg: 24px; + --radius-md: 16px; + --radius-sm: 12px; + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + font-family: var(--font); + color: var(--text); + background: + radial-gradient(1200px 800px at 85% -10%, rgba(125, 176, 255, 0.10), transparent 60%), + radial-gradient(1000px 700px at -10% 30%, rgba(183, 140, 255, 0.09), transparent 55%), + radial-gradient(900px 600px at 50% 110%, rgba(111, 227, 196, 0.06), transparent 60%), + linear-gradient(180deg, var(--bg-1), var(--bg-0)); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + letter-spacing: 0.01em; +} + +#app { min-height: 100vh; } + +/* Farbliche "Orbs" hinter allem – geben dem Glass etwas zum Brechen */ +.bg-scene { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; } +.orb { + position: absolute; + border-radius: 50%; + filter: blur(110px); + opacity: 0.45; + animation: orb-drift 26s ease-in-out infinite alternate; +} +.orb-1 { width: 46vw; height: 46vw; left: -10vw; top: -14vh; background: radial-gradient(circle, #2b5cc4, transparent 70%); } +.orb-2 { width: 40vw; height: 40vw; right: -8vw; top: 22vh; background: radial-gradient(circle, #6d3fb8, transparent 70%); animation-delay: -8s; } +.orb-3 { width: 34vw; height: 34vw; left: 28vw; bottom: -18vh; background: radial-gradient(circle, #1a7f68, transparent 70%); animation-delay: -16s; } + +@keyframes orb-drift { + from { transform: translate3d(0, 0, 0) scale(1); } + to { transform: translate3d(6vw, 5vh, 0) scale(1.12); } +} + +@media (prefers-reduced-motion: reduce) { + .orb { animation: none; } + * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } +} + +/* ─── Glass-Bausteine ─────────────────────────────────────────────────────── */ + +.glass { + background: linear-gradient(150deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.028)); + backdrop-filter: blur(26px) saturate(170%); + -webkit-backdrop-filter: blur(26px) saturate(170%); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10); +} + +.glass-soft { + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +/* ─── Layout ──────────────────────────────────────────────────────────────── */ + +.page { + max-width: 1060px; + margin: 0 auto; + padding: 24px 20px 80px; +} + +.row { display: flex; gap: 12px; align-items: center; } +.col { display: flex; flex-direction: column; gap: 12px; } +.grow { flex: 1; } +.muted { color: var(--text-muted); } +.faint { color: var(--text-faint); } +.small { font-size: 13px; } + +/* ─── Buttons ─────────────────────────────────────────────────────────────── */ + +.btn { + appearance: none; + border: 1px solid var(--border); + border-radius: 999px; + background: linear-gradient(150deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.04)); + color: var(--text); + font: 600 14px/1 var(--font); + padding: 11px 20px; + cursor: pointer; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; +} +.btn:hover { transform: translateY(-1px); border-color: rgba(255, 255, 255, 0.24); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); } +.btn:active { transform: translateY(0); } +.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + border: none; + color: #071018; + box-shadow: 0 10px 34px rgba(125, 176, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.35); +} +.btn-primary:hover { box-shadow: 0 14px 40px rgba(125, 176, 255, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.35); } + +.btn-danger { + border-color: rgba(255, 122, 138, 0.4); + color: var(--danger); + background: rgba(255, 122, 138, 0.08); +} +.btn-danger:hover { border-color: rgba(255, 122, 138, 0.7); box-shadow: 0 8px 24px rgba(255, 122, 138, 0.18); } + +.btn-sm { padding: 8px 14px; font-size: 13px; } + +/* ─── Formulare ───────────────────────────────────────────────────────────── */ + +label.field { display: flex; flex-direction: column; gap: 7px; } +label.field > span { font-size: 13px; color: var(--text-muted); font-weight: 500; } + +.input, select.input, textarea.input { + width: 100%; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font: 500 14px/1.4 var(--font); + padding: 11px 14px; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} +.input:focus { + border-color: rgba(125, 176, 255, 0.65); + box-shadow: 0 0 0 3px rgba(125, 176, 255, 0.18); + background: rgba(255, 255, 255, 0.07); +} +.input::placeholder { color: var(--text-faint); } +select.input option { background: #10141f; color: var(--text); } +textarea.input { resize: vertical; min-height: 84px; } + +/* ─── Badges & Chips ──────────────────────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + padding: 4px 11px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.05); + color: var(--text-muted); +} +.badge-pending { border-color: rgba(125, 176, 255, 0.45); color: var(--accent); background: rgba(125, 176, 255, 0.10); } +.badge-accepted { border-color: rgba(70, 224, 165, 0.45); color: var(--ok); background: rgba(70, 224, 165, 0.10); } +.badge-declined { border-color: rgba(255, 122, 138, 0.4); color: var(--danger); background: rgba(255, 122, 138, 0.08); } + +/* ─── Tabs ────────────────────────────────────────────────────────────────── */ + +.tabs { + display: flex; + gap: 6px; + padding: 6px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: rgba(255, 255, 255, 0.035); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + overflow-x: auto; + scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + flex: 1; + white-space: nowrap; + appearance: none; + border: none; + background: transparent; + color: var(--text-muted); + font: 600 14px/1 var(--font); + padding: 10px 18px; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} +.tab:hover { color: var(--text); } +.tab.active { + color: var(--text); + background: linear-gradient(150deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 6px 18px rgba(0, 0, 0, 0.3); +} + +/* ─── Toasts ──────────────────────────────────────────────────────────────── */ + +.toasts { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 10px; + z-index: 100; + width: min(480px, calc(100vw - 32px)); +} +.toast { + padding: 13px 18px; + font-size: 14px; + font-weight: 500; + animation: toast-in 0.25s ease; +} +.toast-ok { border-color: rgba(70, 224, 165, 0.5); } +.toast-error { border-color: rgba(255, 122, 138, 0.5); } +@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } + +/* ─── Slot-Chips (Buchungsseite) ──────────────────────────────────────────── */ + +.slot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); + gap: 10px; +} +.slot-chip { + appearance: none; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + font: 600 14px/1 var(--font); + padding: 12px 0; + cursor: pointer; + transition: all 0.13s ease; +} +.slot-chip:hover:not(:disabled) { + color: var(--text); + border-color: rgba(125, 176, 255, 0.5); + background: rgba(125, 176, 255, 0.10); +} +.slot-chip.selected { + color: #071018; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + border-color: transparent; + box-shadow: 0 8px 26px rgba(125, 176, 255, 0.4); +} +.slot-chip:disabled { opacity: 0.28; cursor: not-allowed; text-decoration: line-through; } + +/* ─── Day-Picker ──────────────────────────────────────────────────────────── */ + +.day-strip { + display: flex; + gap: 9px; + overflow-x: auto; + padding-bottom: 6px; + scrollbar-width: thin; +} +.day-chip { + appearance: none; + flex: 0 0 auto; + min-width: 74px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + font-family: var(--font); + padding: 10px 8px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + transition: all 0.13s ease; +} +.day-chip:hover { border-color: var(--border); color: var(--text); } +.day-chip.selected { + color: var(--text); + border-color: rgba(125, 176, 255, 0.55); + background: linear-gradient(150deg, rgba(125, 176, 255, 0.16), rgba(183, 140, 255, 0.10)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 8px 24px rgba(0, 0, 0, 0.3); +} +.day-chip .wd { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; } +.day-chip .dom { font-size: 20px; font-weight: 700; color: inherit; } +.day-chip .mon { font-size: 11px; } + +/* ─── Verschiedenes ───────────────────────────────────────────────────────── */ + +.divider { height: 1px; background: var(--border-soft); border: none; margin: 4px 0; } + +.skeleton { + border-radius: var(--radius-md); + background: linear-gradient(100deg, rgba(255,255,255,0.04) 40%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 60%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} +@keyframes shimmer { to { background-position: -200% 0; } } + +.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(6px); } + +@media (max-width: 640px) { + .page { padding: 16px 14px 64px; } +} diff --git a/frontend/src/views/BookingView.vue b/frontend/src/views/BookingView.vue new file mode 100644 index 0000000..45db4c2 --- /dev/null +++ b/frontend/src/views/BookingView.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..2bef300 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/views/LandingView.vue b/frontend/src/views/LandingView.vue new file mode 100644 index 0000000..4f87c9c --- /dev/null +++ b/frontend/src/views/LandingView.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..3e32572 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue new file mode 100644 index 0000000..eb64016 --- /dev/null +++ b/frontend/src/views/RegisterView.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6494f64 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..60f37c7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { target: 'http://localhost:8080', changeOrigin: true }, + }, + }, +})