Uebrig geblieben vom Ein- und Ausbau einer Diagnose-Zeile, mit der zu klaeren war, warum die Kalenderfarbe nicht im Telefon ankam. Die Antwort: DAVx5 fragt die Farbe beim normalen Sync gar nicht ab, nur beim Erfassen der Sammlungen -- nach dem Aktualisieren der Kalenderliste war sie da. Die Diagnose selbst hat es nie ins Repository geschafft, sie lief nur im gebauten Abbild. Weg musste sie trotzdem: Sie schrieb jede PROPFIND-Antwort vollstaendig ins Protokoll, auf INFO-Ebene, und Kalendernamen gehoeren nicht in Logdateien. Beide Selbsttests gruen (45 CalDAV, 36 JSON). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
8.4 KiB
Go
297 lines
8.4 KiB
Go
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("&", "&", "\"", """, "<", "<", ">", ">")
|
||
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
|
||
}
|
||
}
|