Files
marcandClaude Opus 5 8fdde182b7 feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578)
Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen,
Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete
damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn
Minuten, auf Akku und Datenvolumen des Telefons.

Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die
bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den
Stand, bei dem es zuletzt angefasst wurde (Migration 24).

Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe
ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden
war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein
Token darunter bekommt 403 <valid-sync-token/> statt einer Antwort mit
fehlenden Loeschungen.

go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und
weder sync-token noch supported-report-set. Beide gehen denselben Weg wie
getctag und calendar-color. Ohne supported-report-set probiert DAVx5
sync-collection gar nicht erst aus.

Zwei Fehler noch vor dem Ausliefern gefunden:

* Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung --
  xml.EscapeText machte daraus &#34;. Gueltig, aber genug Clients
  vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung.
* pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als
  Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig
  gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst.

caldav-selbsttest.py: 62 -> 86 Pruefungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 00:03:08 +02:00

164 lines
5.3 KiB
Go
Raw Permalink 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 (
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/emersion/go-webdav/caldav"
"aether/internal/auth"
"aether/pkg/middleware"
)
// Brute-force-Schutz für /caldav/, aus denselben Gründen wie beim
// WebDAV-Endpunkt: Basic Auth läuft am normalen Login-Rate-Limit vorbei.
const (
davMaxFailures = 10
davWindow = 15 * time.Minute
davLockout = 15 * time.Minute
)
// 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
}
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),
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
clientIP := middleware.RealIP(r)
if allowed, retryAfter := h.limiter.Allowed(clientIP); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
http.Error(w, "Too many failed login attempts", http.StatusTooManyRequests)
return
}
username, password, ok := r.BasicAuth()
if !ok || username == "" || password == "" {
challenge(w)
return
}
// Nur App-Passwörter, aus demselben Grund wie bei /dav/: das
// Kontopasswort öffnet auch den Admin-Bereich.
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
if err != nil {
h.limiter.Fail(clientIP)
challenge(w)
return
}
h.limiter.Reset(clientIP)
// Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit
// GET statt PROPFIND, oder ein Mensch öffnet die Adresse zum Testen im
// Browser. Ohne diese Abkürzung würde das an GetCalendarObject
// durchgereicht, das für Sammlungen (Principal, Home-Set, Kalender) mit
// 404 antwortet – ein Client, der so den Dienst prüft, bräche die
// Einrichtung ab, bevor er je ein PROPFIND schickt.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !strings.HasSuffix(r.URL.Path, ".ics") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodGet {
io.WriteString(w, "Dies ist eine CalDAV-Sammlung. Mit einem CalDAV-Client verbinden (PROPFIND), nicht mit dem Browser.\n")
}
return
}
// Ein Pfad, der auf einen *anderen* Benutzer zeigt, ist ein Fehler und
// muss auch so aussehen. Ohne diese Prüfung antwortet go-webdav mit
// einem leeren 207: Der Client sieht dann einen Kalenderbestand ohne
// Kalender und meldet „nichts gefunden", statt „falsche Adresse" – die
// stille Variante desselben Fehlers, die bei der Einrichtung Stunden
// kostet.
if !pathBelongsTo(r.URL.Path, user.Username) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
r = r.WithContext(contextWithUser(r.Context(), user))
// Drei Wege gehen an der Bibliothek vorbei, weil sie sie nicht oder
// falsch bedient – siehe propfind.go und collections.go.
switch r.Method {
case "PROPFIND":
h.servePropfind(w, r, user)
return
case "MKCALENDAR":
h.handleMkcalendar(w, r, user)
return
case "PROPPATCH":
h.handleProppatch(w, r, user)
return
case "REPORT":
// Nur sync-collection gehört uns; calendar-query und
// calendar-multiget beantwortet die Bibliothek weiterhin selbst.
if h.handleReport(w, r, user) {
return
}
case http.MethodDelete:
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
// die Bibliothek weiterhin selbst.
if h.handleDeleteCalendar(w, r, user) {
return
}
}
h.dav.ServeHTTP(w, r)
}
// ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem
// Clients wie DAVx5 die automatische Einrichtung starten (RFC 6764). Er
// liegt außerhalb von /caldav/, deshalb braucht es diese eigene Route statt
// caldav.Handler's eingebauter Behandlung, die nur den exakten Pfad
// "/.well-known/caldav" innerhalb des eigenen Präfix kennt.
func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || username == "" || password == "" {
challenge(w)
return
}
user, err := h.authStore.VerifyAppPassword(r.Context(), username, password)
if err != nil {
challenge(w)
return
}
http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect)
}
// pathBelongsTo erkennt Pfade, die auf einen anderen Benutzer zeigen.
// Bewusst nur ablehnen, was sicher fremd ist: Alles Unbekannte geht weiter
// an go-webdav, damit diese Prüfung keine Pfade abschneidet, die sie gar
// nicht kennt.
func pathBelongsTo(p, username string) bool {
rest := strings.Trim(strings.TrimPrefix(p, davPrefix), "/")
if rest == "" {
return true
}
seg := strings.Split(rest, "/")
switch {
case strings.HasPrefix(seg[0], "principals-"):
return strings.EqualFold(strings.TrimPrefix(seg[0], "principals-"), username)
case seg[0] == "calendars" && len(seg) >= 2:
return strings.EqualFold(seg[1], username)
}
return true
}
func challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`)
http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
}