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 <valid-sync-token/> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
df9d2d6ded
commit
8fdde182b7
11 files changed
+728
-33
No files matched your search
@@ -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;
|
||||
@@ -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 (<valid-sync-token/>) 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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <allprop> 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 = `<supported-report-set xmlns="DAV:">` +
|
||||
`<supported-report><report><sync-collection/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-query xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`<supported-report><report><C:calendar-multiget xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
|
||||
`</supported-report-set>`
|
||||
|
||||
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, `<calendar-color xmlns="%s">%s</calendar-color>`, 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, `<sync-token xmlns="DAV:">%s</sync-token>`, syncToken(cal.CTag))
|
||||
}
|
||||
if want.reports {
|
||||
b.WriteString(supportedReports)
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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+
|
||||
`<error xmlns="DAV:"><valid-sync-token/></error>`)
|
||||
}
|
||||
|
||||
func writeSyncResponse(w http.ResponseWriter, user *auth.User,
|
||||
cal *Calendar, changes *Changes, withData bool) {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString(`<multistatus xmlns="DAV:" xmlns:C="` + nsCalDAV + `">`)
|
||||
|
||||
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, `<response><href>%s</href><propstat><prop><getetag>%s</getetag>`,
|
||||
xmlText(objectPath(user.Username, cal.Ref(), o.UID)), quoteETag(o.ETag))
|
||||
if withData {
|
||||
fmt.Fprintf(&b, `<C:calendar-data>%s</C:calendar-data>`, xmlText(o.Data))
|
||||
}
|
||||
b.WriteString(`</prop><status>HTTP/1.1 200 OK</status></propstat></response>`)
|
||||
}
|
||||
|
||||
// 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, `<response><href>%s</href><status>HTTP/1.1 404 Not Found</status></response>`,
|
||||
xmlText(objectPath(user.Username, cal.Ref(), uid)))
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<sync-token>%s</sync-token></multistatus>`, 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) + `"`
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 (<valid-sync-token/>) 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;
|
||||
+47
-1
@@ -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 <valid-sync-token/>` – 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:
|
||||
|
||||
@@ -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 = '<c:calendar-data/>' if mit_daten else ''
|
||||
body = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||
f'<d:sync-token>{token}</d:sync-token>'
|
||||
f'<d:sync-level>{level}</d:sync-level>'
|
||||
f'<d:prop><d:getetag/>{daten}</d:prop>'
|
||||
'</d:sync-collection>')
|
||||
return dav("REPORT", pfad, body, {"Depth": "1", "Content-Type": "application/xml"})
|
||||
|
||||
def token_von(xml_text):
|
||||
m = re.search(r"<sync-token[^>]*>([^<]+)</sync-token>", xml_text)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def geloeschte(xml_text):
|
||||
"""hrefs, deren <response> einen 404-Status **ohne** propstat trägt."""
|
||||
out = []
|
||||
for teil in xml_text.split("<response>")[1:]:
|
||||
if "404" in teil and "<propstat" not in teil:
|
||||
m = re.search(r"<href[^>]*>([^<]+)</href>", 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, "<d:sync-token/><d:supported-report-set/>")
|
||||
check("PROPFIND liefert ein sync-token", "<sync-token" in body and "urn:aether:sync:" in body,
|
||||
body[:160])
|
||||
check("supported-report-set nennt sync-collection", "sync-collection" in body, body[:200])
|
||||
|
||||
st, _, body = sync("")
|
||||
check("erster Abgleich ohne Token: 207", st == 207, st)
|
||||
erst_token = token_von(body)
|
||||
check("liefert ein Token", erst_token.startswith("urn:aether:sync:"), erst_token)
|
||||
check("liefert den vorhandenen Bestand", len(hrefs(body)) >= 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'<getetag[^>]*>"[^"]+"</getetag>', 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("<propstat") == 0 or "<propstat" not in body.split("<response>")[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 = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||
'<d:prop><d:getetag/></d:prop>'
|
||||
'<c:filter><c:comp-filter name="VCALENDAR"/></c:filter></c:calendar-query>')
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user