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.
1208 lines
41 KiB
Go
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, ¤t,
|
|
`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
|
|
}
|