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