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 ". 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>
164 lines
5.3 KiB
Go
164 lines
5.3 KiB
Go
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)
|
||
}
|