Bisher konnte ein CalDAV-Client Termine verwalten, aber keine Kalender:
MKCALENDAR -> 405 anlegen
PROPPATCH -> 501 umbenennen, umfaerben
DELETE -> 404 loeschen
go-webdav deckt davon nur MKCOL ab, und selbst da verwarf unser Backend
die vom Client gewaehlte Adresse. Alle drei Verben faengt der Handler
jetzt vorher ab (collections.go).
Der Kern steckt aber nicht in den Verben, sondern in der Adresse: Bei
MKCALENDAR sucht *der Client* aus, wo der Kalender liegen soll
("…/calendars/max/arbeit/"). Unsere Kalender lagen bisher zwingend unter
ihrer UUID -- der Client haette angelegt und auf seine eigene Adresse
sofort ein 404 bekommen.
Deshalb Migration 23 mit einer Spalte path_segment: gesetzt = die
gewaehlte Adresse, NULL = es gilt weiterhin die UUID. Bestehende
Kalender bleiben damit unter genau der Adresse erreichbar, unter der die
eingerichteten Geraete sie kennen -- ein Wechsel haette jedes Telefon
dazu gebracht, seine Termine fuer geloescht zu halten. Aufgeloest wird
beides in einer Abfrage (GetCalendarByRef), die gewaehlte Adresse hat
Vorrang.
PROPPATCH antwortet mit einem Status je Eigenschaft, auch wenn alles
geklappt hat: Es kann teilweise gelingen, und ein nacktes 200
verschwiege, welcher Teil angekommen ist. Was wir nicht aendern koennen,
bekommt 403 statt stillem Schlucken -- ein Client, der seine Aenderung
fuer angekommen haelt, ist schlimmer dran als einer mit klarer Absage.
Ebenso eine unlesbare Farbe: absagen statt raten.
DELETE auf eine Sammlung loescht den Kalender mitsamt allen Terminen --
das ist in WebDAV seine Bedeutung.
CalDAV-Selbsttest von 45 auf 62 Pruefungen: anlegen, unter der
gewaehlten Adresse erreichbar, Farbe aus dem MKCALENDAR uebernommen,
Termin darin ablegen und abrufen, zweites Anlegen auf dieselbe Adresse
405, umbenennen und umfaerben mit Status je Eigenschaft, loeschen, danach
404 -- und dasselbe noch einmal ueber MKCOL, das Verb, das DAVx5 nimmt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
5.1 KiB
Go
158 lines
5.1 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 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)
|
||
}
|