Bisher konnte ein CalDAV-Client Termine verwalten, aber keine Kalender:
MKCALENDAR -> 405 anlegen
PROPPATCH -> 501 umbenennen, umfaerben
DELETE -> 404 loeschen
go-webdav deckt davon nur MKCOL ab, und selbst da verwarf unser Backend
die vom Client gewaehlte Adresse. Alle drei Verben faengt der Handler
jetzt vorher ab (collections.go).
Der Kern steckt aber nicht in den Verben, sondern in der Adresse: Bei
MKCALENDAR sucht *der Client* aus, wo der Kalender liegen soll
("…/calendars/max/arbeit/"). Unsere Kalender lagen bisher zwingend unter
ihrer UUID -- der Client haette angelegt und auf seine eigene Adresse
sofort ein 404 bekommen.
Deshalb Migration 23 mit einer Spalte path_segment: gesetzt = die
gewaehlte Adresse, NULL = es gilt weiterhin die UUID. Bestehende
Kalender bleiben damit unter genau der Adresse erreichbar, unter der die
eingerichteten Geraete sie kennen -- ein Wechsel haette jedes Telefon
dazu gebracht, seine Termine fuer geloescht zu halten. Aufgeloest wird
beides in einer Abfrage (GetCalendarByRef), die gewaehlte Adresse hat
Vorrang.
PROPPATCH antwortet mit einem Status je Eigenschaft, auch wenn alles
geklappt hat: Es kann teilweise gelingen, und ein nacktes 200
verschwiege, welcher Teil angekommen ist. Was wir nicht aendern koennen,
bekommt 403 statt stillem Schlucken -- ein Client, der seine Aenderung
fuer angekommen haelt, ist schlimmer dran als einer mit klarer Absage.
Ebenso eine unlesbare Farbe: absagen statt raten.
DELETE auf eine Sammlung loescht den Kalender mitsamt allen Terminen --
das ist in WebDAV seine Bedeutung.
CalDAV-Selbsttest von 45 auf 62 Pruefungen: anlegen, unter der
gewaehlten Adresse erreichbar, Farbe aus dem MKCALENDAR uebernommen,
Termin darin ablegen und abrufen, zweites Anlegen auf dieselbe Adresse
405, umbenennen und umfaerben mit Status je Eigenschaft, loeschen, danach
404 -- und dasselbe noch einmal ueber MKCOL, das Verb, das DAVx5 nimmt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package calendar
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
var ErrNotFound = errors.New("calendar: not found")
|
||
|
||
// ErrConflict: im Zielkalender liegt bereits ein Termin mit dieser UID. Zwei
|
||
// Objekte gleicher UID in einer Sammlung wären für CalDAV-Clients nicht
|
||
// unterscheidbar – deshalb bleibt der Termin dann lieber, wo er ist.
|
||
var ErrConflict = errors.New("calendar: uid already exists in target calendar")
|
||
|
||
type Store struct {
|
||
db *pgxpool.Pool
|
||
}
|
||
|
||
func NewStore(db *pgxpool.Pool) *Store {
|
||
return &Store{db: db}
|
||
}
|
||
|
||
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||
ownerID,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query calendars: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Calendar
|
||
for rows.Next() {
|
||
var c Calendar
|
||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt); err != nil {
|
||
return nil, fmt.Errorf("scan calendar: %w", err)
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
|
||
ownerID, id,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query calendar: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
// GetCalendarByRef findet einen Kalender über sein Pfadsegment: entweder die
|
||
// vom Client gewählte Adresse oder – wenn keine gewählt wurde – die UUID.
|
||
//
|
||
// Beides in einer Abfrage, damit der Aufruf nicht raten muss, welche Art
|
||
// Adresse er gerade in der Hand hält. Die gewählte Adresse hat Vorrang: Nur
|
||
// so bleibt eine Adresse eindeutig, selbst wenn sie zufällig aussieht wie
|
||
// die UUID eines anderen Kalenders.
|
||
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||
FROM cal_calendars
|
||
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
||
ORDER BY path_segment NULLS LAST
|
||
LIMIT 1`,
|
||
ownerID, ref,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query calendar by ref: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
// CreateCalendarAt legt einen Kalender unter einer **vom Client gewählten**
|
||
// Adresse an – der Fall MKCALENDAR/MKCOL. Ist die Adresse schon vergeben,
|
||
// kommt ErrConflict: In WebDAV ist das ein 405, kein stilles Überschreiben.
|
||
func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment, name, color, description string) (*Calendar, error) {
|
||
if color == "" {
|
||
color = "#4285f4"
|
||
}
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
|
||
ownerID, name, color, description, segment,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||
if isUniqueViolation(err) {
|
||
return nil, ErrConflict
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("insert calendar at %q: %w", segment, err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
func isUniqueViolation(err error) bool {
|
||
var pgErr *pgconn.PgError
|
||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||
}
|
||
|
||
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
|
||
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort
|
||
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
|
||
// CalDAV-Client kann das.
|
||
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
|
||
cals, err := s.ListCalendars(ctx, ownerID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(cals) > 0 {
|
||
return &cals[0], nil
|
||
}
|
||
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "")
|
||
}
|
||
|
||
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
|
||
if color == "" {
|
||
color = "#4285f4"
|
||
}
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
||
VALUES ($1, $2, $3, $4)
|
||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
|
||
ownerID, name, color, description,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("insert calendar: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
// UpdateCalendar ändert Name/Farbe/Beschreibung/Zeitzone. Ein nil-Feld lässt
|
||
// die bestehende Spalte unangetastet – so kann der Aufrufer nur das Feld
|
||
// schicken, das sich wirklich ändert, ohne vorher den ganzen Datensatz zu
|
||
// laden.
|
||
func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, color, description, timezone *string) (*Calendar, error) {
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`UPDATE cal_calendars SET
|
||
name = COALESCE($3, name),
|
||
color = COALESCE($4, color),
|
||
description = COALESCE($5, description),
|
||
timezone = COALESCE($6, timezone)
|
||
WHERE owner_id = $1 AND id = $2
|
||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
|
||
ownerID, id, name, color, description, timezone,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("update calendar: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error {
|
||
tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id)
|
||
if err != nil {
|
||
return fmt.Errorf("delete calendar: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||
FROM cal_objects WHERE calendar_id = $1 ORDER BY uid`,
|
||
calendarID,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query objects: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Object
|
||
for rows.Next() {
|
||
o, err := scanObject(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, o)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) {
|
||
row := s.db.QueryRow(ctx,
|
||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||
FROM cal_objects WHERE calendar_id = $1 AND uid = $2`,
|
||
calendarID, uid,
|
||
)
|
||
o, err := scanObject(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
func scanObject(row rowScanner) (Object, error) {
|
||
var o Object
|
||
err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||
if err != nil {
|
||
return Object{}, fmt.Errorf("scan object: %w", err)
|
||
}
|
||
return o, nil
|
||
}
|
||
|
||
// PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben
|
||
// Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der
|
||
// Aufrufer vorher – hier zählt nur noch das Schreiben selbst.
|
||
func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) {
|
||
tx, err := s.db.Begin(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
var o Object
|
||
err = tx.QueryRow(ctx,
|
||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
|
||
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now()
|
||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||
calendarID, uid, component, data, etag, dtstart, dtend,
|
||
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("upsert object: %w", err)
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
|
||
return nil, fmt.Errorf("bump ctag: %w", err)
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return nil, fmt.Errorf("commit tx: %w", err)
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
// MoveObject hängt einen Termin an einen anderen Kalender um. Die UID und der
|
||
// gesamte .ics-Inhalt bleiben dabei unangetastet – es wechselt nur die
|
||
// Sammlung, so wie ein MOVE in WebDAV.
|
||
//
|
||
// Für CalDAV-Clients ändert sich damit die Adresse des Termins: Sie sehen ihn
|
||
// in der Quelle verschwinden und im Ziel auftauchen. Deshalb bekommen
|
||
// **beide** Kalender ein neues ctag – mit nur einem holte der Client die
|
||
// andere Seite erst beim nächsten Vollabgleich, und der Termin stünde
|
||
// zwischenzeitlich doppelt oder gar nicht im Telefon.
|
||
func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid string) (*Object, error) {
|
||
if fromCal == toCal {
|
||
return s.GetObjectByUID(ctx, fromCal, uid)
|
||
}
|
||
|
||
tx, err := s.db.Begin(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
var taken bool
|
||
if err := tx.QueryRow(ctx,
|
||
`SELECT EXISTS(SELECT 1 FROM cal_objects WHERE calendar_id = $1 AND uid = $2)`,
|
||
toCal, uid,
|
||
).Scan(&taken); err != nil {
|
||
return nil, fmt.Errorf("check target: %w", err)
|
||
}
|
||
if taken {
|
||
return nil, ErrConflict
|
||
}
|
||
|
||
row := tx.QueryRow(ctx,
|
||
`UPDATE cal_objects SET calendar_id = $1, updated_at = now()
|
||
WHERE calendar_id = $2 AND uid = $3
|
||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||
toCal, fromCal, uid,
|
||
)
|
||
o, err := scanObject(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx,
|
||
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("bump ctag: %w", err)
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return nil, fmt.Errorf("commit tx: %w", err)
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error {
|
||
tx, err := s.db.Begin(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid)
|
||
if err != nil {
|
||
return fmt.Errorf("delete object: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
|
||
return fmt.Errorf("bump ctag: %w", err)
|
||
}
|
||
return tx.Commit(ctx)
|
||
}
|