Files
Aether/backend/internal/calendar/propfind.go
T
marcandClaude Opus 5 128d54035a feat(calendar): getctag und calendar-color fuer CalDAV-Clients
Beide Eigenschaften fragt jeder verbreitete Client ab, und beide kennt
go-webdav v0.7.0 nicht:

  getctag         die billige Frage "hat sich in diesem Kalender etwas
                  geaendert?". Ohne sie laedt ein Client bei jedem
                  Abgleich die Liste aller Termine herunter, um sie zu
                  vergleichen -- richtig, aber teuer an Daten und Akku.
  calendar-color  die Farbe des Kalenders. Ohne sie zeigt das Telefon
                  ihn in seiner eigenen Vorgabefarbe, und die Farbgebung
                  dort hat mit der in der Cloud nichts mehr zu tun.

Die Bibliothek beantwortet einen festen Satz Eigenschaften und bietet
keinen Haken, eigene dazuzulegen (server.go, propFindCalendar). Deshalb
faengt der Handler PROPFIND jetzt ab: Er nimmt die beiden aus der
Anfrage heraus, bevor go-webdav sie zu sehen bekommt, und haengt sie der
Antwort als eigenen propstat-Block wieder an.

Das Herausnehmen ist der Punkt, an dem es sonst schiefgeht: Ohne das
meldet go-webdav sie als "nicht gefunden", und dieselbe Eigenschaft
stuende zweimal in derselben Antwort -- einmal mit 404, einmal mit 200.
Was ein Client daraus macht, ist Gluecksache.

Zwei Fallen dabei, beide abgedeckt: Fragt ein Client *nur* nach diesen
Eigenschaften, bliebe ein leeres <prop> uebrig, das go-webdav abweist --
dann schiebt Aether resourcetype als Platzhalter unter. Und die Farbe
geht als #RRGGBBAA hinaus, mit Alpha-Anteil, weil manche Clients sie
sonst gar nicht anzeigen.

Der Neuaufbau des Anfragerumpfs ist verlustfrei, weil in einem PROPFIND
ausschliesslich leere Elemente stehen: Name und Namensraum sind alles,
was eine Eigenschaft dort ausmacht.

Unit-Tests fuer die XML-Arbeit (propfind_test.go), CalDAV-Selbsttest von
39 auf 45 Pruefungen: beide Eigenschaften mit 200, Apple-Schreibweise,
ctag aendert sich bei Aenderungen (ein festgefrorenes ctag ist schlimmer
als gar keins), Anfrage nur nach ihnen, Farben in der Kalenderliste, und
ohne Nachfrage kommt auch nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:11:25 +02:00

296 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package calendar
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/google/uuid"
"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
type wantedExtras struct{ ctag, color bool }
func (w wantedExtras) any() bool { return w.ctag || w.color }
// 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 wantedExtras{ctag: true, color: true}, 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 wantedExtras{ctag: true, color: true}, 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
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("&", "&amp;", "\"", "&quot;", "<", "&lt;", ">", "&gt;")
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"
}
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 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
}
byID := make(map[uuid.UUID]*Calendar, len(cals))
for i := range cals {
byID[cals[i].ID] = &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, byID); 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, byID map[uuid.UUID]*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
}
calID, objekt, err := parseCalendarPath(href)
if err != nil || objekt != "" {
return nil // Home-Set, Principal oder ein einzelner Termin
}
return byID[calID]
}
// 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
}
}