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>
364 lines
11 KiB
Go
364 lines
11 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"
|
||
"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
|
||
}
|