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.
638 lines
19 KiB
Go
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
|
|
}
|