Manche Clients folgen dem 308-Redirect von /.well-known/caldav mit GET statt PROPFIND (curl -L reproduziert das exakt), landen dann auf einer Sammlung (Principal/Home-Set/Kalender) und bekamen dort bisher ein hartes 404 von GetCalendarObject – ein Client, der so die Erreichbarkeit prüft, bricht die Einrichtung dann vorzeitig ab, bevor er je ein PROPFIND schickt. GET/HEAD auf Nicht-.ics-Pfade antworten jetzt mit einer schlichten 200-Textantwort statt an den CalDAV-Backend durchgereicht zu werden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
3.4 KiB
Go
104 lines
3.4 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
|
||
dav *caldav.Handler
|
||
limiter *middleware.FailureLimiter
|
||
}
|
||
|
||
func NewHandler(authStore *auth.Store, store *Store) *Handler {
|
||
return &Handler{
|
||
authStore: authStore,
|
||
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
|
||
}
|
||
|
||
h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user)))
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
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)
|
||
}
|