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 &#34;. 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:
marcandClaude Opus 5 committed 2026-09-12 00:03:08 +02:00
1 parent df9d2d6ded
commit 8fdde182b7
11 files changed
+728 -33

No files matched your search

+225
View File
@@ -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 `&#34;`. 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) + `"`
}