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 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(``) 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 stehen – damit weist go-webdav die Anfrage ab. // resourcetype kennt jede Ressource und stört keinen Client. if len(props) == 0 { b.WriteString("") } b.WriteString("") 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 = `` + `` + `` + `` + `` func extrasPropstat(cal *Calendar, want wantedExtras) string { var b strings.Builder if want.ctag { fmt.Fprintf(&b, `%d`, nsCalendarServer, cal.CTag) } if want.color { if c := appleColor(cal.Color); c != "" { fmt.Fprintf(&b, `%s`, 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, `%s`, 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 `` + b.String() + `HTTP/1.1 200 OK` } // 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 = "" 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 steht nur noch } 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 -Abschnitt // beschreibt – oder nil, wenn er etwas anderes beschreibt. func calendarOfResponse(part string, byRef map[string]*Calendar) *Calendar { start := strings.Index(part, "") if open < 0 { return nil } rest := part[start+open+1:] end := strings.Index(rest, "") 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 } }