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,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) + `"`
|
||||
}
|
||||
Reference in new issue
Block a user