Files
paliad/internal/services/caldav_service.go
mAi fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00

1208 lines
41 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"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
bindings *CalendarBindingService
targets *AppointmentTargetService
mu sync.Mutex
cancels map[uuid.UUID]context.CancelFunc // userID -> goroutine cancel
discoveryMu sync.Mutex
discoveryCache map[uuid.UUID]discoveryCacheEntry
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, bindings *CalendarBindingService, targets *AppointmentTargetService) *CalDAVService {
return &CalDAVService{
db: db,
cipher: cipher,
appointments: appointments,
bindings: bindings,
targets: targets,
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 user that owns at least one
// enabled binding. No-op when CALDAV_ENCRYPTION_KEY is unset.
//
// Post-Slice-2a, "enabled" is determined by paliad.user_calendar_bindings,
// not paliad.user_caldav_config — the config row holds credentials,
// the binding rows decide which calendars get events. A user with a
// disabled config row still needs no goroutine (no credentials), so
// we intersect: enabled binding AND enabled config.
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)
bindings, err := s.bindings.ListAllEnabled(ctx)
if err != nil {
return fmt.Errorf("list enabled caldav bindings: %w", err)
}
// Deduplicate by user_id — one goroutine per user, not per binding.
seen := map[uuid.UUID]bool{}
for _, b := range bindings {
if seen[b.UserID] {
continue
}
// Only spawn when the config row is also enabled (creds present).
cfg, err := s.loadDecryptedConfig(ctx, b.UserID)
if err != nil || cfg == nil || !cfg.Enabled {
continue
}
seen[b.UserID] = true
s.spawnLoop(b.UserID)
}
slog.Info("CalDAV: started", "users", len(seen), "bindings", len(bindings))
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,
supports_mkcalendar, mkcalendar_probed_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)
}
// First-time configure: auto-create an all_visible binding pointing
// at the (legacy) calendar_path so Phase F's "set up CalDAV → events
// just appear" UX survives Slice 2a. The Slice 1 backfill handled
// existing users; this branch covers new users between Slice 2a
// landing and the Slice 2b picker UI being available.
if existing == nil {
path := in.CalendarPath
if path == "" {
path = in.URL
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.user_calendar_bindings
(user_id, calendar_path, display_name, scope_kind, scope_id,
include_personal, enabled, created_at, updated_at)
VALUES ($1, $2, '', 'all_visible', NULL, false, $3, $4, $4)
ON CONFLICT (user_id, calendar_path) DO NOTHING`,
userID, path, in.Enabled, now); err != nil {
slog.Warn("CalDAV: auto-create default binding failed", "user_id", userID, "error", err)
}
}
// Restart the per-user goroutine so the next tick uses the new config.
s.stopLoop(userID)
if in.Enabled {
s.spawnLoop(userID)
}
// A credential / URL change invalidates the cached discovery result —
// the next picker open should re-PROPFIND against the new server.
s.InvalidateDiscoveryCache(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)
}
s.InvalidateDiscoveryCache(userID)
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
}
// --- Discovery (Slice 2b) ---
// DiscoveredCalendars is the response shape of /api/caldav-discover.
// SupportsMKCalendar is nil-on-unprobed and TRUE/FALSE after the
// Slice 2c probe runs (lazily on the first discovery call).
type DiscoveredCalendars struct {
Calendars []DiscoveredCalendarOut `json:"calendars"`
CalendarHome string `json:"calendar_home,omitempty"`
SupportsMKCalendar *bool `json:"supports_mkcalendar,omitempty"`
}
// DiscoveredCalendarOut is the JSON shape returned to the picker.
type DiscoveredCalendarOut struct {
Href string `json:"href"`
DisplayName string `json:"display_name"`
SupportedComponents []string `json:"supported_components,omitempty"`
}
type discoveryCacheEntry struct {
value DiscoveredCalendars
expiresAt time.Time
}
// DiscoverCalendars walks the calendar-home-set chain and returns the
// user's calendars. Cached 5 minutes per user (Q4 of the Slice 2 brief)
// so repeated picker opens don't hammer the CalDAV server.
func (s *CalDAVService) DiscoverCalendars(ctx context.Context, userID uuid.UUID) (DiscoveredCalendars, error) {
if s.cipher == nil {
return DiscoveredCalendars{}, ErrCalDAVNoKey
}
s.discoveryMu.Lock()
if entry, ok := s.discoveryCache[userID]; ok && time.Now().Before(entry.expiresAt) {
s.discoveryMu.Unlock()
return entry.value, nil
}
s.discoveryMu.Unlock()
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return DiscoveredCalendars{}, err
}
if cfg == nil {
return DiscoveredCalendars{}, fmt.Errorf("%w: no CalDAV config", ErrInvalidInput)
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
cals, home, err := cli.DiscoverCalendars(ctx, cfg.URL)
if err != nil {
return DiscoveredCalendars{}, err
}
out := DiscoveredCalendars{
Calendars: make([]DiscoveredCalendarOut, 0, len(cals)),
CalendarHome: home,
}
for _, c := range cals {
out.Calendars = append(out.Calendars, DiscoveredCalendarOut{
Href: c.Href,
DisplayName: c.DisplayName,
SupportedComponents: c.SupportedComponents,
})
}
// Slice 2c — lazy MKCALENDAR capability probe. If we've never
// probed this user (supports_mkcalendar IS NULL) and we just
// learned the calendar-home-set URL, run the probe and stash
// the result. Probe failure is non-fatal — the field just stays
// NULL and the next discovery will retry.
if home != "" {
probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
if perr == nil {
out.SupportsMKCalendar = probed
}
}
s.discoveryMu.Lock()
if s.discoveryCache == nil {
s.discoveryCache = map[uuid.UUID]discoveryCacheEntry{}
}
s.discoveryCache[userID] = discoveryCacheEntry{
value: out,
expiresAt: time.Now().Add(5 * time.Minute),
}
s.discoveryMu.Unlock()
return out, nil
}
// InvalidateDiscoveryCache clears the cached discovery result for the
// user. Called from SaveConfig so a credential change forces a fresh
// PROPFIND on the next picker open. Also clears the persisted
// supports_mkcalendar capability since credentials may point at a
// different server.
func (s *CalDAVService) InvalidateDiscoveryCache(userID uuid.UUID) {
s.discoveryMu.Lock()
delete(s.discoveryCache, userID)
s.discoveryMu.Unlock()
_, _ = s.db.ExecContext(context.Background(),
`UPDATE paliad.user_caldav_config
SET supports_mkcalendar = NULL, mkcalendar_probed_at = NULL
WHERE user_id = $1`, userID)
}
// ensureMKCalendarProbed runs the probe iff the user_caldav_config
// row's supports_mkcalendar is NULL. Returns the cached value when set.
// Probe failure leaves the column NULL (next call retries) and returns
// (nil, err) — caller treats that as "unknown" and omits the field
// from the JSON response.
func (s *CalDAVService) ensureMKCalendarProbed(ctx context.Context, userID uuid.UUID, homePath string, cli *calDAVClient) (*bool, error) {
var current *bool
if err := s.db.GetContext(ctx, &current,
`SELECT supports_mkcalendar
FROM paliad.user_caldav_config
WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("read mkcalendar capability: %w", err)
}
if current != nil {
return current, nil
}
ok, err := cli.ProbeMKCalendar(ctx, homePath)
if err != nil {
// Don't persist a negative on a transient error — leave NULL so
// the next round retries; just surface "unknown" to caller.
return nil, err
}
if _, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET supports_mkcalendar = $1, mkcalendar_probed_at = NOW(),
updated_at = NOW()
WHERE user_id = $2`, ok, userID); err != nil {
slog.Warn("CalDAV: persist mkcalendar capability failed", "user_id", userID, "error", err)
}
return &ok, nil
}
// CreateCalendarInput is the payload for POST /api/caldav-mkcalendar.
type CreateCalendarInput struct {
DisplayName string `json:"display_name"`
ScopeKind string `json:"scope_kind"`
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
IncludePersonal bool `json:"include_personal"`
}
// CreateCalendarResult is the response shape for POST
// /api/caldav-mkcalendar — the new remote calendar path plus the
// binding that was created in the same transaction.
type CreateCalendarResult struct {
CalendarPath string `json:"calendar_path"`
Binding *models.UserCalendarBinding `json:"binding"`
InitialPushed int `json:"initial_pushed"`
}
// MakeCalendar issues MKCALENDAR against the user's calendar-home-set,
// then creates a paliad.user_calendar_bindings row pointing at the new
// path. On MKCALENDAR success but binding-create failure we leave the
// remote calendar in place; on MKCALENDAR failure we never touch the
// binding table. Slice 2c API.
//
// Errors:
// - ErrCalDAVNoKey when cipher is missing.
// - ErrInvalidInput on missing display name / disallowed scope.
// - ErrMKCalendarUnsupported (or persisted supports_mkcalendar=false)
// → the caller maps this to 501.
// - ErrCalendarNameTaken → 409.
func (s *CalDAVService) MakeCalendar(ctx context.Context, userID uuid.UUID, in CreateCalendarInput) (*CreateCalendarResult, error) {
if s.cipher == nil {
return nil, ErrCalDAVNoKey
}
if strings.TrimSpace(in.DisplayName) == "" {
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
}
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("%w: no CalDAV config", ErrInvalidInput)
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
// Discover home + probe capability if we haven't yet.
_, home, err := cli.DiscoverCalendars(ctx, cfg.URL)
if err != nil {
return nil, fmt.Errorf("discover home: %w", err)
}
probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
if perr != nil {
return nil, fmt.Errorf("probe mkcalendar: %w", perr)
}
if probed == nil || !*probed {
return nil, ErrMKCalendarUnsupported
}
// Try the slugified name; retry with -N suffix on 405 (name taken).
slug := slugifyCalendarName(in.DisplayName)
if slug == "" {
slug = "paliad-" + randomToken(3)
}
path, err := s.mkcalendarWithRetry(ctx, cli, home, slug, in.DisplayName)
if err != nil {
return nil, err
}
// Create the matching binding row.
binding, err := s.bindings.Create(ctx, userID, CreateBindingInput{
CalendarPath: path,
DisplayName: in.DisplayName,
ScopeKind: in.ScopeKind,
ScopeID: in.ScopeID,
IncludePersonal: in.IncludePersonal,
Enabled: true,
})
if err != nil {
// MKCALENDAR already succeeded — the remote calendar exists.
// Don't try to clean it up: the user can re-bind via the
// custom-URL path the next time. Returning the error surfaces
// the validation failure to the caller.
return nil, err
}
// Synchronous first push so the user sees events immediately
// (matches POST /api/caldav-bindings Q5 semantics).
pushed, pushErr := s.PushBindingNow(ctx, userID, binding)
if pushErr != nil {
// Binding row exists; push failed — surface both bits.
return &CreateCalendarResult{
CalendarPath: path,
Binding: binding,
InitialPushed: pushed,
}, pushErr
}
s.EnsureLoop(userID)
return &CreateCalendarResult{
CalendarPath: path,
Binding: binding,
InitialPushed: pushed,
}, nil
}
// mkcalendarWithRetry issues MKCALENDAR with up to 3 disambiguating
// suffixes when the server returns 405 (name taken). Gives up after
// 3 tries with the original error so the UI can ask the user to type
// their own name.
func (s *CalDAVService) mkcalendarWithRetry(ctx context.Context, cli *calDAVClient, home, slug, displayName string) (string, error) {
tryName := slug
for attempt := 0; attempt < 3; attempt++ {
path, err := cli.MakeCalendar(ctx, home, tryName, displayName)
if err == nil {
return path, nil
}
if !errors.Is(err, ErrCalendarNameTaken) {
return "", err
}
tryName = slug + "-" + randomToken(2)
}
return "", ErrCalendarNameTaken
}
// slugifyCalendarName turns "Acme v Bosch" → "acme-v-bosch". Trims to
// 32 chars to keep the URL readable. Empty input → empty output (the
// caller falls back to a random token).
func slugifyCalendarName(name string) string {
const maxLen = 32
var b strings.Builder
prevDash := true
for _, r := range name {
switch {
case r >= 'A' && r <= 'Z':
b.WriteRune(r - 'A' + 'a')
prevDash = false
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevDash = false
case prevDash:
// skip
default:
b.WriteRune('-')
prevDash = true
}
if b.Len() >= maxLen {
break
}
}
return strings.Trim(b.String(), "-")
}
// --- Binding lifecycle hooks (Slice 2b) ---
// PushBindingNow runs one push pass for a single binding synchronously.
// Called by POST /api/caldav-bindings so the user sees "Initial sync
// running…" with events already pushed by the time the modal closes.
//
// Best effort: per-event PUT errors are logged but don't fail the call.
// Returns (itemsPushed, err) where err is only set when the surrounding
// config / credentials are broken (no creds, no cipher, decrypt failure).
func (s *CalDAVService) PushBindingNow(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
if s.cipher == nil {
return 0, ErrCalDAVNoKey
}
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return 0, err
}
if cfg == nil {
return 0, fmt.Errorf("%w: no CalDAV config — configure server first", ErrInvalidInput)
}
if !cfg.Enabled {
return 0, nil
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
pushed, err := s.pushBinding(ctx, cli, userID, b)
if err != nil {
// Record the error against the binding so the UI can surface it
// without waiting for the next tick.
es := err.Error()
_ = s.bindings.SetSyncStatus(ctx, b.ID, &es)
return pushed, err
}
_ = s.bindings.SetSyncStatus(ctx, b.ID, nil)
return pushed, nil
}
// RemoveBinding implements the §2.6 cleanup-on-delete flow from the
// Slice 2 brief: list every target attached to the binding, issue a
// remote DELETE per .ics (best-effort), then drop the binding row.
// Target rows cascade away with the binding row via ON DELETE CASCADE.
//
// On a partial failure (some remote DELETEs fail), the binding is
// flipped to enabled=false instead of being dropped so the next sync
// tick can retry the remaining cleanup. The caller (HTTP handler)
// surfaces that as 202 Accepted.
func (s *CalDAVService) RemoveBinding(ctx context.Context, userID, bindingID uuid.UUID) (bool, error) {
b, err := s.bindings.Get(ctx, userID, bindingID)
if err != nil {
return false, err
}
// Best effort: even when cipher / config is missing, drop the row.
// Without creds we can't reach the remote calendar; leaving the
// binding row in place would block re-add at the same path.
deletedFully := true
if s.cipher != nil {
if cfg, cerr := s.loadDecryptedConfig(ctx, userID); cerr == nil && cfg != nil && cfg.Enabled {
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
targets, terr := s.targets.ListForBinding(ctx, bindingID)
if terr != nil {
slog.Warn("CalDAV: remove-binding list targets failed", "binding_id", bindingID, "error", terr)
}
for _, t := range targets {
if err := cli.DeleteEvent(ctx, b.CalendarPath, t.CalDAVUID); err != nil {
slog.Warn("CalDAV: remove-binding remote delete failed", "binding_id", bindingID, "uid", t.CalDAVUID, "error", err)
deletedFully = false
}
}
} else {
// No creds; treat as "remote untouched" and surface to caller.
deletedFully = false
}
} else {
deletedFully = false
}
if !deletedFully {
// Flip enabled=false so a subsequent tick (after creds are fixed)
// can resume the cleanup. The binding stays around so the user
// can see it in the picker with a "needs cleanup" status.
falseB := false
if _, err := s.bindings.Update(ctx, userID, bindingID, UpdateBindingInput{Enabled: &falseB}); err != nil {
return false, err
}
return false, nil
}
if err := s.bindings.Delete(ctx, userID, bindingID); err != nil {
return false, err
}
return true, nil
}
// EnsureLoop spawns the per-user sync goroutine if it isn't running.
// Called after POST /api/caldav-bindings creates a binding for a user
// who didn't have any enabled bindings before this request.
func (s *CalDAVService) EnsureLoop(userID uuid.UUID) {
s.spawnLoop(userID)
}
// --- 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)
}
}
}
// runSyncOnce runs every enabled binding for a user, writing one
// caldav_sync_log row per binding (binding_id populated post-Slice-2a)
// and rolling the per-user last_sync_at into user_caldav_config so the
// existing /api/caldav-config endpoint still shows aggregate status.
func (s *CalDAVService) runSyncOnce(ctx context.Context, userID uuid.UUID) {
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil || cfg == nil || !cfg.Enabled {
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
bindings, err := s.bindings.ListEnabled(ctx, userID)
if err != nil {
slog.Warn("CalDAV: list bindings failed", "user_id", userID, "error", err)
return
}
if len(bindings) == 0 {
return
}
var lastErr *string
for i := range bindings {
b := &bindings[i]
start := time.Now()
pushed, pulled, syncErr := s.syncBinding(ctx, cli, userID, b)
dur := int(time.Since(start) / time.Millisecond)
var errStr *string
if syncErr != nil {
es := syncErr.Error()
errStr = &es
lastErr = errStr
slog.Warn("CalDAV: binding sync failed", "user_id", userID, "binding_id", b.ID, "error", es)
}
if err := s.bindings.SetSyncStatus(ctx, b.ID, errStr); err != nil {
slog.Warn("CalDAV: write binding sync status", "binding_id", b.ID, "error", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.caldav_sync_log
(user_id, binding_id, direction, items_pushed, items_pulled, error, duration_ms)
VALUES ($1, $2, 'both', $3, $4, $5, $6)`,
userID, b.ID, pushed, pulled, errStr, dur); err != nil {
slog.Warn("CalDAV: write sync log", "error", err)
}
}
// Roll the worst-case per-binding error up onto user_caldav_config so
// the existing /api/caldav-config status surface still reflects sync
// health. last_sync_error = NULL means "every binding synced cleanly".
if _, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET last_sync_at = NOW(), last_sync_error = $1
WHERE user_id = $2`, lastErr, userID); err != nil {
slog.Warn("CalDAV: write last_sync_at", "error", err)
}
// Trim sync log to last 5 entries per (user, binding) so multi-binding
// users don't accumulate noise. Per-binding trim keeps each binding's
// own history independently inspectable.
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 25
)`, userID); err != nil {
slog.Warn("CalDAV: trim sync log", "error", err)
}
}
// syncBinding runs one push + one pull for a single binding.
func (s *CalDAVService) syncBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, int, error) {
pushed, pushErr := s.pushBinding(ctx, cli, userID, b)
pulled, pullErr := s.pullBinding(ctx, cli, userID, b)
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
}
// pushBinding uploads every in-scope Appointment to one binding's calendar
// and cleans up target rows for events that have left the scope. Best
// effort: a single failed PUT logs and continues; the next tick retries.
func (s *CalDAVService) pushBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
appointments, err := s.appointments.ForBinding(ctx, userID, b)
if err != nil {
if errors.Is(err, ErrUnsupportedScope) {
// Slice 2a sees a Slice-3 scope_kind. Skip cleanly — the UI
// shouldn't have let the user create this binding, but a
// direct SQL insert could; we don't want to crash the loop.
return 0, nil
}
return 0, err
}
inScope := make(map[uuid.UUID]struct{}, len(appointments))
pushed := 0
for i := range appointments {
t := &appointments[i]
inScope[t.ID] = struct{}{}
body := formatAppointment(t)
uid := terminUID(t.ID.String())
etag, err := cli.PutEvent(ctx, b.CalendarPath, uid, body)
if err != nil {
slog.Warn("CalDAV: push appointment failed", "binding_id", b.ID, "id", t.ID, "error", err)
continue
}
if err := s.targets.UpsertAfterPush(ctx, t.ID, b.ID, uid, etag); err != nil {
slog.Warn("CalDAV: write target failed", "binding_id", b.ID, "id", t.ID, "error", err)
continue
}
// Back-compat: keep the scalar uid/etag on the appointment row
// fresh as a denormalised pointer to "some" target. Other code
// paths still read these in Slice 2a; Slice 4 drops them.
if err := s.appointments.SetCalDAVMeta(ctx, t.ID, uid, etag); err != nil {
slog.Warn("CalDAV: write meta failed", "id", t.ID, "error", err)
}
pushed++
}
// Stale-target cleanup: events that used to be in this binding's
// scope but no longer are (project unshared, scope_kind PATCHed)
// get DELETEd from the remote calendar so the user's calendar app
// doesn't show ghost events.
currentIDs := make([]uuid.UUID, 0, len(appointments))
for id := range inScope {
currentIDs = append(currentIDs, id)
}
stale, err := s.targets.StaleForBinding(ctx, b.ID, currentIDs)
if err != nil {
slog.Warn("CalDAV: stale-target lookup failed", "binding_id", b.ID, "error", err)
return pushed, nil
}
for _, t := range stale {
if err := cli.DeleteEvent(ctx, b.CalendarPath, t.CalDAVUID); err != nil {
slog.Warn("CalDAV: stale delete failed", "binding_id", b.ID, "uid", t.CalDAVUID, "error", err)
continue
}
if err := s.targets.DeleteByAppointmentAndBinding(ctx, t.AppointmentID, b.ID); err != nil {
slog.Warn("CalDAV: stale target delete failed", "binding_id", b.ID, "uid", t.CalDAVUID, "error", err)
}
}
return pushed, nil
}
// pullBinding inspects the remote calendar for one binding and reconciles
// local Appointments using REPORT calendar-multiget. UIDs outside the
// Paliad namespace (paliad-appointment-*@paliad.de) are ignored.
//
// Reconciliation rules (per (appointment, binding) target row):
// - UID matches a known Appointment + ETag changed → ApplyRemoteUpdate
// and upsert target.caldav_etag
// - UID matches a known Appointment + ETag unchanged → no-op
// - Locally-known UID NOT in remote list → DeleteByCalDAVUID + drop
// target row
//
// Foreign-UID events are intentionally not imported — Paliad "owns" its
// UIDs.
func (s *CalDAVService) pullBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
entries, err := cli.PropfindCalendar(ctx, b.CalendarPath)
if err != nil {
return 0, err
}
// Diff against stored target etags. Hrefs whose etag matches the
// stored target are skipped; others get fetched via multiget in
// one round-trip.
storedByUID := map[string]models.AppointmentCalDAVTarget{}
storedTargets, err := s.targets.ListForBinding(ctx, b.ID)
if err == nil {
for _, t := range storedTargets {
storedByUID[t.CalDAVUID] = t
}
}
hrefsToFetch := make([]string, 0, len(entries))
for _, e := range entries {
// We can't determine UID from href alone; fetch every entry
// whose etag we haven't seen. The first multiget seeds the
// uid↔href mapping for future ticks.
hrefsToFetch = append(hrefsToFetch, e.Href)
}
multi, err := cli.ReportMultiget(ctx, b.CalendarPath, hrefsToFetch)
if err != nil {
return 0, err
}
remoteByUID := map[string]MultigetEvent{}
pulled := 0
for _, m := range multi {
events, err := parseICalendar(m.CalendarData)
if err != nil {
slog.Warn("CalDAV: parse event failed", "href", m.Href, "binding_id", b.ID, "error", err)
continue
}
for _, ev := range events {
id := extractAppointmentID(ev.UID)
if id == "" {
continue
}
remoteByUID[ev.UID] = m
if _, err := uuid.Parse(id); err != nil {
continue
}
// Per-binding etag check: if we have a stored target with
// the same etag, nothing changed remotely for this binding.
if stored, ok := storedByUID[ev.UID]; ok && stored.CalDAVEtag != nil && *stored.CalDAVEtag == m.ETag {
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 — that user's own
// goroutine reconciles it against their own binding(s).
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, m.ETag)
if err != nil {
slog.Warn("CalDAV: apply remote update failed", "id", local.ID, "binding_id", b.ID, "error", err)
continue
}
if err := s.targets.UpsertAfterPush(ctx, local.ID, b.ID, ev.UID, m.ETag); err != nil {
slog.Warn("CalDAV: refresh target etag failed", "id", local.ID, "binding_id", b.ID, "error", err)
}
if changed {
_ = s.appointments.LogConflict(ctx, local.ID, "Appointment from external calendar synced (last-write-wins)")
}
pulled++
}
}
// Detect remote deletions: target rows for this binding whose UID
// is no longer in the remote list. Delete the Appointment locally
// (matches Phase F semantics) and drop the target row.
for uidStr, t := range storedByUID {
if _, ok := remoteByUID[uidStr]; ok {
continue
}
if err := s.appointments.DeleteByCalDAVUID(ctx, uidStr); err != nil {
slog.Warn("CalDAV: pull-delete failed", "uid", uidStr, "binding_id", b.ID, "error", err)
continue
}
if err := s.targets.DeleteByAppointmentAndBinding(ctx, t.AppointmentID, b.ID); err != nil {
slog.Warn("CalDAV: pull-delete target cleanup failed", "uid", uidStr, "binding_id", b.ID, "error", err)
}
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")
}
// OnAppointmentDeleted fires a DELETE against every binding that
// previously held a target for this appointment, regardless of current
// scope — the appointment is gone from Paliad and shouldn't survive in
// any user calendar.
func (s *CalDAVService) OnAppointmentDeleted(_ context.Context, userID uuid.UUID, t *models.Appointment) {
if !s.Enabled() {
return
}
appointmentID := t.ID
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)
uid := terminUID(appointmentID.String())
bindings, err := s.bindings.ListEnabled(ctx, userID)
if err != nil {
slog.Warn("CalDAV: hook list bindings failed", "user_id", userID, "error", err)
return
}
for i := range bindings {
b := &bindings[i]
if err := cli.DeleteEvent(ctx, b.CalendarPath, uid); err != nil {
slog.Warn("CalDAV: hook delete failed", "uid", uid, "binding_id", b.ID, "error", err)
}
if err := s.targets.DeleteByAppointmentAndBinding(ctx, appointmentID, b.ID); err != nil {
slog.Warn("CalDAV: hook target cleanup failed", "uid", uid, "binding_id", b.ID, "error", err)
}
}
}()
}
// fireSync pushes an appointment to every binding whose ForBinding()
// scope includes it. Best-effort per binding, identical 30s-timeout
// background goroutine to the Phase F single-binding hook.
func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op string) {
if !s.Enabled() {
return
}
appointmentID := t.ID
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)
uid := terminUID(appointmentID.String())
body := formatAppointment(t)
bindings, err := s.bindings.ListEnabled(ctx, userID)
if err != nil {
slog.Warn("CalDAV: hook list bindings failed", "op", op, "user_id", userID, "error", err)
return
}
for i := range bindings {
b := &bindings[i]
inScope, err := s.appointmentInBinding(ctx, userID, b, appointmentID)
if err != nil {
slog.Warn("CalDAV: hook scope check failed", "op", op, "binding_id", b.ID, "error", err)
continue
}
if !inScope {
continue
}
etag, err := cli.PutEvent(ctx, b.CalendarPath, uid, body)
if err != nil {
slog.Warn("CalDAV: hook push failed", "op", op, "id", appointmentID, "binding_id", b.ID, "error", err)
continue
}
if err := s.targets.UpsertAfterPush(ctx, appointmentID, b.ID, uid, etag); err != nil {
slog.Warn("CalDAV: hook target write failed", "id", appointmentID, "binding_id", b.ID, "error", err)
}
}
}()
}
// appointmentInBinding returns true when the binding's ForBinding()
// scope contains the given appointment. Implemented by reusing
// ForBinding so the rules stay in one place; for personal/project
// bindings the result set is small so the linear scan is fine.
func (s *CalDAVService) appointmentInBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding, appointmentID uuid.UUID) (bool, error) {
appointments, err := s.appointments.ForBinding(ctx, userID, b)
if err != nil {
if errors.Is(err, ErrUnsupportedScope) {
return false, nil
}
return false, err
}
for i := range appointments {
if appointments[i].ID == appointmentID {
return true, nil
}
}
return false, nil
}
// --- 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,
supports_mkcalendar, mkcalendar_probed_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
}
// 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
}