From 87788817f844c83ec8cc8b500e0ae7efac2c5e70 Mon Sep 17 00:00:00 2001 From: marc Date: Thu, 3 Sep 2026 22:27:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20Phase=201=20=E2=80=93=20CalDA?= =?UTF-8?q?V=20lesend=20und=20schreibend,=20ein=20Kalender=20pro=20Nutzer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/cmd/aether/main.go | 13 +- .../db/migrations/000021_calendar.down.sql | 2 + backend/db/migrations/000021_calendar.up.sql | 52 +++ backend/go.mod | 3 + backend/go.sum | 8 + backend/internal/calendar/backend.go | 353 ++++++++++++++++++ backend/internal/calendar/handler.go | 86 +++++ backend/internal/calendar/model.go | 41 ++ backend/internal/calendar/store.go | 208 +++++++++++ .../db/migrations/000021_calendar.down.sql | 2 + .../pkg/db/migrations/000021_calendar.up.sql | 52 +++ 11 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 backend/db/migrations/000021_calendar.down.sql create mode 100644 backend/db/migrations/000021_calendar.up.sql create mode 100644 backend/internal/calendar/backend.go create mode 100644 backend/internal/calendar/handler.go create mode 100644 backend/internal/calendar/model.go create mode 100644 backend/internal/calendar/store.go create mode 100644 backend/pkg/db/migrations/000021_calendar.down.sql create mode 100644 backend/pkg/db/migrations/000021_calendar.up.sql diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 09e6bc2..86de48c 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -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" diff --git a/backend/db/migrations/000021_calendar.down.sql b/backend/db/migrations/000021_calendar.down.sql new file mode 100644 index 0000000..5c7d519 --- /dev/null +++ b/backend/db/migrations/000021_calendar.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS cal_objects; +DROP TABLE IF EXISTS cal_calendars; diff --git a/backend/db/migrations/000021_calendar.up.sql b/backend/db/migrations/000021_calendar.up.sql new file mode 100644 index 0000000..6ca2e5c --- /dev/null +++ b/backend/db/migrations/000021_calendar.up.sql @@ -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); diff --git a/backend/go.mod b/backend/go.mod index df2cf8a..4275b9c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 74c265b..a9b09d8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/calendar/backend.go b/backend/internal/calendar/backend.go new file mode 100644 index 0000000..1b57330 --- /dev/null +++ b/backend/internal/calendar/backend.go @@ -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 +} diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go new file mode 100644 index 0000000..3b34e76 --- /dev/null +++ b/backend/internal/calendar/handler.go @@ -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) +} diff --git a/backend/internal/calendar/model.go b/backend/internal/calendar/model.go new file mode 100644 index 0000000..3c58307 --- /dev/null +++ b/backend/internal/calendar/model.go @@ -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"` +} diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go new file mode 100644 index 0000000..c17d4af --- /dev/null +++ b/backend/internal/calendar/store.go @@ -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) +} diff --git a/backend/pkg/db/migrations/000021_calendar.down.sql b/backend/pkg/db/migrations/000021_calendar.down.sql new file mode 100644 index 0000000..5c7d519 --- /dev/null +++ b/backend/pkg/db/migrations/000021_calendar.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS cal_objects; +DROP TABLE IF EXISTS cal_calendars; diff --git a/backend/pkg/db/migrations/000021_calendar.up.sql b/backend/pkg/db/migrations/000021_calendar.up.sql new file mode 100644 index 0000000..6ca2e5c --- /dev/null +++ b/backend/pkg/db/migrations/000021_calendar.up.sql @@ -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);