Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.
Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
Termine follow AkteService.GetByID. Every Akte-attached mutation appends
an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
to start with malformed key; unset key leaves CalDAV disabled and all
/api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
/api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.
Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
test-connection button, status card, sync log table, delete button that
purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.
Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password
t-paliad-010
117 lines
3.7 KiB
Go
117 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/auth"
|
|
"mgit.msbls.de/m/patholo/internal/db"
|
|
"mgit.msbls.de/m/patholo/internal/handlers"
|
|
"mgit.msbls.de/m/patholo/internal/services"
|
|
)
|
|
|
|
func main() {
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
supabaseURL := os.Getenv("SUPABASE_URL")
|
|
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
|
|
if supabaseURL == "" || supabaseAnonKey == "" {
|
|
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
|
|
}
|
|
|
|
client := auth.NewClient(supabaseURL, supabaseAnonKey)
|
|
|
|
giteaToken := os.Getenv("GITEA_TOKEN")
|
|
if giteaToken == "" {
|
|
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
|
|
}
|
|
|
|
// DATABASE_URL is optional during the Phase A → Phase D transition. The
|
|
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
|
|
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
|
|
dbURL := os.Getenv("DATABASE_URL")
|
|
var svcBundle *handlers.Services
|
|
var caldavSvc *services.CalDAVService
|
|
|
|
if dbURL != "" {
|
|
log.Println("applying database migrations…")
|
|
if err := db.ApplyMigrations(dbURL); err != nil {
|
|
log.Fatalf("migration failed: %v", err)
|
|
}
|
|
log.Println("database migrations applied")
|
|
|
|
pool, err := db.OpenPool(dbURL)
|
|
if err != nil {
|
|
log.Fatalf("open db pool: %v", err)
|
|
}
|
|
holidays := services.NewHolidayService(pool)
|
|
users := services.NewUserService(pool)
|
|
akteSvc := services.NewAkteService(pool, users)
|
|
rules := services.NewDeadlineRuleService(pool)
|
|
|
|
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
|
|
// the service exists but Enabled() reports false; handlers return 501.
|
|
// If the env var is malformed, fail fast — silently skipping would
|
|
// leave plaintext-credential bugs hidden.
|
|
cipher, err := services.LoadCalDAVCipher()
|
|
if err != nil {
|
|
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
|
|
}
|
|
if cipher == nil {
|
|
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
|
|
} else {
|
|
log.Println("CalDAV encryption configured (AES-256-GCM)")
|
|
}
|
|
|
|
terminSvc := services.NewTerminService(pool, akteSvc)
|
|
caldavSvc = services.NewCalDAVService(pool, cipher, terminSvc)
|
|
// Wire the push hook so user-driven mutations sync to the external
|
|
// calendar without waiting for the next 60-second tick.
|
|
terminSvc.SetCalDAVPusher(caldavSvc)
|
|
|
|
svcBundle = &handlers.Services{
|
|
Akte: akteSvc,
|
|
Parteien: services.NewParteienService(pool, akteSvc),
|
|
Frist: services.NewFristService(pool, akteSvc),
|
|
Termin: terminSvc,
|
|
CalDAV: caldavSvc,
|
|
Rules: rules,
|
|
Calculator: services.NewDeadlineCalculator(holidays),
|
|
Users: users,
|
|
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
|
Dashboard: services.NewDashboardService(pool, users),
|
|
}
|
|
log.Println("Phase B services initialised")
|
|
|
|
// Spawn one CalDAV sync goroutine per enabled user. No-op if cipher
|
|
// is nil. Lives for the process lifetime; signal handler cleans up.
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
if err := caldavSvc.Start(ctx); err != nil {
|
|
log.Printf("CalDAV start: %v", err)
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
log.Println("CalDAV: shutdown signal received")
|
|
caldavSvc.Stop()
|
|
}()
|
|
} else {
|
|
log.Println("DATABASE_URL not set — Akten/Frist endpoints will return 503")
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
handlers.Register(mux, client, giteaToken, svcBundle)
|
|
|
|
log.Printf("paliad server starting on :%s", port)
|
|
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|