Files
Aether/backend/internal/calendar/backend.go
T
marcandClaude Sonnet 5 d6e1854f1b fix(calendar): klare Fehlermeldung für GET auf CalDAV-Sammlungen
go-webdav leitet GET/HEAD auf jeden Pfad unverändert an
GetCalendarObject weiter, auch auf Principal- oder Home-Set-Pfade (ein
Browser kann nur GET, kein PROPFIND). Ohne Prüfung hielt
parseCalendarPath dann z.B. das Wort 'caldav' aus dem Pfad für eine
Kalender-ID und gab einen internen UUID-Parsing-Fehler nach außen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:45:24 +02:00

364 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) {
// 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 uuid.UUID{}, "", fmt.Errorf("calendar: not a calendar path: %q", p)
}
parts := strings.SplitN(strings.TrimPrefix(p, calPrefix), "/", 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
}