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/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);
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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