Beide Eigenschaften fragt jeder verbreitete Client ab, und beide kennt
go-webdav v0.7.0 nicht:
getctag die billige Frage "hat sich in diesem Kalender etwas
geaendert?". Ohne sie laedt ein Client bei jedem
Abgleich die Liste aller Termine herunter, um sie zu
vergleichen -- richtig, aber teuer an Daten und Akku.
calendar-color die Farbe des Kalenders. Ohne sie zeigt das Telefon
ihn in seiner eigenen Vorgabefarbe, und die Farbgebung
dort hat mit der in der Cloud nichts mehr zu tun.
Die Bibliothek beantwortet einen festen Satz Eigenschaften und bietet
keinen Haken, eigene dazuzulegen (server.go, propFindCalendar). Deshalb
faengt der Handler PROPFIND jetzt ab: Er nimmt die beiden aus der
Anfrage heraus, bevor go-webdav sie zu sehen bekommt, und haengt sie der
Antwort als eigenen propstat-Block wieder an.
Das Herausnehmen ist der Punkt, an dem es sonst schiefgeht: Ohne das
meldet go-webdav sie als "nicht gefunden", und dieselbe Eigenschaft
stuende zweimal in derselben Antwort -- einmal mit 404, einmal mit 200.
Was ein Client daraus macht, ist Gluecksache.
Zwei Fallen dabei, beide abgedeckt: Fragt ein Client *nur* nach diesen
Eigenschaften, bliebe ein leeres <prop> uebrig, das go-webdav abweist --
dann schiebt Aether resourcetype als Platzhalter unter. Und die Farbe
geht als #RRGGBBAA hinaus, mit Alpha-Anteil, weil manche Clients sie
sonst gar nicht anzeigen.
Der Neuaufbau des Anfragerumpfs ist verlustfrei, weil in einem PROPFIND
ausschliesslich leere Elemente stehen: Name und Namensraum sind alles,
was eine Eigenschaft dort ausmacht.
Unit-Tests fuer die XML-Arbeit (propfind_test.go), CalDAV-Selbsttest von
39 auf 45 Pruefungen: beide Eigenschaften mit 200, Apple-Schreibweise,
ctag aendert sich bei Aenderungen (ein festgefrorenes ctag ist schlimmer
als gar keins), Anfrage nur nach ihnen, Farben in der Kalenderliste, und
ohne Nachfrage kommt auch nichts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
4.8 KiB
Go
145 lines
4.8 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))
|
||
|
||
// 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
|
||
// 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)
|
||
}
|