From 8fdde182b7e01ad71b7de23ad4d26f43601f0bb0 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 12 Sep 2026 00:03:08 +0200 Subject: [PATCH] feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578) Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen, Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn Minuten, auf Akku und Datenvolumen des Telefons. Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den Stand, bei dem es zuletzt angefasst wurde (Migration 24). Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein Token darunter bekommt 403 statt einer Antwort mit fehlenden Loeschungen. go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und weder sync-token noch supported-report-set. Beide gehen denselben Weg wie getctag und calendar-color. Ohne supported-report-set probiert DAVx5 sync-collection gar nicht erst aus. Zwei Fehler noch vor dem Ausliefern gefunden: * Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung -- xml.EscapeText machte daraus ". Gueltig, aber genug Clients vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung. * pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst. caldav-selbsttest.py: 62 -> 86 Pruefungen. Co-Authored-By: Claude Opus 5 --- .../migrations/000024_calendar_sync.down.sql | 4 + .../db/migrations/000024_calendar_sync.up.sql | 38 +++ backend/internal/calendar/handler.go | 6 + backend/internal/calendar/model.go | 11 +- backend/internal/calendar/propfind.go | 37 ++- backend/internal/calendar/store.go | 241 ++++++++++++++++-- backend/internal/calendar/sync.go | 225 ++++++++++++++++ .../migrations/000024_calendar_sync.down.sql | 4 + .../db/migrations/000024_calendar_sync.up.sql | 38 +++ kalender-plan.md | 48 +++- scripts/caldav-selbsttest.py | 109 ++++++++ 11 files changed, 728 insertions(+), 33 deletions(-) create mode 100644 backend/db/migrations/000024_calendar_sync.down.sql create mode 100644 backend/db/migrations/000024_calendar_sync.up.sql create mode 100644 backend/internal/calendar/sync.go create mode 100644 backend/pkg/db/migrations/000024_calendar_sync.down.sql create mode 100644 backend/pkg/db/migrations/000024_calendar_sync.up.sql diff --git a/backend/db/migrations/000024_calendar_sync.down.sql b/backend/db/migrations/000024_calendar_sync.down.sql new file mode 100644 index 0000000..3800c0b --- /dev/null +++ b/backend/db/migrations/000024_calendar_sync.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS cal_tombstones; +DROP INDEX IF EXISTS cal_objects_sync; +ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq; +ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor; diff --git a/backend/db/migrations/000024_calendar_sync.up.sql b/backend/db/migrations/000024_calendar_sync.up.sql new file mode 100644 index 0000000..550c689 --- /dev/null +++ b/backend/db/migrations/000024_calendar_sync.up.sql @@ -0,0 +1,38 @@ +-- Abgleich nur der Aenderungen (RFC 6578, sync-collection). +-- +-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client +-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr +-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung. +-- +-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan". +-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits +-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt. +-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand, +-- bei dem es zuletzt angefasst wurde. +ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0; + +-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein +-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt +-- also ohnehin einmal alles und bekommt dabei sein erstes Token. +UPDATE cal_objects o SET sync_seq = c.ctag + FROM cal_calendars c WHERE c.id = o.calendar_id; + +CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq); + +-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie +-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das +-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser +-- Art, weil er unsichtbar ist. +CREATE TABLE IF NOT EXISTS cal_tombstones ( + calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE, + uid TEXT NOT NULL, + sync_seq BIGINT NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (calendar_id, uid) +); +CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq); + +-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem +-- aelteren Token bekommt eine klare Absage () und macht +-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen. +ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0; diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go index 0ee953b..d43d72e 100644 --- a/backend/internal/calendar/handler.go +++ b/backend/internal/calendar/handler.go @@ -102,6 +102,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "PROPPATCH": h.handleProppatch(w, r, user) return + case "REPORT": + // Nur sync-collection gehört uns; calendar-query und + // calendar-multiget beantwortet die Bibliothek weiterhin selbst. + if h.handleReport(w, r, user) { + return + } case http.MethodDelete: // Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht // die Bibliothek weiterhin selbst. diff --git a/backend/internal/calendar/model.go b/backend/internal/calendar/model.go index 1cead5b..4aaccfa 100644 --- a/backend/internal/calendar/model.go +++ b/backend/internal/calendar/model.go @@ -27,9 +27,14 @@ type Calendar struct { // PathSegment ist die Adresse, unter der ein Client diesen Kalender // angelegt hat ("…/calendars/max/arbeit/"). Leer heißt: keine gewählte // Adresse, es gilt die UUID. Siehe Ref. - PathSegment string `json:"-"` - CTag int64 `json:"ctag"` - CreatedAt time.Time `json:"created_at"` + PathSegment string `json:"-"` + CTag int64 `json:"ctag"` + // SyncFloor: Bis zu diesem Stand sind Grabsteine weggeräumt. Ein Token + // darunter lässt sich nicht mehr vollständig beantworten – der Client + // bekommt dann eine Absage und gleicht vollständig ab. Siehe + // Store.ChangesSince. + SyncFloor int64 `json:"-"` + CreatedAt time.Time `json:"created_at"` } // Ref ist das Pfadsegment, unter dem dieser Kalender in CalDAV erreichbar diff --git a/backend/internal/calendar/propfind.go b/backend/internal/calendar/propfind.go index a0ec608..b119316 100644 --- a/backend/internal/calendar/propfind.go +++ b/backend/internal/calendar/propfind.go @@ -42,9 +42,17 @@ const ( // Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher. const maxPropfindBody = 1 << 20 -type wantedExtras struct{ ctag, color bool } +// Vier Eigenschaften, die go-webdav nicht kennt. Die ersten beiden sind +// herstellereigen, die letzten beiden stehen im RFC – die Bibliothek liefert +// sie trotzdem nicht. +type wantedExtras struct{ ctag, color, syncToken, reports bool } -func (w wantedExtras) any() bool { return w.ctag || w.color } +func (w wantedExtras) any() bool { return w.ctag || w.color || w.syncToken || w.reports } + +// alleExtras: Antwort auf und auf einen leeren Rumpf. +func alleExtras() wantedExtras { + return wantedExtras{ctag: true, color: true, syncToken: true, reports: true} +} // parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind, // und baut den Rumpf ohne sie neu auf. @@ -55,7 +63,7 @@ func (w wantedExtras) any() bool { return w.ctag || w.color } func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) { // Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1). if len(bytes.TrimSpace(body)) == 0 { - return wantedExtras{ctag: true, color: true}, body + return alleExtras(), body } dec := xml.NewDecoder(bytes.NewReader(body)) @@ -72,7 +80,7 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) { switch t := tok.(type) { case xml.StartElement: if t.Name.Space == "DAV:" && t.Name.Local == "allprop" { - return wantedExtras{ctag: true, color: true}, body + return alleExtras(), body } if !inProp { if t.Name.Space == "DAV:" && t.Name.Local == "prop" { @@ -89,6 +97,10 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) { want.ctag = true case t.Name.Space == nsApple && t.Name.Local == "calendar-color": want.color = true + case t.Name.Space == "DAV:" && t.Name.Local == "sync-token": + want.syncToken = true + case t.Name.Space == "DAV:" && t.Name.Local == "supported-report-set": + want.reports = true default: keep = append(keep, t.Name) } @@ -159,6 +171,15 @@ func appleColor(hex string) string { return strings.ToUpper(h) + "FF" } +// Die Berichte, die dieser Server beantwortet. Ohne diese Liste probiert ein +// Client sync-collection gar nicht erst aus – DAVx5 sieht hier nach, bevor es +// entscheidet, ob es die volle Terminliste holt oder nur die Änderungen. +const supportedReports = `` + + `` + + `` + + `` + + `` + func extrasPropstat(cal *Calendar, want wantedExtras) string { var b strings.Builder if want.ctag { @@ -169,6 +190,14 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string { fmt.Fprintf(&b, `%s`, nsApple, c) } } + if want.syncToken { + // Derselbe Zähler wie im ctag – eine Zahl, zwei Namen, weil die + // Clients historisch nach beidem fragen. + fmt.Fprintf(&b, `%s`, syncToken(cal.CTag)) + } + if want.reports { + b.WriteString(supportedReports) + } if b.Len() == 0 { return "" } diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go index 8802d42..cf2384d 100644 --- a/backend/internal/calendar/store.go +++ b/backend/internal/calendar/store.go @@ -29,7 +29,7 @@ func NewStore(db *pgxpool.Pool) *Store { 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 + `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`, ownerID, ) @@ -41,7 +41,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda 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 { + if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil { return nil, fmt.Errorf("scan calendar: %w", err) } out = append(out, c) @@ -52,10 +52,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda 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 + `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, 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) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -75,13 +75,13 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend 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 + `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, 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) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -102,9 +102,9 @@ func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment 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`, + RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, 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) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if isUniqueViolation(err) { return nil, ErrConflict } @@ -142,9 +142,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col 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`, + RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, 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) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if err != nil { return nil, fmt.Errorf("insert calendar: %w", err) } @@ -164,9 +164,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, 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`, + RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, 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) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -248,22 +248,35 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon } defer tx.Rollback(ctx) + // Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den + // Stand tragen, auf den der Kalender damit springt. + seq, err := bumpSeq(ctx, tx, calendarID) + if err != nil { + return nil, err + } + 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()) + `INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 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() + dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, + sync_seq = EXCLUDED.sync_seq, updated_at = now() RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`, - calendarID, uid, component, data, etag, dtstart, dtend, + calendarID, uid, component, data, etag, dtstart, dtend, seq, ).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) + // Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein + // Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten + // Abgleich beides zu sehen -- die Datei und ihre Todesmeldung. + if _, err := tx.Exec(ctx, + `DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid, + ); err != nil { + return nil, fmt.Errorf("clear tombstone: %w", err) } if err := tx.Commit(ctx); err != nil { @@ -303,11 +316,22 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st return nil, ErrConflict } + // Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin + // fort (Grabstein), im Ziel ist er neu. + fromSeq, err := bumpSeq(ctx, tx, fromCal) + if err != nil { + return nil, err + } + toSeq, err := bumpSeq(ctx, tx, toCal) + if err != nil { + return nil, err + } + row := tx.QueryRow(ctx, - `UPDATE cal_objects SET calendar_id = $1, updated_at = now() - WHERE calendar_id = $2 AND uid = $3 + `UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now() + WHERE calendar_id = $3 AND uid = $4 RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`, - toCal, fromCal, uid, + toCal, toSeq, fromCal, uid, ) o, err := scanObject(row) if errors.Is(err, pgx.ErrNoRows) { @@ -317,10 +341,14 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st return nil, err } + if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil { + return nil, err + } + // Im Ziel darf kein alter Grabstein derselben UID stehen bleiben. if _, err := tx.Exec(ctx, - `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal, + `DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid, ); err != nil { - return nil, fmt.Errorf("bump ctag: %w", err) + return nil, fmt.Errorf("clear tombstone: %w", err) } if err := tx.Commit(ctx); err != nil { @@ -343,8 +371,171 @@ func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid stri 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) + seq, err := bumpSeq(ctx, tx, calendarID) + if err != nil { + return err + } + if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil { + return err + } + if err := pruneTombstones(ctx, tx, calendarID); err != nil { + return err } return tx.Commit(ctx) } + +// ── Abgleich nur der Änderungen (RFC 6578) ─────────────────────────────────── + +// Wie lange ein Grabstein stehen bleibt. +// +// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann +// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen; +// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer. +// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine +// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät. +const tombstoneTTL = 180 * 24 * time.Hour + +type execer interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen. +// +// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen +// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über +// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander. +func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) { + var seq int64 + if err := tx.QueryRow(ctx, + `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID, + ).Scan(&seq); err != nil { + return 0, fmt.Errorf("bump ctag: %w", err) + } + return seq, nil +} + +func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error { + _, err := tx.Exec(ctx, + `INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (calendar_id, uid) DO UPDATE + SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`, + calendarID, uid, seq) + if err != nil { + return fmt.Errorf("set tombstone: %w", err) + } + return nil +} + +// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an. +// +// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis +// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die +// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort, +// in der eine Löschung einfach fehlt, und behielte den Termin für immer. +// +// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen +// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt. +func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error { + // Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der + // ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die + // erste Zeile greift, setzt den Boden auf irgendeinen der geräumten + // Stände – und ein Client mit einem Token dazwischen bekäme dann eine + // Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele. + var hoechste *int64 + if err := tx.QueryRow(ctx, + `WITH geraeumt AS ( + DELETE FROM cal_tombstones + WHERE calendar_id = $1 AND deleted_at < now() - $2::interval + RETURNING sync_seq + ) + SELECT max(sync_seq) FROM geraeumt`, + calendarID, tombstoneTTL.String(), + ).Scan(&hoechste); err != nil { + return fmt.Errorf("prune tombstones: %w", err) + } + if hoechste == nil { + return nil + } + if _, err := tx.Exec(ctx, + `UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`, + calendarID, *hoechste, + ); err != nil { + return fmt.Errorf("raise sync floor: %w", err) + } + return nil +} + +// Changes ist die Antwort auf „was hat sich seit Stand N getan". +type Changes struct { + // Angelegt oder geändert – beides sieht für den Client gleich aus, er + // holt das Objekt in beiden Fällen neu. + Changed []Object + // UIDs, die es nicht mehr gibt. + Deleted []string + // Der Stand, auf dem der Client danach steht. + Token int64 +} + +// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan +// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen +// Bestand – genau das, was ein Client beim ersten Abgleich braucht. +// +// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind +// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre +// unvollständig, ohne dass es jemandem auffiele. +func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) { + if since < 0 || since > cal.CTag { + // Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von + // einer anderen Instanz oder aus einer wiederhergestellten Sicherung. + return nil, false, nil + } + if since > 0 && since < cal.SyncFloor { + return nil, false, nil + } + + out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}} + + 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 AND sync_seq > $2 ORDER BY sync_seq`, + cal.ID, since) + if err != nil { + return nil, false, fmt.Errorf("query changed objects: %w", err) + } + defer rows.Close() + for rows.Next() { + o, err := scanObject(rows) + if err != nil { + return nil, false, err + } + out.Changed = append(out.Changed, o) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + + // Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt: + // Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm + // nur Löschungen für Termine schicken, die er nie gesehen hat. + if since == 0 { + return out, true, nil + } + + trows, err := s.db.Query(ctx, + `SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`, + cal.ID, since) + if err != nil { + return nil, false, fmt.Errorf("query tombstones: %w", err) + } + defer trows.Close() + for trows.Next() { + var uid string + if err := trows.Scan(&uid); err != nil { + return nil, false, err + } + out.Deleted = append(out.Deleted, uid) + } + return out, true, trows.Err() +} diff --git a/backend/internal/calendar/sync.go b/backend/internal/calendar/sync.go new file mode 100644 index 0000000..e63c0c4 --- /dev/null +++ b/backend/internal/calendar/sync.go @@ -0,0 +1,225 @@ +package calendar + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "aether/internal/auth" +) + +// Abgleich nur der Änderungen: REPORT sync-collection, RFC 6578. +// +// **Wozu.** Bis hierher lief jeder Durchgang so: Der Client fragt das ctag +// ab, sieht es verändert und holt daraufhin die *volle* Terminliste. Eine +// verschobene Besprechung kostet damit denselben Verkehr wie ein Neuanfang – +// bei einem Jahr Termine jedes Mal. Am Telefon zahlt man das in Akku und +// Datenvolumen, und zwar bei jedem der viertelstündlichen Durchgänge. +// +// Mit sync-collection sagt der Client stattdessen „ich bin auf Stand 41" und +// bekommt zurück, was seither geschah – und ein neues Token. +// +// **Warum hier und nicht in der Bibliothek.** go-webdav kennt für REPORT nur +// calendar-query und calendar-multiget; alles andere beantwortet es mit 400. +// Der Weg geht deshalb an ihr vorbei, wie schon bei PROPFIND und MKCALENDAR. +// +// **Was ein Token ist.** Eine Zahl: der Stand des Kalenders, also sein ctag. +// Kein Zeitstempel – zwei Änderungen in derselben Sekunde wären damit nicht +// zu trennen, und eine Uhr, die zurückspringt, ließe Änderungen verschwinden. + +// Präfix des Tokens. Ein Token ist laut RFC ein URI; der Präfix macht +// zugleich erkennbar, dass ein Token einer *fremden* Instanz nicht passt. +const syncTokenPrefix = "urn:aether:sync:" + +// Ein sync-collection-Rumpf ist eine Handvoll Elemente. Alles darüber ist +// kein Abgleich mehr. +const maxReportBody = 1 << 20 + +type syncReport struct { + XMLName xml.Name `xml:"DAV: sync-collection"` + SyncToken string `xml:"DAV: sync-token"` + SyncLevel string `xml:"DAV: sync-level"` + Limit *struct { + NResults int `xml:"DAV: nresults"` + } `xml:"DAV: limit"` + Prop struct { + Any []struct { + XMLName xml.Name + } `xml:",any"` + } `xml:"DAV: prop"` +} + +// wantsCalendarData: Fragt der Client die Termindaten gleich mit ab? Die +// meisten holen erst die ETags und danach gezielt per multiget – wer aber +// beides in einem Zug will, soll es bekommen. +func (s *syncReport) wantsCalendarData() bool { + for _, el := range s.Prop.Any { + if el.XMLName.Space == nsCalDAV && el.XMLName.Local == "calendar-data" { + return true + } + } + return false +} + +const nsCalDAV = "urn:ietf:params:xml:ns:caldav" + +// handleReport nimmt sync-collection an sich und lässt alles andere durch. +// Rückgabe: true, wenn die Antwort geschrieben wurde. +func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *auth.User) bool { + body, err := io.ReadAll(io.LimitReader(r.Body, maxReportBody)) + r.Body.Close() + // Was wir nicht selbst beantworten, muss die Bibliothek unverändert + // vorfinden – also den gelesenen Rumpf wieder unterlegen. + r.Body = io.NopCloser(bytes.NewReader(body)) + if err != nil || !isSyncCollection(body) { + return false + } + + var req syncReport + if err := xml.Unmarshal(body, &req); err != nil { + http.Error(w, "malformed sync-collection request", http.StatusBadRequest) + return true + } + + // sync-level ist Pflicht (RFC 6578, 3.3). Bei einem Kalender gibt es nur + // eine Ebene; "infinite" bedeutet hier dasselbe wie "1" und wird + // angenommen, statt den Client an einer Formalie scheitern zu lassen. + if lvl := strings.TrimSpace(req.SyncLevel); lvl != "1" && lvl != "infinite" { + http.Error(w, "sync-level must be 1", http.StatusBadRequest) + return true + } + + ref, rest, err := parseCalendarPath(r.URL.Path) + if err != nil || rest != "" { + // sync-collection gilt für eine Sammlung, nicht für einen Termin. + http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden) + return true + } + cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return true + } + + since, ok := parseSyncToken(req.SyncToken) + if !ok { + writeInvalidToken(w) + return true + } + + changes, gueltig, err := h.store.ChangesSince(r.Context(), cal, since) + if err != nil { + http.Error(w, "sync failed", http.StatusInternalServerError) + return true + } + if !gueltig { + // Das Token ist zu alt oder stammt nicht von uns. Eine unvollständige + // Antwort wäre hier das Schlimmste: Der Client hielte sie für den + // ganzen Unterschied und behielte gelöschte Termine für immer. + writeInvalidToken(w) + return true + } + + writeSyncResponse(w, user, cal, changes, req.wantsCalendarData()) + return true +} + +// isSyncCollection sieht nach, ob das Wurzelelement sync-collection ist – +// ohne den ganzen Rumpf zu deuten. Alles andere geht unverändert weiter an +// go-webdav. +func isSyncCollection(body []byte) bool { + dec := xml.NewDecoder(bytes.NewReader(body)) + for { + tok, err := dec.Token() + if err != nil { + return false + } + if start, ok := tok.(xml.StartElement); ok { + return start.Name.Space == "DAV:" && start.Name.Local == "sync-collection" + } + } +} + +// parseSyncToken liest den Stand aus dem Token. Ein leeres Token heißt +// „ich fange bei null an" – der erste Abgleich eines Clients. +func parseSyncToken(tok string) (int64, bool) { + tok = strings.TrimSpace(tok) + if tok == "" { + return 0, true + } + if !strings.HasPrefix(tok, syncTokenPrefix) { + return 0, false + } + n, err := strconv.ParseInt(strings.TrimPrefix(tok, syncTokenPrefix), 10, 64) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +func syncToken(seq int64) string { + return syncTokenPrefix + strconv.FormatInt(seq, 10) +} + +// writeInvalidToken schickt die Absage, die der RFC dafür vorsieht. Sie ist +// kein Fehler im üblichen Sinn: Jeder Client kennt sie und macht daraufhin +// einen Vollabgleich – genau das, was wir wollen. +func writeInvalidToken(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, xml.Header+ + ``) +} + +func writeSyncResponse(w http.ResponseWriter, user *auth.User, + cal *Calendar, changes *Changes, withData bool) { + + var b strings.Builder + b.WriteString(xml.Header) + b.WriteString(``) + + for i := range changes.Changed { + o := &changes.Changed[i] + // Die Anführungszeichen stehen **außerhalb** der Maskierung: Sonst + // macht xml.EscapeText daraus `"`. Gültiges XML – aber genug + // CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die + // sähen dann bei jedem Durchgang eine Änderung, wo keine ist. + fmt.Fprintf(&b, `%s%s`, + xmlText(objectPath(user.Username, cal.Ref(), o.UID)), quoteETag(o.ETag)) + if withData { + fmt.Fprintf(&b, `%s`, xmlText(o.Data)) + } + b.WriteString(`HTTP/1.1 200 OK`) + } + + // Gelöschtes trägt **keinen** propstat, sondern einen Status direkt an der + // Antwort – so unterscheidet der Client „ist weg" von „gibt es, konnte + // aber eine Eigenschaft nicht liefern". + for _, uid := range changes.Deleted { + fmt.Fprintf(&b, `%sHTTP/1.1 404 Not Found`, + xmlText(objectPath(user.Username, cal.Ref(), uid))) + } + + fmt.Fprintf(&b, `%s`, syncToken(changes.Token)) + + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + io.WriteString(w, b.String()) +} + +// quoteETag: In der Datenbank steht der nackte Wert, über die Leitung gehört +// er in Anführungszeichen (RFC 9110). go-webdav macht das an anderer Stelle +// selbst – hier bauen wir die Antwort von Hand, also hier auch. +// +// Der Inhalt wird maskiert, die Anführungszeichen nicht: Sie sind Teil der +// Syntax, nicht des Werts. +func quoteETag(etag string) string { + if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/`) { + return xmlText(etag) + } + return `"` + xmlText(etag) + `"` +} diff --git a/backend/pkg/db/migrations/000024_calendar_sync.down.sql b/backend/pkg/db/migrations/000024_calendar_sync.down.sql new file mode 100644 index 0000000..3800c0b --- /dev/null +++ b/backend/pkg/db/migrations/000024_calendar_sync.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS cal_tombstones; +DROP INDEX IF EXISTS cal_objects_sync; +ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq; +ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor; diff --git a/backend/pkg/db/migrations/000024_calendar_sync.up.sql b/backend/pkg/db/migrations/000024_calendar_sync.up.sql new file mode 100644 index 0000000..550c689 --- /dev/null +++ b/backend/pkg/db/migrations/000024_calendar_sync.up.sql @@ -0,0 +1,38 @@ +-- Abgleich nur der Aenderungen (RFC 6578, sync-collection). +-- +-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client +-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr +-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung. +-- +-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan". +-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits +-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt. +-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand, +-- bei dem es zuletzt angefasst wurde. +ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0; + +-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein +-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt +-- also ohnehin einmal alles und bekommt dabei sein erstes Token. +UPDATE cal_objects o SET sync_seq = c.ctag + FROM cal_calendars c WHERE c.id = o.calendar_id; + +CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq); + +-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie +-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das +-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser +-- Art, weil er unsichtbar ist. +CREATE TABLE IF NOT EXISTS cal_tombstones ( + calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE, + uid TEXT NOT NULL, + sync_seq BIGINT NOT NULL, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (calendar_id, uid) +); +CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq); + +-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem +-- aelteren Token bekommt eine klare Absage () und macht +-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen. +ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0; diff --git a/kalender-plan.md b/kalender-plan.md index 1e39acb..e8fddfa 100644 --- a/kalender-plan.md +++ b/kalender-plan.md @@ -86,7 +86,7 @@ Die abgeleiteten Spalten sind nur zum Vorsortieren da. | **1** | CalDAV lesend und schreibend, ein Kalender, Anmeldung über App-Passwörter, Auffindpfade (`/.well-known/caldav`, `current-user-principal`, `calendar-home-set`). Ziel: DAVx5 gleicht ab. | ✅ | | **2** | Mehrere Kalender, Farbe, Umbenennen, Löschen. Export als `.ics`, Import einer bestehenden Datei. | ✅ | | **3** | Weboberfläche in Aether: Monat, Woche, Liste; Termin anlegen, ändern, löschen. Der Teil, der Spaß macht – und dessen Fehler man **sieht**. | ✅ | -| **4** | `sync-collection` statt Vollabgleich, Aufgabenansicht, Teilen mit anderen Nutzern, öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | offen | +| **4** | `sync-collection` ✅, Aufgabenansicht, Teilen mit anderen Nutzern, öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | läuft | | **5** | Kontakte (CardDAV). Dieselbe Maschinerie, dieselbe Bibliothek – und **einfacher**, weil es dort keine Wiederholungen gibt. | später entscheiden | Über die Phasen hinaus dazugekommen, weil es sich beim Prüfen als nötig @@ -109,6 +109,52 @@ erwies: Wege führen auf dieselben Daten, aber über völlig verschiedenen Code – ein grüner Lauf des einen sagt nichts über den anderen. +## Abgleich nur der Änderungen (Phase 4, erledigt 12.09.2026) + +Bis hierher lief jeder Durchgang so: Der Client fragt das `ctag` ab, sieht es +verändert und holt daraufhin die **volle** Terminliste. Eine verschobene +Besprechung kostet damit denselben Verkehr wie ein Neuanfang – bei einem Jahr +Termine, jedes Mal, alle fünfzehn Minuten. Bezahlt wird das in Akku und +Datenvolumen des Telefons. + +`sync-collection` (RFC 6578) dreht das um: Der Client sagt „ich bin auf Stand +41" und bekommt zurück, was seither geschah, plus ein neues Token. + +**Kein zweiter Zähler.** Das `ctag` *ist* bereits eine Zahl je Kalender, die +bei jedem Schreiben um eins steigt – also genau die Sequenz, die hier gebraucht +wird. Jedes Objekt merkt sich in `sync_seq` den Stand, bei dem es zuletzt +angefasst wurde. Ein eigener Sync-Zähler daneben wäre eine zweite Wahrheit über +denselben Sachverhalt gewesen, und zwei Wahrheiten laufen auseinander. + +**Grabsteine.** Eine gelöschte Zeile kann nicht mehr erzählen, dass es sie gab. +`cal_tombstones` hält deshalb UID und Stand jedes gelöschten Termins fest – +sonst bliebe er auf jedem Gerät stehen, das während des Löschens nicht +verbunden war. Das ist der teuerste Fehler dieser Art, weil er unsichtbar ist. + +Grabsteine stehen 180 Tage; beim Räumen wandert `sync_floor` mit. Ein Token +darunter lässt sich nicht mehr vollständig beantworten, und dann ist die +ehrliche Antwort `403 ` – der Client gleicht daraufhin voll +ab. Eine lückenhafte Antwort wäre schlimmer als eine Absage: Der Client hielte +sie für den ganzen Unterschied. + +Drei Feinheiten, die beim Bauen auffielen: + +- **Beim ersten Abgleich keine Grabsteine.** Wer den Kalender noch gar nicht + kennt, braucht keine Löschmeldungen für Termine, die er nie gesehen hat. +- **Auferstehung.** Wird eine gelöschte UID neu angelegt, muss ihr Grabstein + weg – sonst bekäme der Client die Datei und ihre Todesmeldung im selben Zug. +- **Anführungszeichen am ETag stehen außerhalb der Maskierung.** `xml.EscapeText` + macht daraus sonst `"`; gültiges XML, aber genug Clients vergleichen + ETags als rohe Zeichenkette und sähen bei jedem Durchgang eine Änderung. + +Dazu zwei Eigenschaften, die go-webdav gar nicht kennt und die jetzt denselben +Weg gehen wie `getctag` und `calendar-color` (`propfind.go`): `sync-token` und +`supported-report-set`. Ohne die zweite probiert DAVx5 `sync-collection` nicht +einmal aus. + +Geprüft in `scripts/caldav-selbsttest.py` (86 statt 62 Prüfungen), darunter der +Fall, auf den es ankommt: *ohne Änderung kommt nichts zurück*. + ## Teilen – drei Bedeutungen, zwei davon gehen „Geteilter Kalender" meint je nach Person etwas anderes: diff --git a/scripts/caldav-selbsttest.py b/scripts/caldav-selbsttest.py index a2b79ed..bd34b6f 100644 --- a/scripts/caldav-selbsttest.py +++ b/scripts/caldav-selbsttest.py @@ -16,6 +16,7 @@ import http.cookiejar import json import re import sys +import uuid import urllib.error import urllib.request @@ -423,6 +424,114 @@ try: headers={"Depth": "0", "Content-Type": "application/xml"}) check("fremder Benutzerpfad: 404", st == 404, st) + # ── Abgleich nur der Änderungen (RFC 6578) ────────────────────────── + print("\n── sync-collection ──") + + def sync(token, pfad=CAL_PFAD, level="1", mit_daten=False): + daten = '' if mit_daten else '' + body = ('' + '' + f'{token}' + f'{level}' + f'{daten}' + '') + return dav("REPORT", pfad, body, {"Depth": "1", "Content-Type": "application/xml"}) + + def token_von(xml_text): + m = re.search(r"]*>([^<]+)", xml_text) + return m.group(1) if m else "" + + def geloeschte(xml_text): + """hrefs, deren einen 404-Status **ohne** propstat trägt.""" + out = [] + for teil in xml_text.split("")[1:]: + if "404" in teil and "]*>([^<]+)", teil) + if m: + out.append(m.group(1)) + return out + + # Der Kalender muss sich zuerst als tauglich melden – sonst probiert es + # kein Client aus. + st, _, body = propfind(CAL_PFAD, "") + check("PROPFIND liefert ein sync-token", "= 1, len(hrefs(body))) + check("beim ersten Mal keine Löschmeldungen", geloeschte(body) == [], geloeschte(body)) + + # Nichts getan – der zweite Abgleich muss leer sein. Das ist der ganze + # Sinn der Übung: kein Verkehr, wenn sich nichts geändert hat. + st, _, body = sync(erst_token) + check("ohne Änderung kommt nichts zurück", st == 207 and hrefs(body) == [], hrefs(body)) + check("Token bleibt gleich", token_von(body) == erst_token, token_von(body)) + + # Ein neuer Termin – und **nur** der darf kommen. + neu_uid = "sync-" + uuid.uuid4().hex[:8] + neu_pfad = f"{CAL_PFAD}{neu_uid}.ics" + st, _, _ = dav("PUT", neu_pfad, + ICS.format(uid=neu_uid, start="20260714T090000Z", end="20260714T100000Z", + summary="Sync-Probe"), + {"Content-Type": "text/calendar"}) + check("Termin angelegt", st in (201, 204), st) + + st, _, body = sync(erst_token) + treffer = hrefs(body) + check("genau ein geänderter Termin", len(treffer) == 1, treffer) + check("und zwar der richtige", treffer and treffer[0].endswith(f"{neu_uid}.ics"), treffer) + check("mit ETag in Anführungszeichen", re.search(r']*>"[^"]+"', body) is not None, + body[:200]) + zweit_token = token_von(body) + check("neues Token ist ein anderes", zweit_token != erst_token, zweit_token) + + # Daten gleich mitliefern, wenn der Client danach fragt. + st, _, body = sync(erst_token, mit_daten=True) + check("calendar-data auf Wunsch dabei", "BEGIN:VCALENDAR" in body and "Sync-Probe" in body, + body[:200]) + + # Löschen: Der nächste Abgleich muss es melden – sonst bliebe der Termin + # auf jedem Gerät stehen, das gerade nicht verbunden war. + st, _, _ = dav("DELETE", neu_pfad) + check("Termin gelöscht", st in (200, 204), st) + st, _, body = sync(zweit_token) + weg = geloeschte(body) + check("Löschung wird gemeldet", len(weg) == 1 and weg[0].endswith(f"{neu_uid}.ics"), weg) + check("Löschung ohne propstat (404 an der Antwort)", + body.count("")[1], body[:250]) + + # Wer von vorn anfängt, darf keine Grabsteine sehen: Er kennt den Termin + # gar nicht, eine Löschmeldung dafür wäre Unsinn. + st, _, body = sync("") + check("Vollabgleich zeigt keine Grabsteine", geloeschte(body) == [], geloeschte(body)) + + print("\n── sync-collection: was schiefgehen darf ──") + st, _, body = sync(erst_token, level="") + check("ohne sync-level: 400", st == 400, st) + st, _, body = sync("urn:aether:sync:abc") + check("unlesbares Token: 403 valid-sync-token", + st == 403 and "valid-sync-token" in body, f"{st} {body[:120]}") + st, _, body = sync("urn:fremd:4711") + check("Token einer fremden Instanz: 403", st == 403 and "valid-sync-token" in body, st) + st, _, body = sync("urn:aether:sync:999999999") + check("Token aus der Zukunft: 403", st == 403 and "valid-sync-token" in body, st) + st, _, body = sync(erst_token, pfad=f"{CAL_PFAD}{neu_uid}.ics") + check("sync-collection auf einem Termin: 403", st == 403, st) + + # calendar-query muss weiterhin die Bibliothek bedienen – der Umweg für + # sync-collection darf die anderen Berichte nicht abschneiden. + abfrage = ('' + '' + '' + '') + st, _, body = dav("REPORT", CAL_PFAD, abfrage, + {"Depth": "1", "Content-Type": "application/xml"}) + check("calendar-query geht weiterhin durch", st == 207, st) + print("\n── Löschen ──") st, _, _ = dav("DELETE", pfad) check("DELETE räumt weg (204)", st in (200, 204), st)