Files
Aether/backend/internal/calendar/handler.go
T
marcandClaude Sonnet 5 87788817f8 feat(calendar): Phase 1 – CalDAV lesend und schreibend, ein Kalender pro Nutzer
Neues, eigenständiges Modul internal/calendar: implementiert
caldav.Backend (emersion/go-webdav) gegen zwei neue Tabellen
(cal_calendars, cal_objects). Endpunkt /caldav/ mit eigener Basic Auth
(nur App-Passwörter, wie /dav), plus /.well-known/caldav für die
automatische Einrichtung nach RFC 6764.

Gegen echte Container getestet (nicht nur go build): Discovery-Kette
Principal -> Home-Set -> automatisch angelegter Standardkalender,
PUT/GET/DELETE eines Termins, REPORT mit Zeitraumfilter (Treffer und
Nicht-Treffer), If-Match/If-None-Match-Vorbedingungen (412), und dass
ein fremder Nutzer auf einen Kalender-Pfad mit 404 statt 403 abgewiesen
wird.

Zwei Fallen unterwegs gefunden und dokumentiert:
- go-webdav vergleicht von CurrentUserPrincipal/CalendarHomeSetPath
  zurückgegebene Pfade direkt gegen r.URL.Path (inklusive Prefix) und
  bestimmt den Ressourcentyp allein an der Segmentzahl dahinter – der
  Nutzername beim Principal steht deshalb in einem Segment
  (principals-max), nicht als Unterpfad.
- chi kennt REPORT/MKCALENDAR nicht von Haus aus (nur PROPFIND & Co.
  waren für /dav bereits registriert) und hätte sie sonst mit 405
  abgewiesen, bevor der CalDAV-Handler überhaupt lief.

Migration 000021 nur mit CREATE TABLE IF NOT EXISTS, wie in
kalender-plan.md festgelegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:27:33 +02:00

87 lines
2.6 KiB
Go
Raw 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 (
"net/http"
"strconv"
"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)
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)
}