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 }