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>
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package calendar
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"aether/internal/auth"
|
||
)
|
||
|
||
// Zwei Eigenschaften, die jeder verbreitete CalDAV-Client abfragt und die
|
||
// go-webdav v0.7.0 nicht kennt:
|
||
//
|
||
// - getctag (Calendarserver): die billige Frage „hat sich in diesem
|
||
// Kalender etwas geändert?". Ohne sie lädt ein Client bei jedem Abgleich
|
||
// die Liste aller Termine herunter, um sie zu vergleichen.
|
||
// - calendar-color (Apple): die Farbe des Kalenders. Ohne sie zeigt der
|
||
// Client ihn in seiner eigenen Vorgabefarbe, und die Farbgebung im
|
||
// Telefon hat mit der in der Cloud nichts mehr zu tun.
|
||
//
|
||
// Die Bibliothek beantwortet in PROPFIND einen festen Satz Eigenschaften und
|
||
// bietet keinen Haken, eigene dazuzulegen (server.go, propFindCalendar).
|
||
// Deshalb dieser Umweg: Wir nehmen die beiden aus der Anfrage heraus, bevor
|
||
// go-webdav sie zu sehen bekommt, und hängen sie der Antwort wieder an.
|
||
//
|
||
// Warum herausnehmen und nicht einfach anhängen? Weil go-webdav sie sonst
|
||
// als „nicht gefunden" meldet und dieselbe Eigenschaft in derselben Antwort
|
||
// zweimal stünde – einmal mit 404, einmal mit 200. Was ein Client daraus
|
||
// macht, ist Glückssache.
|
||
const (
|
||
nsCalendarServer = "http://calendarserver.org/ns/"
|
||
nsApple = "http://apple.com/ns/ical/"
|
||
)
|
||
|
||
// Ein PROPFIND-Rumpf ist eine Liste leerer Elemente. Alles darüber ist kein
|
||
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
|
||
const maxPropfindBody = 1 << 20
|
||
|
||
// 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 || 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.
|
||
//
|
||
// Der Neuaufbau ist verlustfrei, weil in einem PROPFIND ausschließlich leere
|
||
// Elemente stehen: Name und Namensraum sind alles, was eine Eigenschaft dort
|
||
// ausmacht.
|
||
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 alleExtras(), body
|
||
}
|
||
|
||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||
var (
|
||
inProp bool
|
||
depth int
|
||
keep []xml.Name
|
||
)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
break
|
||
}
|
||
switch t := tok.(type) {
|
||
case xml.StartElement:
|
||
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
|
||
return alleExtras(), body
|
||
}
|
||
if !inProp {
|
||
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
|
||
inProp, depth = true, 0
|
||
}
|
||
continue
|
||
}
|
||
depth++
|
||
if depth > 1 {
|
||
continue // verschachtelte Inhalte gibt es in PROPFIND nicht
|
||
}
|
||
switch {
|
||
case t.Name.Space == nsCalendarServer && t.Name.Local == "getctag":
|
||
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)
|
||
}
|
||
case xml.EndElement:
|
||
if inProp {
|
||
if depth == 0 {
|
||
inProp = false
|
||
} else {
|
||
depth--
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !want.any() {
|
||
return want, body // nichts zu tun – Rumpf unverändert weiterreichen
|
||
}
|
||
return want, buildPropfind(keep)
|
||
}
|
||
|
||
func buildPropfind(props []xml.Name) []byte {
|
||
var b strings.Builder
|
||
b.WriteString(`<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop>`)
|
||
for _, n := range props {
|
||
if n.Space == "DAV:" {
|
||
fmt.Fprintf(&b, "<%s/>", xmlName(n.Local))
|
||
} else {
|
||
fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space))
|
||
}
|
||
}
|
||
// Fragt ein Client *nur* nach unseren beiden Eigenschaften, bliebe hier
|
||
// ein leeres <prop> stehen – damit weist go-webdav die Anfrage ab.
|
||
// resourcetype kennt jede Ressource und stört keinen Client.
|
||
if len(props) == 0 {
|
||
b.WriteString("<resourcetype/>")
|
||
}
|
||
b.WriteString("</prop></propfind>")
|
||
return []byte(b.String())
|
||
}
|
||
|
||
// Elementnamen und Namensräume stammen aus fremdem XML und wandern hier
|
||
// zurück in XML. Alles, was dort etwas anderes bedeuten könnte, fliegt raus –
|
||
// gültige Namen und URIs enthalten davon ohnehin nichts.
|
||
func xmlName(s string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||
r == '-', r == '_', r == '.':
|
||
return r
|
||
}
|
||
return -1
|
||
}, s)
|
||
}
|
||
|
||
func xmlAttr(s string) string {
|
||
r := strings.NewReplacer("&", "&", "\"", """, "<", "<", ">", ">")
|
||
return r.Replace(s)
|
||
}
|
||
|
||
// appleColor bringt eine Farbe in die Schreibweise, die Apple-Clients und
|
||
// DAVx5 erwarten: #RRGGBBAA. Ohne den Alpha-Anteil zeigen manche Clients
|
||
// die Farbe gar nicht an.
|
||
func appleColor(hex string) string {
|
||
h := normalizeHex(hex)
|
||
if h == "" {
|
||
return ""
|
||
}
|
||
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 {
|
||
fmt.Fprintf(&b, `<getctag xmlns="%s">%d</getctag>`, nsCalendarServer, cal.CTag)
|
||
}
|
||
if want.color {
|
||
if c := appleColor(cal.Color); c != "" {
|
||
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 ""
|
||
}
|
||
// Mehrere propstat je Antwort sind der Normalfall in WebDAV – so trennt
|
||
// go-webdav ohnehin schon gefundene von nicht gefundenen Eigenschaften.
|
||
return `<propstat xmlns="DAV:"><prop>` + b.String() +
|
||
`</prop><status>HTTP/1.1 200 OK</status></propstat>`
|
||
}
|
||
|
||
// injectExtras hängt jeder Antwort, die einen Kalender beschreibt, unsere
|
||
// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine
|
||
// bleiben unangetastet.
|
||
func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte {
|
||
cals, err := h.store.ListCalendars(ctx, user.ID)
|
||
if err != nil {
|
||
return body // im Zweifel die Antwort der Bibliothek, unverändert
|
||
}
|
||
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
|
||
// erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat.
|
||
byRef := make(map[string]*Calendar, len(cals))
|
||
for i := range cals {
|
||
byRef[cals[i].Ref()] = &cals[i]
|
||
}
|
||
|
||
const closeTag = "</response>"
|
||
parts := strings.Split(string(body), closeTag)
|
||
var out strings.Builder
|
||
for i, part := range parts {
|
||
out.WriteString(part)
|
||
if i == len(parts)-1 {
|
||
break // hinter dem letzten </response> steht nur noch </multistatus>
|
||
}
|
||
if cal := calendarOfResponse(part, byRef); cal != nil {
|
||
out.WriteString(extrasPropstat(cal, want))
|
||
}
|
||
out.WriteString(closeTag)
|
||
}
|
||
return []byte(out.String())
|
||
}
|
||
|
||
// calendarOfResponse findet den Kalender, den ein <response>-Abschnitt
|
||
// beschreibt – oder nil, wenn er etwas anderes beschreibt.
|
||
func calendarOfResponse(part string, byRef map[string]*Calendar) *Calendar {
|
||
start := strings.Index(part, "<href")
|
||
if start < 0 {
|
||
return nil
|
||
}
|
||
open := strings.Index(part[start:], ">")
|
||
if open < 0 {
|
||
return nil
|
||
}
|
||
rest := part[start+open+1:]
|
||
end := strings.Index(rest, "</href>")
|
||
if end < 0 {
|
||
return nil
|
||
}
|
||
href := rest[:end]
|
||
if p, err := url.PathUnescape(href); err == nil {
|
||
href = p
|
||
}
|
||
ref, objekt, err := parseCalendarPath(href)
|
||
if err != nil || objekt != "" {
|
||
return nil // Home-Set, Principal oder ein einzelner Termin
|
||
}
|
||
return byRef[ref]
|
||
}
|
||
|
||
// recorder hält die Antwort der Bibliothek fest, bis feststeht, ob noch
|
||
// etwas hineingehört. Nötig, weil die Länge sich ändert und Content-Length
|
||
// erst danach stimmt.
|
||
type recorder struct {
|
||
header http.Header
|
||
body bytes.Buffer
|
||
status int
|
||
}
|
||
|
||
func (rec *recorder) Header() http.Header { return rec.header }
|
||
|
||
func (rec *recorder) WriteHeader(code int) {
|
||
if rec.status == 0 {
|
||
rec.status = code
|
||
}
|
||
}
|
||
|
||
func (rec *recorder) Write(p []byte) (int, error) {
|
||
if rec.status == 0 {
|
||
rec.status = http.StatusOK
|
||
}
|
||
return rec.body.Write(p)
|
||
}
|
||
|
||
// servePropfind reicht die Anfrage an go-webdav weiter und ergänzt die
|
||
// Antwort um die Eigenschaften, die die Bibliothek nicht kennt.
|
||
func (h *Handler) servePropfind(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
|
||
if err != nil {
|
||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
want, rewritten := parsePropfind(body)
|
||
|
||
r.Body = io.NopCloser(bytes.NewReader(rewritten))
|
||
r.ContentLength = int64(len(rewritten))
|
||
if !want.any() {
|
||
h.dav.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
rec := &recorder{header: http.Header{}}
|
||
h.dav.ServeHTTP(rec, r)
|
||
|
||
out := rec.body.Bytes()
|
||
if rec.status == http.StatusMultiStatus {
|
||
out = h.injectExtras(r.Context(), user, out, want)
|
||
}
|
||
|
||
for k, v := range rec.header {
|
||
w.Header()[k] = v
|
||
}
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
|
||
w.WriteHeader(rec.status)
|
||
if _, err := w.Write(out); err != nil {
|
||
return
|
||
}
|
||
}
|