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

+12 -1
View File
@@ -26,6 +26,7 @@ import (
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/avatar"
"aether/internal/calendar"
"aether/internal/documents"
"aether/internal/files"
"aether/internal/gc"
@@ -246,6 +247,10 @@ func main() {
// WebDAV
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
// CalDAV
calStore := calendar.NewStore(pool)
calHandler := calendar.NewHandler(authStore, calStore)
// OIDC (optional – disabled if OIDC_ISSUER not set)
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
Issuer: cfg.OIDCIssuer,
@@ -306,7 +311,7 @@ func main() {
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK"} {
for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT", "MKCALENDAR"} {
chi.RegisterMethod(m)
}
@@ -352,6 +357,12 @@ func main() {
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
r.Mount("/dav", davHandler)
// CalDAV endpoint – eigene Basic Auth (App-Passwörter), wie /dav.
// .well-known/caldav liegt bewusst außerhalb von /caldav: RFC 6764 sieht
// diesen Pfad an der Wurzel vor, Clients wie DAVx5 fragen dort zuerst an.
r.Mount("/caldav", calHandler)
r.Get("/.well-known/caldav", calHandler.ServeWellKnown)
// Public config (no auth) – used by login page to discover OIDC availability
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
cloudName := "Aether"
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS cal_objects;
DROP TABLE IF EXISTS cal_calendars;
@@ -0,0 +1,52 @@
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
--
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS cal_calendars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
color TEXT NOT NULL DEFAULT '#4285f4',
description TEXT NOT NULL DEFAULT '',
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
-- einzelne Objekt abzufragen.
ctag BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cal_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
uid TEXT NOT NULL,
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
component TEXT NOT NULL,
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
data TEXT NOT NULL,
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
etag TEXT NOT NULL,
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
dtstart TIMESTAMPTZ,
dtend TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);
+3
View File
@@ -4,6 +4,8 @@ go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.19.0
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
github.com/emersion/go-webdav v0.7.0
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/httprate v0.15.0
github.com/golang-migrate/migrate/v4 v4.19.1
@@ -27,6 +29,7 @@ require (
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
+8
View File
@@ -22,6 +22,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
@@ -87,6 +93,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
+353
View File
@@ -0,0 +1,353 @@
package calendar
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/google/uuid"
"aether/internal/auth"
)
// Pfadschema. Zwei Eigenheiten der Bibliothek bestimmen es:
//
// 1. Zurückgegebene Pfade (CurrentUserPrincipal, CalendarHomeSetPath,
// Calendar.Path, ...) vergleicht sie direkt mit r.URL.Path – also
// inklusive Präfix. Deshalb tragen alle hier gebauten Pfade davPrefix
// von Anfang an mit.
//
// 2. Welche Art Ressource ein Pfad ist, entscheidet sie allein an der
// Anzahl der Pfadsegmente hinter dem Präfix (resourceTypeAtPath in
// server.go) – nicht am Namen. Ein Segment = Principal, zwei = Calendar-
// Home-Set, drei = Kalender, vier = Objekt. Deshalb steckt der
// Nutzername beim Principal *in* dem einen Segment ("principals-max"),
// nicht als eigenes Unterverzeichnis – sonst läse die Bibliothek ihn als
// Calendar-Home-Set und die PROPFIND-Antwort bliebe leer.
//
// /caldav/principals-{username}/ – 1 Segment
// /caldav/calendars/{username}/ – 2 Segmente, Calendar-Home-Set
// /caldav/calendars/{username}/{calendar-id}/ – 3 Segmente, ein Kalender
// /caldav/calendars/{username}/{calendar-id}/{uid}.ics – 4 Segmente, ein Objekt
const davPrefix = "/caldav"
type contextKey int
const keyUser contextKey = iota
func contextWithUser(ctx context.Context, u *auth.User) context.Context {
return context.WithValue(ctx, keyUser, u)
}
func userFromContext(ctx context.Context) (*auth.User, error) {
u, _ := ctx.Value(keyUser).(*auth.User)
if u == nil {
return nil, webdav.NewHTTPError(http.StatusUnauthorized, errors.New("no authenticated user"))
}
return u, nil
}
// Backend implementiert caldav.Backend. Phase 1: jeder Nutzer sieht nur die
// eigenen Kalender – Freigaben (cal_shares) kommen erst in einer späteren
// Phase, siehe kalender-plan.md.
type Backend struct {
store *Store
}
func NewBackend(store *Store) *Backend {
return &Backend{store: store}
}
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
u, err := userFromContext(ctx)
if err != nil {
return "", err
}
return davPrefix + "/principals-" + u.Username + "/", nil
}
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
u, err := userFromContext(ctx)
if err != nil {
return "", err
}
return davPrefix + "/calendars/" + u.Username + "/", nil
}
func calendarPath(username string, id uuid.UUID) string {
return davPrefix + "/calendars/" + username + "/" + id.String() + "/"
}
func objectPath(username string, id uuid.UUID, uid string) string {
return calendarPath(username, id) + uid + ".ics"
}
// parseCalendarPath erwartet "/caldav/calendars/{username}/{calendar-id}(/...)"
// und liefert die Kalender-ID. Der Benutzername im Pfad wird nicht gegen den
// angemeldeten Nutzer geprüft – Phase 1 kennt ohnehin nur den eigenen Pfad,
// spätere Freigaben ändern das gezielt in resolveCalendar.
func parseCalendarPath(p string) (calID uuid.UUID, rest string, err error) {
parts := strings.SplitN(strings.TrimPrefix(p, davPrefix+"/calendars/"), "/", 3)
if len(parts) < 2 || parts[1] == "" {
return uuid.UUID{}, "", fmt.Errorf("calendar: malformed path %q", p)
}
id, err := uuid.Parse(parts[1])
if err != nil {
return uuid.UUID{}, "", fmt.Errorf("calendar: malformed calendar id in %q: %w", p, err)
}
if len(parts) == 3 {
rest = parts[2]
}
return id, rest, nil
}
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Calendar, error) {
u, err := userFromContext(ctx)
if err != nil {
return nil, nil, err
}
calID, _, err := parseCalendarPath(p)
if err != nil {
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
cal, err := b.store.GetCalendar(ctx, u.ID, calID)
if errors.Is(err, ErrNotFound) {
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
if err != nil {
return nil, nil, err
}
return u, cal, nil
}
func toCalDAVCalendar(username string, c *Calendar) caldav.Calendar {
comps := []string{"VEVENT"}
if c.SupportsTodo {
comps = append(comps, "VTODO")
}
return caldav.Calendar{
Path: calendarPath(username, c.ID),
Name: c.Name,
Description: c.Description,
MaxResourceSize: 5 * 1024 * 1024,
SupportedComponentSet: comps,
}
}
func (b *Backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) error {
u, err := userFromContext(ctx)
if err != nil {
return err
}
name := cal.Name
if name == "" {
name = "Kalender"
}
created, err := b.store.CreateCalendar(ctx, u.ID, name, "", cal.Description)
if err != nil {
return fmt.Errorf("create calendar: %w", err)
}
cal.Path = calendarPath(u.Username, created.ID)
return nil
}
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
u, err := userFromContext(ctx)
if err != nil {
return nil, err
}
if _, err := b.store.EnsureDefaultCalendar(ctx, u.ID); err != nil {
return nil, fmt.Errorf("ensure default calendar: %w", err)
}
all, err := b.store.ListCalendars(ctx, u.ID)
if err != nil {
return nil, fmt.Errorf("list calendars: %w", err)
}
out := make([]caldav.Calendar, len(all))
for i, c := range all {
out[i] = toCalDAVCalendar(u.Username, &c)
}
return out, nil
}
func (b *Backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
c := toCalDAVCalendar(u.Username, cal)
return &c, nil
}
func toCalendarObject(username string, calID uuid.UUID, o *Object) (*caldav.CalendarObject, error) {
dec := ical.NewDecoder(strings.NewReader(o.Data))
data, err := dec.Decode()
if err != nil {
return nil, fmt.Errorf("decode object %s: %w", o.UID, err)
}
return &caldav.CalendarObject{
Path: objectPath(username, calID, o.UID),
ModTime: o.UpdatedAt,
ContentLength: int64(len(o.Data)),
ETag: o.ETag,
Data: data,
}, nil
}
func (b *Backend) GetCalendarObject(ctx context.Context, p string, _ *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
_, rest, err := parseCalendarPath(p)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
uid := strings.TrimSuffix(rest, ".ics")
obj, err := b.store.GetObjectByUID(ctx, cal.ID, uid)
if errors.Is(err, ErrNotFound) {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
if err != nil {
return nil, err
}
return toCalendarObject(u.Username, cal.ID, obj)
}
func (b *Backend) ListCalendarObjects(ctx context.Context, p string, _ *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
objs, err := b.store.ListObjects(ctx, cal.ID)
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
}
out := make([]caldav.CalendarObject, 0, len(objs))
for i := range objs {
co, err := toCalendarObject(u.Username, cal.ID, &objs[i])
if err != nil {
// Eine kaputte Einzeldatei darf nicht die gesamte Synchronisation
// blockieren; der Client bekommt die übrigen Termine trotzdem.
continue
}
out = append(out, *co)
}
return out, nil
}
func (b *Backend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
all, err := b.ListCalendarObjects(ctx, p, &query.CompRequest)
if err != nil {
return nil, err
}
return caldav.Filter(query, all)
}
// firstUID/firstComponent lesen UID und Komponententyp (VEVENT/VTODO) aus
// dem ersten Termin-/Aufgaben-Kind der .ics-Datei. VTIMEZONE-Kinder werden
// übersprungen, die tragen keine UID.
func firstUIDAndComponent(cal *ical.Calendar) (uid, component string, err error) {
for _, child := range cal.Children {
if child.Name != "VEVENT" && child.Name != "VTODO" {
continue
}
v, err := child.Props.Text("UID")
if err != nil || v == "" {
return "", "", fmt.Errorf("calendar object missing UID")
}
return v, child.Name, nil
}
return "", "", fmt.Errorf("calendar object has no VEVENT/VTODO component")
}
func timeRange(cal *ical.Calendar) (start, end *time.Time) {
for _, ev := range cal.Events() {
if t, err := ev.DateTimeStart(time.Local); err == nil {
start = &t
}
if t, err := ev.DateTimeEnd(time.Local); err == nil {
end = &t
}
break
}
return
}
func etagFor(data string) string {
sum := sha256.Sum256([]byte(data))
return hex.EncodeToString(sum[:])
}
func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
u, calRow, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
uid, component, err := firstUIDAndComponent(cal)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
}
existing, err := b.store.GetObjectByUID(ctx, calRow.ID, uid)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if opts != nil {
if opts.IfNoneMatch.IsSet() && opts.IfNoneMatch.IsWildcard() && existing != nil {
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object already exists"))
}
if opts.IfMatch.IsSet() {
if existing == nil {
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object not found"))
}
ok, err := opts.IfMatch.MatchETag(existing.ETag)
if err != nil || !ok {
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("etag mismatch"))
}
}
}
var buf bytes.Buffer
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
return nil, fmt.Errorf("encode object: %w", err)
}
data := buf.String()
etag := etagFor(data)
start, end := timeRange(cal)
obj, err := b.store.PutObject(ctx, calRow.ID, uid, component, data, etag, start, end)
if err != nil {
return nil, fmt.Errorf("put object: %w", err)
}
return toCalendarObject(u.Username, calRow.ID, obj)
}
func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error {
_, calRow, err := b.resolveCalendar(ctx, p)
if err != nil {
return err
}
_, rest, err := parseCalendarPath(p)
if err != nil {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
uid := strings.TrimSuffix(rest, ".ics")
if err := b.store.DeleteObject(ctx, calRow.ID, uid); err != nil {
if errors.Is(err, ErrNotFound) {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
return fmt.Errorf("delete object: %w", err)
}
return nil
}
+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)
}
+41
View File
@@ -0,0 +1,41 @@
// Package calendar ist ein eigenständiges Modul, kein Kernbestandteil:
// CalDAV für Termine und Aufgaben. Es importiert nichts aus files, sharing
// oder admin – nur auth und acl, wie jeder andere Bereich auch. Ist es
// abgeschaltet, gibt es keine Route und keinen Menüpunkt; die Tabellen
// bleiben dann einfach ungenutzt liegen. Siehe kalender-plan.md.
package calendar
import (
"time"
"github.com/google/uuid"
)
// Calendar ist eine Sammlung von Terminen/Aufgaben – das, was ein Client als
// einen Kalender in seiner Liste zeigt.
type Calendar struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"owner_id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
SupportsTodo bool `json:"supports_todo"`
CTag int64 `json:"ctag"`
CreatedAt time.Time `json:"created_at"`
}
// Object ist ein einzelner Termin oder eine Aufgabe. Data ist die
// vollständige .ics-Datei, unverändert wie vom Client geschickt – siehe
// kalender-plan.md, Abschnitt "Felder".
type Object struct {
ID uuid.UUID `json:"id"`
CalendarID uuid.UUID `json:"calendar_id"`
UID string `json:"uid"`
Component string `json:"component"` // "VEVENT" | "VTODO"
Data string `json:"-"`
ETag string `json:"etag"`
DTStart *time.Time `json:"dtstart,omitempty"`
DTEnd *time.Time `json:"dtend,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+208
View File
@@ -0,0 +1,208 @@
package calendar
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("calendar: not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
rows, err := s.db.Query(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, ctag, created_at
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
ownerID,
)
if err != nil {
return nil, fmt.Errorf("query calendars: %w", err)
}
defer rows.Close()
var out []Calendar
for rows.Next() {
var c Calendar
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.CTag, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan calendar: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, ctag, created_at
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
ownerID, id,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.CTag, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("query calendar: %w", err)
}
return &c, nil
}
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
// CalDAV-Client kann das.
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
cals, err := s.ListCalendars(ctx, ownerID)
if err != nil {
return nil, err
}
if len(cals) > 0 {
return &cals[0], nil
}
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "")
}
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
if color == "" {
color = "#4285f4"
}
var c Calendar
err := s.db.QueryRow(ctx,
`INSERT INTO cal_calendars (owner_id, name, color, description)
VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, name, color, description, supports_todo, ctag, created_at`,
ownerID, name, color, description,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.CTag, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert calendar: %w", err)
}
return &c, nil
}
func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error {
tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id)
if err != nil {
return fmt.Errorf("delete calendar: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) {
rows, err := s.db.Query(ctx,
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
FROM cal_objects WHERE calendar_id = $1 ORDER BY uid`,
calendarID,
)
if err != nil {
return nil, fmt.Errorf("query objects: %w", err)
}
defer rows.Close()
var out []Object
for rows.Next() {
o, err := scanObject(rows)
if err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) {
row := s.db.QueryRow(ctx,
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
FROM cal_objects WHERE calendar_id = $1 AND uid = $2`,
calendarID, uid,
)
o, err := scanObject(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &o, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanObject(row rowScanner) (Object, error) {
var o Object
err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return Object{}, fmt.Errorf("scan object: %w", err)
}
return o, nil
}
// PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben
// Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der
// Aufrufer vorher – hier zählt nur noch das Schreiben selbst.
func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
var o Object
err = tx.QueryRow(ctx,
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (calendar_id, uid) DO UPDATE
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now()
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
calendarID, uid, component, data, etag, dtstart, dtend,
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert object: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return nil, fmt.Errorf("bump ctag: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit tx: %w", err)
}
return &o, nil
}
func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid)
if err != nil {
return fmt.Errorf("delete object: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return fmt.Errorf("bump ctag: %w", err)
}
return tx.Commit(ctx)
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS cal_objects;
DROP TABLE IF EXISTS cal_calendars;
@@ -0,0 +1,52 @@
-- Kalender, Phase 1: ein Kalender pro Nutzer, CalDAV lesend und schreibend.
--
-- Nur CREATE TABLE, nichts an bestehenden Tabellen geändert. Siehe
-- kalender-plan.md, Abschnitt "Migrationen": eine fehlschlagende Migration
-- verhindert den Start der ganzen Cloud, deshalb hier ausschließlich die
-- risikoärmste Operation, die es gibt – idempotent mit IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS cal_calendars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- calendar-color (Apple-Namensraum, kein RFC, aber von Nextcloud,
-- Thunderbird und DAVx5 unterstützt) – siehe kalender-plan.md.
color TEXT NOT NULL DEFAULT '#4285f4',
description TEXT NOT NULL DEFAULT '',
-- Meldet der Kalender VTODO von Anfang an, muss ein Client sein Konto
-- nicht neu einrichten, wenn Aufgaben später dazukommen.
supports_todo BOOLEAN NOT NULL DEFAULT TRUE,
-- Aendert sich bei jeder Aenderung an einem Termin dieses Kalenders.
-- Daran erkennt der Client "hier gibt es etwas Neues", ohne jedes
-- einzelne Objekt abzufragen.
ctag BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cal_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
-- UID aus der .ics-Datei – der Ausweis des Termins, bleibt beim
-- Verschieben/Aendern gleich. Eindeutig je Kalender, nicht global.
uid TEXT NOT NULL,
-- 'VEVENT' oder 'VTODO'. Fuer die Ansicht spaeter, nicht fuer CalDAV
-- selbst - der Client fragt nach Zeitraum, nicht nach Typ.
component TEXT NOT NULL,
-- Das vollstaendige .ics, byte-genau wie vom Client geschickt. Siehe
-- kalender-plan.md: nie zerlegen und neu zusammensetzen, sonst gehen
-- Felder verloren, die der Server nicht kennt (Anhaenge, Herstellerfelder).
data TEXT NOT NULL,
-- Aendert sich bei jedem Schreiben. Clients schicken es beim Aendern
-- mit ("nur wenn es noch das hier ist") - das loest gleichzeitiges
-- Schreiben zweier Geraete/Nutzer ohne zusaetzlichen Code.
etag TEXT NOT NULL,
-- Abgeleitet aus DTSTART/DTEND, nur zum Vorsortieren. Massgeblich fuer
-- den Zeitraumfilter bleibt "data" selbst (RRULE eingeschlossen).
dtstart TIMESTAMPTZ,
dtend TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (calendar_id, uid)
);
CREATE INDEX IF NOT EXISTS cal_objects_calendar_idx ON cal_objects(calendar_id);