Files
paliad/internal/services/caldav_service.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

638 lines
19 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// CalDAVService — bidirectional CalDAV sync for paliad.appointments.
//
// Per-user goroutine model:
// - One goroutine per user with enabled = true and a usable cipher
// - Tick every 60s: push local Appointments, then pull remote events
// - Spawned on Start() (server boot) and on each PUT /api/caldav-config
// - Torn down on DELETE /api/caldav-config
//
// Conflict resolution: last-write-wins on the modified timestamp. When the
// remote etag has changed, the remote payload overwrites the local row;
// when local mutated more recently, the next push overwrites the remote.
// Akte-attached conflicts append a row to akten_events (via
// AppointmentService.LogConflict) so the audit trail records the change.
//
// Audit §1.3 fix: passwords are read from paliad.user_caldav_config in
// AES-GCM-encrypted form and decrypted only inside this service. They
// never leave the process boundary; the API never returns them; the model
// has json:"-" on PasswordEncrypted.
type CalDAVService struct {
db *sqlx.DB
cipher *CalDAVCipher
appointments *AppointmentService
mu sync.Mutex
cancels map[uuid.UUID]context.CancelFunc // userID -> goroutine cancel
rootCtx context.Context
rootStop context.CancelFunc
wg sync.WaitGroup
}
// NewCalDAVService wires the service. cipher may be nil — in that case all
// operations return ErrCalDAVNoKey and the goroutines are never spawned.
func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, appointments *AppointmentService) *CalDAVService {
return &CalDAVService{
db: db,
cipher: cipher,
appointments: appointments,
cancels: map[uuid.UUID]context.CancelFunc{},
}
}
// Enabled reports whether the service has the encryption key needed to
// perform any CalDAV work.
func (s *CalDAVService) Enabled() bool { return s.cipher != nil }
// Start spawns one sync goroutine per row currently in user_caldav_config
// where enabled = true. No-op when CALDAV_ENCRYPTION_KEY is unset.
func (s *CalDAVService) Start(ctx context.Context) error {
if s.cipher == nil {
slog.Info("CalDAV: disabled (CALDAV_ENCRYPTION_KEY unset)")
return nil
}
s.rootCtx, s.rootStop = context.WithCancel(ctx)
configs, err := s.listEnabledConfigs(ctx)
if err != nil {
return fmt.Errorf("list enabled caldav configs: %w", err)
}
for _, cfg := range configs {
s.spawnLoop(cfg.UserID)
}
slog.Info("CalDAV: started", "users", len(configs))
return nil
}
// Stop cancels all per-user goroutines and waits for them to exit.
func (s *CalDAVService) Stop() {
if s.rootStop != nil {
s.rootStop()
}
s.wg.Wait()
}
// --- Config CRUD ---
// PublicConfig is the password-free view of UserCalDAVConfig returned by
// API endpoints. Mirrors the model without the encrypted blob.
type PublicConfig struct {
URL string `json:"url"`
Username string `json:"username"`
CalendarPath string `json:"calendar_path"`
Enabled bool `json:"enabled"`
LastSyncAt *time.Time `json:"last_sync_at,omitempty"`
LastSyncError *string `json:"last_sync_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// SaveConfigInput is the upsert payload for PUT /api/caldav-config. Password
// is required on first save; an empty password on update means "keep the
// existing one".
type SaveConfigInput struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
CalendarPath string `json:"calendar_path"`
Enabled bool `json:"enabled"`
}
// GetConfig returns the user's current CalDAV configuration or (nil, nil)
// if none exists.
func (s *CalDAVService) GetConfig(ctx context.Context, userID uuid.UUID) (*PublicConfig, error) {
var c models.UserCalDAVConfig
err := s.db.GetContext(ctx, &c,
`SELECT user_id, url, username, password_encrypted, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at
FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get caldav config: %w", err)
}
return &PublicConfig{
URL: c.URL,
Username: c.Username,
CalendarPath: c.CalendarPath,
Enabled: c.Enabled,
LastSyncAt: c.LastSyncAt,
LastSyncError: c.LastSyncError,
UpdatedAt: c.UpdatedAt,
}, nil
}
// SaveConfig upserts the user's CalDAV configuration. The password is
// encrypted before storage; the goroutine for this user is restarted so
// the next sync uses the new credentials.
func (s *CalDAVService) SaveConfig(ctx context.Context, userID uuid.UUID, in SaveConfigInput) (*PublicConfig, error) {
if s.cipher == nil {
return nil, ErrCalDAVNoKey
}
existing, err := s.GetConfig(ctx, userID)
if err != nil {
return nil, err
}
password := in.Password
if password == "" {
if existing == nil {
return nil, fmt.Errorf("%w: password is required on first save", ErrInvalidInput)
}
// Keep existing encrypted blob.
}
var encrypted []byte
if password != "" {
encrypted, err = s.cipher.Encrypt(password)
if err != nil {
return nil, fmt.Errorf("encrypt password: %w", err)
}
}
now := time.Now().UTC()
if existing == nil {
_, err = s.db.ExecContext(ctx,
`INSERT INTO paliad.user_caldav_config
(user_id, url, username, password_encrypted, calendar_path,
enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
userID, in.URL, in.Username, encrypted, in.CalendarPath, in.Enabled, now)
} else if password != "" {
_, err = s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET url = $1, username = $2, password_encrypted = $3,
calendar_path = $4, enabled = $5, updated_at = $6
WHERE user_id = $7`,
in.URL, in.Username, encrypted, in.CalendarPath, in.Enabled, now, userID)
} else {
_, err = s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET url = $1, username = $2, calendar_path = $3,
enabled = $4, updated_at = $5
WHERE user_id = $6`,
in.URL, in.Username, in.CalendarPath, in.Enabled, now, userID)
}
if err != nil {
return nil, fmt.Errorf("upsert caldav config: %w", err)
}
// Restart the per-user goroutine so the next tick uses the new config.
s.stopLoop(userID)
if in.Enabled {
s.spawnLoop(userID)
}
return s.GetConfig(ctx, userID)
}
// DeleteConfig purges the user's CalDAV credentials and stops the sync
// goroutine. Audit §1.3 requirement: encrypted blob is overwritten with
// a single-row DELETE so it's gone from primary storage.
func (s *CalDAVService) DeleteConfig(ctx context.Context, userID uuid.UUID) error {
s.stopLoop(userID)
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.user_caldav_config WHERE user_id = $1`, userID); err != nil {
return fmt.Errorf("delete caldav config: %w", err)
}
return nil
}
// TestConnection performs an ad-hoc PROPFIND with the supplied credentials
// and reports whether they work. Doesn't persist anything. Returns the
// raw error so the UI can surface it.
func (s *CalDAVService) TestConnection(ctx context.Context, in SaveConfigInput) error {
if in.URL == "" || in.Username == "" {
return fmt.Errorf("%w: url and username are required", ErrInvalidInput)
}
password := in.Password
if password == "" {
// Allow "test stored password" by re-decrypting current config.
uid, ok := userIDFromCtx(ctx)
if !ok {
return fmt.Errorf("%w: password required when not logged in", ErrInvalidInput)
}
stored, err := s.decryptedPassword(ctx, uid)
if err != nil {
return err
}
password = stored
}
calendarPath := in.CalendarPath
if calendarPath == "" {
calendarPath = in.URL
}
cli := newCalDAVClient(in.URL, in.Username, password)
return cli.PropfindRoot(ctx, calendarPath)
}
// SyncLog returns the last `n` sync attempts for the user.
func (s *CalDAVService) SyncLog(ctx context.Context, userID uuid.UUID, n int) ([]models.CalDAVSyncLogEntry, error) {
if n <= 0 || n > 50 {
n = 5
}
var rows []models.CalDAVSyncLogEntry
if err := s.db.SelectContext(ctx, &rows,
`SELECT id, user_id, occurred_at, direction, items_pushed,
items_pulled, error, duration_ms
FROM paliad.caldav_sync_log
WHERE user_id = $1
ORDER BY occurred_at DESC
LIMIT $2`, userID, n); err != nil {
return nil, fmt.Errorf("select sync log: %w", err)
}
return rows, nil
}
// --- Per-user sync goroutine ---
func (s *CalDAVService) spawnLoop(userID uuid.UUID) {
if s.cipher == nil || s.rootCtx == nil {
return
}
s.mu.Lock()
if _, exists := s.cancels[userID]; exists {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(s.rootCtx)
s.cancels[userID] = cancel
s.mu.Unlock()
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.userLoop(ctx, userID)
}()
}
func (s *CalDAVService) stopLoop(userID uuid.UUID) {
s.mu.Lock()
cancel, ok := s.cancels[userID]
if ok {
delete(s.cancels, userID)
}
s.mu.Unlock()
if ok {
cancel()
}
}
func (s *CalDAVService) userLoop(ctx context.Context, userID uuid.UUID) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
// First sync runs immediately so the user sees results without waiting.
s.runSyncOnce(ctx, userID)
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.runSyncOnce(ctx, userID)
}
}
}
func (s *CalDAVService) runSyncOnce(ctx context.Context, userID uuid.UUID) {
start := time.Now()
pushed, pulled, syncErr := s.syncOnce(ctx, userID)
dur := int(time.Since(start) / time.Millisecond)
var errStr *string
if syncErr != nil {
es := syncErr.Error()
errStr = &es
slog.Warn("CalDAV: sync failed", "user_id", userID, "error", es)
}
if _, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET last_sync_at = NOW(), last_sync_error = $1
WHERE user_id = $2`, errStr, userID); err != nil {
slog.Warn("CalDAV: write last_sync_at", "error", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.caldav_sync_log
(user_id, direction, items_pushed, items_pulled, error, duration_ms)
VALUES ($1, 'both', $2, $3, $4, $5)`,
userID, pushed, pulled, errStr, dur); err != nil {
slog.Warn("CalDAV: write sync log", "error", err)
}
// Trim sync log to last 5 entries per user.
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.caldav_sync_log
WHERE user_id = $1
AND id NOT IN (
SELECT id FROM paliad.caldav_sync_log
WHERE user_id = $1
ORDER BY occurred_at DESC LIMIT 5
)`, userID); err != nil {
slog.Warn("CalDAV: trim sync log", "error", err)
}
}
// syncOnce runs one push + one pull for a user. Returns (pushed, pulled, err).
func (s *CalDAVService) syncOnce(ctx context.Context, userID uuid.UUID) (int, int, error) {
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return 0, 0, err
}
if cfg == nil || !cfg.Enabled {
return 0, 0, nil
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
pushed, pushErr := s.pushAll(ctx, cli, cfg, userID)
pulled, pullErr := s.pullAll(ctx, cli, cfg, userID)
if pushErr != nil && pullErr != nil {
return pushed, pulled, fmt.Errorf("push: %v; pull: %v", pushErr, pullErr)
}
if pushErr != nil {
return pushed, pulled, fmt.Errorf("push: %w", pushErr)
}
if pullErr != nil {
return pushed, pulled, fmt.Errorf("pull: %w", pullErr)
}
return pushed, pulled, nil
}
// pushAll uploads every visible Appointment to the user's external calendar.
// Best effort: a single failed PUT logs and continues.
func (s *CalDAVService) pushAll(ctx context.Context, cli *calDAVClient, cfg *decryptedConfig, userID uuid.UUID) (int, error) {
appointments, err := s.appointments.AllForUser(ctx, userID)
if err != nil {
return 0, err
}
pushed := 0
for i := range appointments {
t := &appointments[i]
body := formatAppointment(t)
etag, err := cli.PutEvent(ctx, cfg.CalendarPath, terminUID(t.ID.String()), body)
if err != nil {
slog.Warn("CalDAV: push appointment failed", "id", t.ID, "error", err)
continue
}
uid := terminUID(t.ID.String())
if err := s.appointments.SetCalDAVMeta(ctx, t.ID, uid, etag); err != nil {
slog.Warn("CalDAV: write meta failed", "id", t.ID, "error", err)
continue
}
pushed++
}
return pushed, nil
}
// pullAll inspects the remote calendar and reconciles local Appointments. UIDs
// outside the Paliad namespace (paliad-appointment-*@paliad.de) are ignored.
//
// Reconciliation rules:
// - UID matches a known Appointment + ETag changed → ApplyRemoteUpdate
// - UID matches a known Appointment + ETag unchanged → no-op
// - Locally-known UID NOT in remote list → DeleteByCalDAVUID
//
// Foreign-UID events are intentionally not imported in v1 — Paliad
// "owns" its calendar; user-driven additions in the external calendar
// are out of scope and would create attribution problems.
func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *decryptedConfig, userID uuid.UUID) (int, error) {
entries, err := cli.PropfindCalendar(ctx, cfg.CalendarPath)
if err != nil {
return 0, err
}
remoteByUID := map[string]CalDAVEntry{}
pulled := 0
for _, e := range entries {
body, err := cli.GetEvent(ctx, e.Href)
if err != nil {
slog.Warn("CalDAV: GET event failed", "href", e.Href, "error", err)
continue
}
events, err := parseICalendar(body)
if err != nil {
slog.Warn("CalDAV: parse event failed", "href", e.Href, "error", err)
continue
}
for _, ev := range events {
id := extractAppointmentID(ev.UID)
if id == "" {
continue
}
remoteByUID[ev.UID] = e
if _, err := uuid.Parse(id); err != nil {
continue
}
local, err := s.appointments.FindByCalDAVUID(ctx, ev.UID)
if err != nil {
continue // local row not yet created or deleted
}
if local.CreatedBy == nil || *local.CreatedBy != userID {
// Pulled an event owned by another user — skip; that user's
// own goroutine will reconcile.
continue
}
if local.CalDAVEtag != nil && *local.CalDAVEtag == e.ETag {
continue
}
var titlePtr, descPtr, locPtr *string
if ev.Summary != "" {
ts := ev.Summary
titlePtr = &ts
}
if ev.Description != "" {
ds := ev.Description
descPtr = &ds
}
if ev.Location != "" {
ls := ev.Location
locPtr = &ls
}
changed, err := s.appointments.ApplyRemoteUpdate(ctx, local.ID, titlePtr, descPtr, locPtr, ev.DTStart, ev.DTEnd, e.ETag)
if err != nil {
slog.Warn("CalDAV: apply remote update failed", "id", local.ID, "error", err)
continue
}
if changed {
_ = s.appointments.LogConflict(ctx, local.ID, "Appointment from external calendar synced (last-write-wins)")
}
pulled++
}
}
// Detect remote deletions for this user's Paliad-owned events.
all, err := s.appointments.AllForUser(ctx, userID)
if err == nil {
for i := range all {
t := &all[i]
if t.CalDAVUID == nil {
continue
}
if _, ok := remoteByUID[*t.CalDAVUID]; ok {
continue
}
// Remote no longer has this UID — pull-delete.
if err := s.appointments.DeleteByCalDAVUID(ctx, *t.CalDAVUID); err != nil {
slog.Warn("CalDAV: pull-delete failed", "uid", *t.CalDAVUID, "error", err)
continue
}
pulled++
}
}
return pulled, nil
}
// --- Push hooks (AppointmentCalDAVPusher) ---
// OnAppointmentCreated, OnAppointmentUpdated, OnAppointmentDeleted satisfy the
// AppointmentCalDAVPusher interface. They schedule a one-shot best-effort sync
// for the relevant user on a fresh background goroutine so the
// user-facing request returns immediately.
func (s *CalDAVService) OnAppointmentCreated(_ context.Context, userID uuid.UUID, t *models.Appointment) {
s.fireSync(userID, t, "create")
}
func (s *CalDAVService) OnAppointmentUpdated(_ context.Context, userID uuid.UUID, t *models.Appointment) {
s.fireSync(userID, t, "update")
}
func (s *CalDAVService) OnAppointmentDeleted(_ context.Context, userID uuid.UUID, t *models.Appointment) {
if !s.Enabled() {
return
}
go func(uid string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil || cfg == nil || !cfg.Enabled {
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
if err := cli.DeleteEvent(ctx, cfg.CalendarPath, uid); err != nil {
slog.Warn("CalDAV: hook delete failed", "uid", uid, "error", err)
}
}(terminUID(t.ID.String()))
}
func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op string) {
if !s.Enabled() {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil || cfg == nil || !cfg.Enabled {
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
body := formatAppointment(t)
etag, err := cli.PutEvent(ctx, cfg.CalendarPath, terminUID(t.ID.String()), body)
if err != nil {
slog.Warn("CalDAV: hook push failed", "op", op, "id", t.ID, "error", err)
return
}
if err := s.appointments.SetCalDAVMeta(ctx, t.ID, terminUID(t.ID.String()), etag); err != nil {
slog.Warn("CalDAV: hook write meta failed", "id", t.ID, "error", err)
}
}()
}
// --- Internal helpers ---
type decryptedConfig struct {
URL string
Username string
Password string
CalendarPath string
Enabled bool
}
func (s *CalDAVService) loadDecryptedConfig(ctx context.Context, userID uuid.UUID) (*decryptedConfig, error) {
if s.cipher == nil {
return nil, ErrCalDAVNoKey
}
var c models.UserCalDAVConfig
err := s.db.GetContext(ctx, &c,
`SELECT user_id, url, username, password_encrypted, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at
FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
pw, err := s.cipher.Decrypt(c.PasswordEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypt caldav password for %s: %w", userID, err)
}
return &decryptedConfig{
URL: c.URL,
Username: c.Username,
Password: pw,
CalendarPath: c.CalendarPath,
Enabled: c.Enabled,
}, nil
}
func (s *CalDAVService) decryptedPassword(ctx context.Context, userID uuid.UUID) (string, error) {
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return "", err
}
if cfg == nil {
return "", fmt.Errorf("%w: no stored CalDAV config", ErrInvalidInput)
}
return cfg.Password, nil
}
func (s *CalDAVService) listEnabledConfigs(ctx context.Context) ([]models.UserCalDAVConfig, error) {
var rows []models.UserCalDAVConfig
if err := s.db.SelectContext(ctx, &rows,
`SELECT user_id, url, username, password_encrypted, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at
FROM paliad.user_caldav_config WHERE enabled = true`); err != nil {
return nil, err
}
return rows, nil
}
// userIDFromCtx is used by TestConnection. The handler stores the userID
// in the context; we look it up here to avoid changing the function
// signature on every caller. Defined as a private hook so tests can
// override it.
type ctxKey string
const ctxKeyCalDAVUser ctxKey = "caldav-user-id"
// WithCalDAVUser annotates a context with the userID for downstream
// CalDAVService calls that need it (TestConnection).
func WithCalDAVUser(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, ctxKeyCalDAVUser, userID)
}
func userIDFromCtx(ctx context.Context) (uuid.UUID, bool) {
v, ok := ctx.Value(ctxKeyCalDAVUser).(uuid.UUID)
return v, ok
}