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>
This commit is contained in:
marcandClaude Sonnet 5 committed 2026-09-03 22:27:33 +02:00
1 parent e44bed0a34
commit 87788817f8
11 files changed
+819 -1

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
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)
}