Files
Aether/backend/internal/calendar/backend.go
T
marcandClaude Sonnet 5 87788817f8 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>
2026-09-03 22:27:33 +02:00

354 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}