diff --git a/TESTING.md b/TESTING.md index 04dbc3f..d68273e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -41,35 +41,43 @@ anderen. python3 scripts/caldav-selbsttest.py -39 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden +45 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden abgewiesen – für CalDAV gelten nur App-Passwörter), Auffinden über `.well-known/caldav` samt der Umleitung, der Clients mit GET folgen, -Kalenderliste mit Anzeigename und Komponenten, PUT/GET/DELETE eines -Termins, `If-Match` (falsches ETag muss 412 geben), `calendar-query` mit -Zeitfenster und `calendar-multiget`, beide Richtungen zwischen Web und -CalDAV (Ganztagstermin als reines Datum mit exklusivem DTEND, Terminfarbe -als CSS3-Name, RRULE, EXDATE), das Umhängen aus Client-Sicht (Datei -verschwindet in der Quelle, taucht im Ziel auf, UID bleibt) und dass ein -fremder Benutzerpfad 404 gibt statt eines leeren 207. +Kalenderliste mit Anzeigename, Komponenten, `getctag` und +`calendar-color`, PUT/GET/DELETE eines Termins, `If-Match` (falsches ETag +muss 412 geben), `calendar-query` mit Zeitfenster und `calendar-multiget`, +beide Richtungen zwischen Web und CalDAV (Ganztagstermin als reines Datum +mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE, EXDATE), das +Umhängen aus Client-Sicht (Datei verschwindet in der Quelle, taucht im Ziel +auf, UID bleibt) und dass ein fremder Benutzerpfad 404 gibt statt eines +leeren 207. Legt sich ein eigenes App-Passwort und zwei eigene Kalender an und räumt beides am Ende weg; vorhandene Kalender werden nur gelesen. -### Zwei bekannte Lücken, die das Skript als Hinweis meldet +### Zwei Eigenschaften kommen nicht aus der Bibliothek -Beide kommen daher, dass `go-webdav` v0.7.0 nur einen festen Satz -Eigenschaften beantwortet und keinen Haken bietet, um eigene dazuzulegen: +`go-webdav` v0.7.0 beantwortet in PROPFIND einen festen Satz Eigenschaften +und bietet keinen Haken, eigene dazuzulegen. `getctag` (Calendarserver) und +`calendar-color` (Apple) ergänzt Aether deshalb selbst +(`backend/internal/calendar/propfind.go`): Beide werden aus der Anfrage +genommen, bevor go-webdav sie zu sehen bekommt, und der Antwort als eigener +`propstat`-Block wieder angehängt. Ohne das Herausnehmen stünde dieselbe +Eigenschaft zweimal in der Antwort – einmal mit 404, einmal mit 200. -- ⚠️ **`getctag` fehlt** (Antwort 404). Clients können nicht billig fragen - „hat sich etwas geändert?" und listen stattdessen bei jedem Abgleich alle - Termine auf. Der Abgleich bleibt richtig, kostet aber mehr Daten und Akku. -- ⚠️ **`calendar-color` fehlt** (Antwort 404). Der Kalender erscheint im - Client in dessen Vorgabefarbe. **Terminfarben** sind davon nicht - betroffen – die stehen im .ics selbst und kommen an. +Weil das eigener Code ist und nicht Bibliothek, prüft das Skript ihn +besonders: dass beide mit 200 kommen, die Farbe in Apple-Schreibweise +(`#RRGGBBAA`), dass das ctag sich bei jeder Änderung **ändert** (ein +festgefrorenes ctag ist schlimmer als gar keins – der Client fragt einmal +und hält den Kalender danach für unverändert), dass eine Anfrage nach +*nur* diesen Eigenschaften funktioniert (sonst bliebe für go-webdav ein +leeres `` übrig, das es abweist), und dass ohne Nachfrage auch nichts +davon in der Antwort steht. Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt -ebenfalls aus go-webdav; Clients prüfen auf 2xx. +aus go-webdav; Clients prüfen auf 2xx. ## Notfall: Migration hängt fest ("Dirty database version") diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go index e276637..8cd49a1 100644 --- a/backend/internal/calendar/handler.go +++ b/backend/internal/calendar/handler.go @@ -24,6 +24,7 @@ const ( // Handler serves CalDAV at /caldav/ with HTTP Basic Auth (App-Passwörter). type Handler struct { authStore *auth.Store + store *Store dav *caldav.Handler limiter *middleware.FailureLimiter } @@ -31,6 +32,7 @@ type Handler struct { func NewHandler(authStore *auth.Store, store *Store) *Handler { return &Handler{ authStore: authStore, + store: store, dav: &caldav.Handler{Backend: NewBackend(store), Prefix: "/caldav"}, limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout), } @@ -86,7 +88,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user))) + r = r.WithContext(contextWithUser(r.Context(), user)) + + // PROPFIND geht über einen eigenen Weg: Er ergänzt die Antwort um + // Eigenschaften, die go-webdav nicht kennt (siehe propfind.go). + if r.Method == "PROPFIND" { + h.servePropfind(w, r, user) + return + } + + h.dav.ServeHTTP(w, r) } // ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem diff --git a/backend/internal/calendar/propfind.go b/backend/internal/calendar/propfind.go new file mode 100644 index 0000000..a4dbeb5 --- /dev/null +++ b/backend/internal/calendar/propfind.go @@ -0,0 +1,295 @@ +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(``) + 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 + } + byID := make(map[uuid.UUID]*Calendar, len(cals)) + for i := range cals { + byID[cals[i].ID] = &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, byID); 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, byID map[uuid.UUID]*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 + } + 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 + } +} diff --git a/backend/internal/calendar/propfind_test.go b/backend/internal/calendar/propfind_test.go new file mode 100644 index 0000000..6817e51 --- /dev/null +++ b/backend/internal/calendar/propfind_test.go @@ -0,0 +1,116 @@ +package calendar + +import ( + "strings" + "testing" + + "github.com/google/uuid" +) + +const propfindHead = ` +` + +func TestParsePropfindTrenntUnsereEigenschaftenAb(t *testing.T) { + body := []byte(propfindHead + ``) + + want, rewritten := parsePropfind(body) + if !want.ctag || !want.color { + t.Fatalf("beide Eigenschaften sollten erkannt werden, bekam %+v", want) + } + out := string(rewritten) + if !strings.Contains(out, "") { + t.Errorf("die übrigen Eigenschaften müssen erhalten bleiben: %s", out) + } + // Der Kern: go-webdav darf sie nicht zu sehen bekommen, sonst meldet es + // sie als 404 und dieselbe Eigenschaft stünde zweimal in der Antwort. + if strings.Contains(out, "getctag") || strings.Contains(out, "calendar-color") { + t.Errorf("unsere Eigenschaften müssen entfernt sein: %s", out) + } +} + +func TestParsePropfindLaesstFremdeAnfragenInRuhe(t *testing.T) { + body := []byte(propfindHead + ``) + + want, rewritten := parsePropfind(body) + if want.any() { + t.Errorf("nichts von uns gefragt, trotzdem %+v", want) + } + if string(rewritten) != string(body) { + t.Errorf("Rumpf muss unverändert durchgereicht werden") + } +} + +func TestParsePropfindNurUnsereEigenschaftHinterlaesstKeinLeeresProp(t *testing.T) { + // Ein leeres weist go-webdav ab – dann käme auf eine gültige + // Anfrage ein Fehler statt einer Antwort. + body := []byte(propfindHead + ``) + + _, rewritten := parsePropfind(body) + if !strings.Contains(string(rewritten), "") { + t.Errorf("Platzhalter fehlt: %s", rewritten) + } +} + +func TestParsePropfindAllpropUndLeererRumpf(t *testing.T) { + for _, tc := range []struct { + name string + body []byte + }{ + {"allprop", []byte(propfindHead + ``)}, + {"leerer Rumpf", nil}, // RFC 4918, 9.1: leer bedeutet allprop + } { + want, rewritten := parsePropfind(tc.body) + if !want.ctag || !want.color { + t.Errorf("%s: sollte alles liefern, bekam %+v", tc.name, want) + } + if string(rewritten) != string(tc.body) { + t.Errorf("%s: Rumpf darf nicht umgebaut werden", tc.name) + } + } +} + +func TestCalendarOfResponseTrifftNurKalender(t *testing.T) { + id := uuid.New() + cal := &Calendar{ID: id, Color: "#a142f4", CTag: 7} + byID := map[uuid.UUID]*Calendar{id: cal} + + tests := []struct { + name string + href string + will *Calendar + }{ + {"Kalender", "/caldav/calendars/max/" + id.String() + "/", cal}, + {"Home-Set", "/caldav/calendars/max/", nil}, + {"Principal", "/caldav/principals-max/", nil}, + {"einzelner Termin", "/caldav/calendars/max/" + id.String() + "/abc.ics", nil}, + {"fremder Kalender", "/caldav/calendars/max/" + uuid.New().String() + "/", nil}, + } + for _, tc := range tests { + part := `` + tc.href + `` + if got := calendarOfResponse(part, byID); got != tc.will { + t.Errorf("%s: bekam %v, erwartet %v", tc.name, got, tc.will) + } + } +} + +func TestExtrasPropstat(t *testing.T) { + cal := &Calendar{Color: "#a142f4", CTag: 12} + + got := extrasPropstat(cal, wantedExtras{ctag: true, color: true}) + if !strings.Contains(got, ">12") { + t.Errorf("ctag fehlt: %s", got) + } + // Apple-Clients und DAVx5 erwarten den Alpha-Anteil. + if !strings.Contains(got, ">#A142F4FF") { + t.Errorf("Farbe nicht in Apple-Schreibweise: %s", got) + } + if !strings.Contains(got, "HTTP/1.1 200 OK") { + t.Errorf("Status fehlt: %s", got) + } + + // Ohne Farbe bleibt die Eigenschaft weg – lieber gar nicht melden als + // leer, sonst färbt der Client den Kalender schwarz. + if got := extrasPropstat(&Calendar{CTag: 1}, wantedExtras{color: true}); got != "" { + t.Errorf("ohne Farbe sollte nichts entstehen, bekam %s", got) + } +} diff --git a/scripts/caldav-selbsttest.py b/scripts/caldav-selbsttest.py index 5b91f9b..58d76c5 100644 --- a/scripts/caldav-selbsttest.py +++ b/scripts/caldav-selbsttest.py @@ -185,15 +185,49 @@ try: return int(m.group(1)) if m else 0 return 0 - check("ctag oder calendar-color werden überhaupt gemeldet", - prop_status(x, "getctag") or prop_status(x, "calendar-color"), x[:200]) - if prop_status(x, "getctag") != 200: - hinweis("getctag fehlt (404) – Clients können nicht billig fragen " - "\u201ehat sich etwas ge\u00e4ndert?\u201c und listen stattdessen jedes " - "Mal alle Termine auf. Abgleich bleibt richtig, kostet aber mehr.") - if prop_status(x, "calendar-color") != 200: - hinweis("calendar-color fehlt (404) – die Farbe des Kalenders erscheint im " - "Client in dessen Vorgabefarbe. Terminfarben sind davon nicht betroffen.") + def wert(xml_text, prop): + m = re.search(rf"<{prop}[^>]*>([^<]*)", xml_text) + return m.group(1) if m else None + + # Diese beiden kennt go-webdav nicht; Aether ergänzt sie selbst + # (backend/internal/calendar/propfind.go). Genau deshalb gehören sie + # geprüft: Sie hängen an eigenem Code, nicht an der Bibliothek. + check("getctag wird geliefert (200)", prop_status(x, "getctag") == 200, + prop_status(x, "getctag")) + check("calendar-color wird geliefert (200)", prop_status(x, "calendar-color") == 200, + prop_status(x, "calendar-color")) + check("Farbe in Apple-Schreibweise #RRGGBBAA", + wert(x, "calendar-color") == "#A142F4FF", wert(x, "calendar-color")) + + # Ein ctag, das sich nicht ändert, ist schlimmer als gar keins: Der + # Client fragt einmal und hält den Kalender danach für unverändert. + vor = wert(x, "getctag") + st_, tmp = api("POST", f"/api/v1/calendars/{CAL}/events", + {"summary": "ctag-Probe", "start": "2026-12-01T09:00:00Z", + "end": "2026-12-01T10:00:00Z", "all_day": False}, CSRF) + _, _, x2 = propfind(CAL_PFAD, "") + check("ctag ändert sich, wenn sich etwas ändert", wert(x2, "getctag") != vor, + f"{vor} -> {wert(x2, 'getctag')}") + api("DELETE", f"/api/v1/calendars/{CAL}/events/{tmp['uid']}", None, CSRF) + + # Fragt ein Client *nur* danach, bliebe für go-webdav ein leeres + # übrig – das weist es ab. Aether schiebt dann einen Platzhalter unter. + _, _, x3 = propfind(CAL_PFAD, "") + check("Anfrage nur nach getctag funktioniert", prop_status(x3, "getctag") == 200, + x3[:200]) + + # In der Übersicht (Depth 1 auf das Home-Set) muss jeder Kalender seine + # eigene Farbe tragen – dort holt sich ein Client sie beim Einrichten. + _, _, x4 = propfind(HOME, "", depth="1") + farben = re.findall(r"]*>([^<]*)", x4) + check("Kalenderliste trägt die Farben", "#A142F4FF" in farben and "#34A853FF" in farben, + farben) + + # Und eine Anfrage, in der nichts davon vorkommt, darf auch nichts davon + # zurückbekommen – sonst antwortete der Server auf ungestellte Fragen. + _, _, x5 = propfind(CAL_PFAD, "") + check("ohne Nachfrage keine Zusatz-Eigenschaften", + "getctag" not in x5 and "calendar-color" not in x5, x5[:200]) # ── Termine schreiben und lesen wie ein Client ────────────────────── print("\n── Termin anlegen, ändern, löschen (PUT/GET/DELETE) ──")