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:
1 parent
e44bed0a34
commit
87788817f8
11 files changed
+819
-1
No files matched your search
@@ -26,6 +26,7 @@ import (
|
|||||||
"aether/internal/audit"
|
"aether/internal/audit"
|
||||||
"aether/internal/auth"
|
"aether/internal/auth"
|
||||||
"aether/internal/avatar"
|
"aether/internal/avatar"
|
||||||
|
"aether/internal/calendar"
|
||||||
"aether/internal/documents"
|
"aether/internal/documents"
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/gc"
|
"aether/internal/gc"
|
||||||
@@ -246,6 +247,10 @@ func main() {
|
|||||||
// WebDAV
|
// WebDAV
|
||||||
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
|
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)
|
// OIDC (optional – disabled if OIDC_ISSUER not set)
|
||||||
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
oidcHandler, err := appoidc.New(ctx, appoidc.Config{
|
||||||
Issuer: cfg.OIDCIssuer,
|
Issuer: cfg.OIDCIssuer,
|
||||||
@@ -306,7 +311,7 @@ func main() {
|
|||||||
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
// chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung
|
||||||
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
// beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der
|
||||||
// WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird.
|
// 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)
|
chi.RegisterMethod(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +357,12 @@ func main() {
|
|||||||
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
// Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404.
|
||||||
r.Mount("/dav", davHandler)
|
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
|
// 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) {
|
r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) {
|
||||||
cloudName := "Aether"
|
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);
|
||||||
@@ -4,6 +4,8 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0
|
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/chi/v5 v5.1.0
|
||||||
github.com/go-chi/httprate v0.15.0
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
@@ -27,6 +29,7 @@ require (
|
|||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // 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
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
|||||||
@@ -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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in new issue
Block a user