Bisher konnte ein CalDAV-Client Termine verwalten, aber keine Kalender:
MKCALENDAR -> 405 anlegen
PROPPATCH -> 501 umbenennen, umfaerben
DELETE -> 404 loeschen
go-webdav deckt davon nur MKCOL ab, und selbst da verwarf unser Backend
die vom Client gewaehlte Adresse. Alle drei Verben faengt der Handler
jetzt vorher ab (collections.go).
Der Kern steckt aber nicht in den Verben, sondern in der Adresse: Bei
MKCALENDAR sucht *der Client* aus, wo der Kalender liegen soll
("…/calendars/max/arbeit/"). Unsere Kalender lagen bisher zwingend unter
ihrer UUID -- der Client haette angelegt und auf seine eigene Adresse
sofort ein 404 bekommen.
Deshalb Migration 23 mit einer Spalte path_segment: gesetzt = die
gewaehlte Adresse, NULL = es gilt weiterhin die UUID. Bestehende
Kalender bleiben damit unter genau der Adresse erreichbar, unter der die
eingerichteten Geraete sie kennen -- ein Wechsel haette jedes Telefon
dazu gebracht, seine Termine fuer geloescht zu halten. Aufgeloest wird
beides in einer Abfrage (GetCalendarByRef), die gewaehlte Adresse hat
Vorrang.
PROPPATCH antwortet mit einem Status je Eigenschaft, auch wenn alles
geklappt hat: Es kann teilweise gelingen, und ein nacktes 200
verschwiege, welcher Teil angekommen ist. Was wir nicht aendern koennen,
bekommt 403 statt stillem Schlucken -- ein Client, der seine Aenderung
fuer angekommen haelt, ist schlimmer dran als einer mit klarer Absage.
Ebenso eine unlesbare Farbe: absagen statt raten.
DELETE auf eine Sammlung loescht den Kalender mitsamt allen Terminen --
das ist in WebDAV seine Bedeutung.
CalDAV-Selbsttest von 45 auf 62 Pruefungen: anlegen, unter der
gewaehlten Adresse erreichbar, Farbe aus dem MKCALENDAR uebernommen,
Termin darin ablegen und abrufen, zweites Anlegen auf dieselbe Adresse
405, umbenennen und umfaerben mit Status je Eigenschaft, loeschen, danach
404 -- und dasselbe noch einmal ueber MKCOL, das Verb, das DAVx5 nimmt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
12 KiB
Go
386 lines
12 KiB
Go
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"
|
||
|
||
"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
|
||
}
|
||
|
||
// calendarPath baut die Adresse eines Kalenders. ref ist sein Pfadsegment –
|
||
// die vom Client gewählte Adresse, sonst die UUID (siehe Calendar.Ref).
|
||
func calendarPath(username, ref string) string {
|
||
return davPrefix + "/calendars/" + username + "/" + ref + "/"
|
||
}
|
||
|
||
func objectPath(username, ref, uid string) string {
|
||
return calendarPath(username, ref) + uid + ".ics"
|
||
}
|
||
|
||
// parseCalendarPath erwartet "/caldav/calendars/{username}/{segment}(/...)"
|
||
// und liefert das Pfadsegment des Kalenders – nicht dessen UUID: Legt ein
|
||
// Client per MKCALENDAR einen Kalender an, sucht **er** die Adresse aus
|
||
// ("…/calendars/max/arbeit/"). Aufgelöst wird sie in resolveCalendar.
|
||
//
|
||
// Der Benutzername im Pfad wird hier nicht geprüft; das tut der Handler,
|
||
// bevor die Anfrage überhaupt hier ankommt (pathBelongsTo).
|
||
func parseCalendarPath(p string) (ref string, rest string, err error) {
|
||
// Kommt vor: ein Browser macht GET auf eine Sammlung (Principal,
|
||
// Home-Set) statt PROPFIND – go-webdav reicht das trotzdem unverändert
|
||
// an GetCalendarObject durch, siehe backend.go-Kommentar zum Pfadschema.
|
||
// Ohne diese Prüfung würde z. B. "/caldav/principals-max/" das Wort
|
||
// "caldav" für eine Kalender-ID halten und einen kryptischen
|
||
// UUID-Parsing-Fehler nach außen geben.
|
||
const calPrefix = davPrefix + "/calendars/"
|
||
if !strings.HasPrefix(p, calPrefix) {
|
||
return "", "", fmt.Errorf("calendar: not a calendar path: %q", p)
|
||
}
|
||
parts := strings.SplitN(strings.TrimPrefix(p, calPrefix), "/", 3)
|
||
if len(parts) < 2 || parts[1] == "" {
|
||
return "", "", fmt.Errorf("calendar: malformed path %q", p)
|
||
}
|
||
if len(parts) == 3 {
|
||
rest = parts[2]
|
||
}
|
||
return parts[1], 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
|
||
}
|
||
ref, _, err := parseCalendarPath(p)
|
||
if err != nil {
|
||
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
|
||
}
|
||
cal, err := b.store.GetCalendarByRef(ctx, u.ID, ref)
|
||
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.Ref()),
|
||
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"
|
||
}
|
||
// Die Adresse sucht der Client aus (MKCOL/MKCALENDAR). Sie zu ersetzen
|
||
// wäre der eine Fehler, den man hier machen kann: Der Client legt an
|
||
// unter "…/arbeit/", fragt gleich darauf dieselbe Adresse ab – und bekäme
|
||
// ein 404, weil der Kalender in Wahrheit unter seiner UUID läge.
|
||
ref, rest, err := parseCalendarPath(cal.Path)
|
||
if err != nil || rest != "" {
|
||
return webdav.NewHTTPError(http.StatusForbidden,
|
||
fmt.Errorf("calendar: cannot create a calendar at %q", cal.Path))
|
||
}
|
||
created, err := b.store.CreateCalendarAt(ctx, u.ID, ref, name, "", cal.Description)
|
||
if errors.Is(err, ErrConflict) {
|
||
// RFC 4918: Anlegen, wo schon etwas liegt, ist 405 – nicht stilles
|
||
// Überschreiben.
|
||
return webdav.NewHTTPError(http.StatusMethodNotAllowed,
|
||
fmt.Errorf("calendar: %q already exists", ref))
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("create calendar: %w", err)
|
||
}
|
||
cal.Path = calendarPath(u.Username, created.Ref())
|
||
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, calRef string, 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, calRef, 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.Ref(), 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.Ref(), &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 encodeICal(cal *ical.Calendar) (string, error) {
|
||
var buf bytes.Buffer
|
||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||
return "", fmt.Errorf("encode object: %w", err)
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
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"))
|
||
}
|
||
}
|
||
}
|
||
|
||
data, err := encodeICal(cal)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
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.Ref(), 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
|
||
}
|