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) + `"` }