diff --git a/TESTING.md b/TESTING.md index d68273e..2898e5a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -41,7 +41,7 @@ anderen. python3 scripts/caldav-selbsttest.py -45 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden +62 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden abgewiesen – für CalDAV gelten nur App-Passwörter), Auffinden über `.well-known/caldav` samt der Umleitung, der Clients mit GET folgen, Kalenderliste mit Anzeigename, Komponenten, `getctag` und @@ -50,12 +50,28 @@ muss 412 geben), `calendar-query` mit Zeitfenster und `calendar-multiget`, beide Richtungen zwischen Web und CalDAV (Ganztagstermin als reines Datum mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE, EXDATE), das Umhängen aus Client-Sicht (Datei verschwindet in der Quelle, taucht im Ziel -auf, UID bleibt) und dass ein fremder Benutzerpfad 404 gibt statt eines +auf, UID bleibt), Kalender vom Client aus verwalten (MKCALENDAR und der +erweiterte MKCOL, PROPPATCH zum Umbenennen und Umfärben, DELETE der +ganzen Sammlung) und dass ein fremder Benutzerpfad 404 gibt statt eines leeren 207. Legt sich ein eigenes App-Passwort und zwei eigene Kalender an und räumt beides am Ende weg; vorhandene Kalender werden nur gelesen. +### Kalender verwalten: Adresse wählt der Client + +Bei MKCALENDAR sucht **der Client** die Adresse aus (`…/calendars/max/arbeit/`). +Deshalb hat `cal_calendars` seit Migration 23 eine Spalte `path_segment`: +gesetzt = die gewählte Adresse, leer = es gilt weiterhin die UUID. Alle vor +der Migration angelegten Kalender bleiben damit unter genau der Adresse +erreichbar, unter der eingerichtete Geräte sie schon kennen – das ist der +Punkt, den ein Test hier zuerst prüfen sollte. + +`go-webdav` kann davon nur MKCOL. MKCALENDAR kennt es nicht (405), PROPPATCH +beantwortet es mit 501, und ein DELETE auf einen Kalender reicht es an das +Löschen eines *Termins* weiter, was im 404 endet. Alle drei fängt Aether +deshalb vorher ab (`backend/internal/calendar/collections.go`). + ### Zwei Eigenschaften kommen nicht aus der Bibliothek `go-webdav` v0.7.0 beantwortet in PROPFIND einen festen Satz Eigenschaften diff --git a/backend/db/migrations/000023_calendar_path_segment.down.sql b/backend/db/migrations/000023_calendar_path_segment.down.sql new file mode 100644 index 0000000..217ff6d --- /dev/null +++ b/backend/db/migrations/000023_calendar_path_segment.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS cal_calendars_owner_path_segment; +ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment; diff --git a/backend/db/migrations/000023_calendar_path_segment.up.sql b/backend/db/migrations/000023_calendar_path_segment.up.sql new file mode 100644 index 0000000..a96fb41 --- /dev/null +++ b/backend/db/migrations/000023_calendar_path_segment.up.sql @@ -0,0 +1,15 @@ +-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus +-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer +-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen +-- und der Client bekäme auf seine eigene Adresse ein 404. +-- +-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle +-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar, +-- unter der die eingerichteten Geräte sie schon kennen. +ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT; + +-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe +-- Adresse haben, aber beliebig viele dürfen gar keine haben. +CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment + ON cal_calendars (owner_id, path_segment) + WHERE path_segment IS NOT NULL; diff --git a/backend/internal/calendar/backend.go b/backend/internal/calendar/backend.go index ee78981..13c6534 100644 --- a/backend/internal/calendar/backend.go +++ b/backend/internal/calendar/backend.go @@ -14,7 +14,6 @@ import ( ical "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" - "github.com/google/uuid" "aether/internal/auth" ) @@ -83,19 +82,24 @@ func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) { return davPrefix + "/calendars/" + u.Username + "/", nil } -func calendarPath(username string, id uuid.UUID) string { - return davPrefix + "/calendars/" + username + "/" + id.String() + "/" +// 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 string, id uuid.UUID, uid string) string { - return calendarPath(username, id) + uid + ".ics" +func objectPath(username, ref, uid string) string { + return calendarPath(username, ref) + 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) { +// 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. @@ -104,20 +108,16 @@ func parseCalendarPath(p string) (calID uuid.UUID, rest string, err error) { // 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) + 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 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) + return "", "", fmt.Errorf("calendar: malformed path %q", p) } if len(parts) == 3 { rest = parts[2] } - return id, rest, nil + return parts[1], rest, nil } func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Calendar, error) { @@ -125,11 +125,11 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C if err != nil { return nil, nil, err } - calID, _, err := parseCalendarPath(p) + ref, _, err := parseCalendarPath(p) if err != nil { return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err) } - cal, err := b.store.GetCalendar(ctx, u.ID, calID) + cal, err := b.store.GetCalendarByRef(ctx, u.ID, ref) if errors.Is(err, ErrNotFound) { return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err) } @@ -145,7 +145,7 @@ func toCalDAVCalendar(username string, c *Calendar) caldav.Calendar { comps = append(comps, "VTODO") } return caldav.Calendar{ - Path: calendarPath(username, c.ID), + Path: calendarPath(username, c.Ref()), Name: c.Name, Description: c.Description, MaxResourceSize: 5 * 1024 * 1024, @@ -162,11 +162,26 @@ func (b *Backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) erro if name == "" { name = "Kalender" } - created, err := b.store.CreateCalendar(ctx, u.ID, name, "", cal.Description) + // 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.ID) + cal.Path = calendarPath(u.Username, created.Ref()) return nil } @@ -199,14 +214,14 @@ func (b *Backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, return &c, nil } -func toCalendarObject(username string, calID uuid.UUID, o *Object) (*caldav.CalendarObject, error) { +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, calID, o.UID), + Path: objectPath(username, calRef, o.UID), ModTime: o.UpdatedAt, ContentLength: int64(len(o.Data)), ETag: o.ETag, @@ -231,7 +246,7 @@ func (b *Backend) GetCalendarObject(ctx context.Context, p string, _ *caldav.Cal if err != nil { return nil, err } - return toCalendarObject(u.Username, cal.ID, obj) + return toCalendarObject(u.Username, cal.Ref(), obj) } func (b *Backend) ListCalendarObjects(ctx context.Context, p string, _ *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { @@ -245,7 +260,7 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, p string, _ *caldav.C } out := make([]caldav.CalendarObject, 0, len(objs)) for i := range objs { - co, err := toCalendarObject(u.Username, cal.ID, &objs[i]) + 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. @@ -347,7 +362,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Cal if err != nil { return nil, fmt.Errorf("put object: %w", err) } - return toCalendarObject(u.Username, calRow.ID, obj) + return toCalendarObject(u.Username, calRow.Ref(), obj) } func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error { diff --git a/backend/internal/calendar/collections.go b/backend/internal/calendar/collections.go new file mode 100644 index 0000000..facbee4 --- /dev/null +++ b/backend/internal/calendar/collections.go @@ -0,0 +1,274 @@ +package calendar + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "aether/internal/auth" +) + +// Kalender verwalten – anlegen, umbenennen, umfärben, löschen –, und zwar +// vom Client aus. +// +// go-webdav deckt davon nur MKCOL ab. MKCALENDAR (RFC 4791, das Verb, das +// Apple-Clients benutzen) kennt es gar nicht, PROPPATCH beantwortet es mit +// 501, und ein DELETE auf einen Kalender reicht es an das Löschen eines +// *Termins* weiter – das findet dort natürlich nichts und endet im 404. +// +// Alle drei landen deshalb hier, bevor die Bibliothek sie zu sehen bekommt. + +// Eigenschaften, die ein Client an einem Kalender ändern darf. Alles andere +// wird höflich mit 403 beantwortet, statt es stillschweigend zu schlucken: +// Ein Client, der glaubt, seine Änderung sei angekommen, ist schlimmer dran +// als einer, der eine klare Absage bekommt. +var ( + propDisplayName = xml.Name{Space: "DAV:", Local: "displayname"} + propCalColor = xml.Name{Space: nsApple, Local: "calendar-color"} + propCalDesc = xml.Name{Space: "urn:ietf:params:xml:ns:caldav", Local: "calendar-description"} +) + +// mkcalendarReq deckt MKCALENDAR und den erweiterten MKCOL ab: Beide tragen +// die Anfangswerte in . +type mkcalendarReq struct { + Sets []struct { + Prop struct { + DisplayName string `xml:"DAV: displayname"` + Color string `xml:"http://apple.com/ns/ical/ calendar-color"` + Description string `xml:"urn:ietf:params:xml:ns:caldav calendar-description"` + } `xml:"prop"` + } `xml:"set"` +} + +// handleMkcalendar legt einen Kalender unter der Adresse an, die der Client +// gewählt hat. +func (h *Handler) handleMkcalendar(w http.ResponseWriter, r *http.Request, user *auth.User) { + ref, rest, err := parseCalendarPath(r.URL.Path) + if err != nil || rest != "" { + http.Error(w, "calendars can only be created directly in the calendar home set", + http.StatusForbidden) + return + } + + var req mkcalendarReq + body, _ := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody)) + if len(bytes.TrimSpace(body)) > 0 { + if err := xml.Unmarshal(body, &req); err != nil { + http.Error(w, "malformed MKCALENDAR body", http.StatusBadRequest) + return + } + } + var name, color, desc string + for _, set := range req.Sets { + if set.Prop.DisplayName != "" { + name = set.Prop.DisplayName + } + if set.Prop.Color != "" { + color = set.Prop.Color + } + if set.Prop.Description != "" { + desc = set.Prop.Description + } + } + if strings.TrimSpace(name) == "" { + // Ohne Namen wäre der Kalender in der Oberfläche eine leere Zeile. + // Die gewählte Adresse ist der beste Anhaltspunkt, den es gibt. + name = ref + } + + created, err := h.store.CreateCalendarAt(r.Context(), user.ID, ref, name, fromAppleColor(color), desc) + if errors.Is(err, ErrConflict) { + http.Error(w, "a calendar already exists at this address", http.StatusMethodNotAllowed) + return + } + if err != nil { + http.Error(w, "could not create calendar", http.StatusInternalServerError) + return + } + + w.Header().Set("Location", calendarPath(user.Username, created.Ref())) + w.WriteHeader(http.StatusCreated) +} + +// handleProppatch ändert Name, Farbe und Beschreibung eines Kalenders. +// +// Die Antwort ist immer ein 207 mit einem Status **je Eigenschaft** – auch +// wenn alles geklappt hat. Ein nacktes 200 wäre hier falsch: PROPPATCH kann +// teilweise gelingen, und der Client will wissen, welcher Teil. +func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request, user *auth.User) { + ref, rest, err := parseCalendarPath(r.URL.Path) + if err != nil || rest != "" { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + body, _ := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody)) + gesetzt, abgelehnt, err := parseProppatch(body) + if err != nil { + http.Error(w, "malformed PROPPATCH body", http.StatusBadRequest) + return + } + + var name, color, desc *string + for n, v := range gesetzt { + switch n { + case propDisplayName: + s := v + name = &s + case propCalColor: + s := fromAppleColor(v) + if s == "" { + // Unlesbare Farbe: nicht raten, sondern absagen. + abgelehnt = append(abgelehnt, n) + continue + } + color = &s + case propCalDesc: + s := v + desc = &s + default: + abgelehnt = append(abgelehnt, n) + } + } + + if name != nil || color != nil || desc != nil { + if _, err := h.store.UpdateCalendar(r.Context(), user.ID, cal.ID, name, color, desc, nil); err != nil { + http.Error(w, "could not update calendar", http.StatusInternalServerError) + return + } + } + + var b strings.Builder + b.WriteString(`` + + `` + xmlText(r.URL.Path) + ``) + writePropstat := func(namen []xml.Name, status string) { + if len(namen) == 0 { + return + } + b.WriteString("") + for _, n := range namen { + if n.Space == "DAV:" { + fmt.Fprintf(&b, "<%s/>", xmlName(n.Local)) + } else { + fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space)) + } + } + b.WriteString("" + status + "") + } + var ok []xml.Name + for n := range gesetzt { + if !enthalten(abgelehnt, n) { + ok = append(ok, n) + } + } + writePropstat(ok, "HTTP/1.1 200 OK") + writePropstat(abgelehnt, "HTTP/1.1 403 Forbidden") + b.WriteString("") + + w.Header().Set("Content-Type", "text/xml; charset=utf-8") + w.WriteHeader(http.StatusMultiStatus) + io.WriteString(w, b.String()) +} + +func enthalten(list []xml.Name, n xml.Name) bool { + for _, x := range list { + if x == n { + return true + } + } + return false +} + +// parseProppatch liest und eines propertyupdate. +// +// Entfernen wird nicht unterstützt: Ein Kalender ohne Namen oder ohne Farbe +// hat keine sinnvolle Entsprechung – die Felder haben immer einen Wert. +// Solche Eigenschaften landen unter „abgelehnt". +func parseProppatch(body []byte) (gesetzt map[xml.Name]string, abgelehnt []xml.Name, err error) { + gesetzt = map[xml.Name]string{} + dec := xml.NewDecoder(bytes.NewReader(body)) + + var modus string // "set" | "remove" | "" + inProp := false + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, err + } + switch t := tok.(type) { + case xml.StartElement: + switch { + case t.Name.Space == "DAV:" && (t.Name.Local == "set" || t.Name.Local == "remove"): + modus = t.Name.Local + case t.Name.Space == "DAV:" && t.Name.Local == "prop": + inProp = true + case inProp && modus == "set": + var wert string + if err := dec.DecodeElement(&wert, &t); err != nil { + return nil, nil, err + } + gesetzt[t.Name] = strings.TrimSpace(wert) + case inProp && modus == "remove": + abgelehnt = append(abgelehnt, t.Name) + if err := dec.Skip(); err != nil { + return nil, nil, err + } + } + case xml.EndElement: + if t.Name.Space == "DAV:" && t.Name.Local == "prop" { + inProp = false + } + } + } + return gesetzt, abgelehnt, nil +} + +// handleDeleteCalendar löscht einen Kalender **mitsamt allen Terminen** – +// das ist in WebDAV die Bedeutung eines DELETE auf eine Sammlung. +func (h *Handler) handleDeleteCalendar(w http.ResponseWriter, r *http.Request, user *auth.User) bool { + ref, rest, err := parseCalendarPath(r.URL.Path) + if err != nil || rest != "" { + return false // kein Kalender, sondern ein Termin: weiter zur Bibliothek + } + cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return true + } + if err := h.store.DeleteCalendar(r.Context(), user.ID, cal.ID); err != nil { + http.Error(w, "could not delete calendar", http.StatusInternalServerError) + return true + } + w.WriteHeader(http.StatusNoContent) + return true +} + +// fromAppleColor nimmt "#RRGGBB" oder "#RRGGBBAA" entgegen und gibt die +// Schreibweise zurück, die intern gilt. Der Alpha-Anteil fällt weg: Ein +// halbdurchsichtiger Kalender ist in der Oberfläche nicht darstellbar, und +// ihn zu behalten hieße, ihn irgendwo später doch zu verlieren. +func fromAppleColor(s string) string { + s = strings.TrimSpace(s) + if len(s) == 9 && strings.HasPrefix(s, "#") { + s = s[:7] + } + return normalizeHex(s) +} + +func xmlText(s string) string { + var b bytes.Buffer + xml.EscapeText(&b, []byte(s)) + return b.String() +} diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go index 8cd49a1..0ee953b 100644 --- a/backend/internal/calendar/handler.go +++ b/backend/internal/calendar/handler.go @@ -90,11 +90,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { r = r.WithContext(contextWithUser(r.Context(), user)) - // PROPFIND geht über einen eigenen Weg: Er ergänzt die Antwort um - // Eigenschaften, die go-webdav nicht kennt (siehe propfind.go). - if r.Method == "PROPFIND" { + // Drei Wege gehen an der Bibliothek vorbei, weil sie sie nicht oder + // falsch bedient – siehe propfind.go und collections.go. + switch r.Method { + case "PROPFIND": h.servePropfind(w, r, user) return + case "MKCALENDAR": + h.handleMkcalendar(w, r, user) + return + case "PROPPATCH": + h.handleProppatch(w, r, user) + return + case http.MethodDelete: + // Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht + // die Bibliothek weiterhin selbst. + if h.handleDeleteCalendar(w, r, user) { + return + } } h.dav.ServeHTTP(w, r) diff --git a/backend/internal/calendar/model.go b/backend/internal/calendar/model.go index c65e7ce..1cead5b 100644 --- a/backend/internal/calendar/model.go +++ b/backend/internal/calendar/model.go @@ -23,9 +23,22 @@ type Calendar struct { // TimeZone ist die Vorgabe für neue Termine, die die Web-Oberfläche // künftig hier anlegt (Phase 3). CalDAV selbst braucht sie nicht – // jeder Termin trägt seine Zeitzone bereits im .ics. - TimeZone string `json:"timezone"` - CTag int64 `json:"ctag"` - CreatedAt time.Time `json:"created_at"` + TimeZone string `json:"timezone"` + // PathSegment ist die Adresse, unter der ein Client diesen Kalender + // angelegt hat ("…/calendars/max/arbeit/"). Leer heißt: keine gewählte + // Adresse, es gilt die UUID. Siehe Ref. + PathSegment string `json:"-"` + CTag int64 `json:"ctag"` + CreatedAt time.Time `json:"created_at"` +} + +// Ref ist das Pfadsegment, unter dem dieser Kalender in CalDAV erreichbar +// ist: die vom Client gewählte Adresse, sonst die UUID. +func (c *Calendar) Ref() string { + if c.PathSegment != "" { + return c.PathSegment + } + return c.ID.String() } // Object ist ein einzelner Termin oder eine Aufgabe. Data ist die diff --git a/backend/internal/calendar/propfind.go b/backend/internal/calendar/propfind.go index 96ad4ed..a0ec608 100644 --- a/backend/internal/calendar/propfind.go +++ b/backend/internal/calendar/propfind.go @@ -11,8 +11,6 @@ import ( "strconv" "strings" - "github.com/google/uuid" - "aether/internal/auth" ) @@ -188,9 +186,11 @@ func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte if err != nil { return body // im Zweifel die Antwort der Bibliothek, unverändert } - byID := make(map[uuid.UUID]*Calendar, len(cals)) + // Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender + // erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat. + byRef := make(map[string]*Calendar, len(cals)) for i := range cals { - byID[cals[i].ID] = &cals[i] + byRef[cals[i].Ref()] = &cals[i] } const closeTag = "" @@ -201,7 +201,7 @@ func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte if i == len(parts)-1 { break // hinter dem letzten steht nur noch } - if cal := calendarOfResponse(part, byID); cal != nil { + if cal := calendarOfResponse(part, byRef); cal != nil { out.WriteString(extrasPropstat(cal, want)) } out.WriteString(closeTag) @@ -211,7 +211,7 @@ func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte // calendarOfResponse findet den Kalender, den ein -Abschnitt // beschreibt – oder nil, wenn er etwas anderes beschreibt. -func calendarOfResponse(part string, byID map[uuid.UUID]*Calendar) *Calendar { +func calendarOfResponse(part string, byRef map[string]*Calendar) *Calendar { start := strings.Index(part, "` + tc.href + `` - if got := calendarOfResponse(part, byID); got != tc.will { + if got := calendarOfResponse(part, byRef); got != tc.will { t.Errorf("%s: bekam %v, erwartet %v", tc.name, got, tc.will) } } diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go index daa11aa..8802d42 100644 --- a/backend/internal/calendar/store.go +++ b/backend/internal/calendar/store.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -28,7 +29,7 @@ func NewStore(db *pgxpool.Pool) *Store { func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) { rows, err := s.db.Query(ctx, - `SELECT id, owner_id, name, color, description, supports_todo, timezone, ctag, created_at + `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`, ownerID, ) @@ -40,7 +41,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda var out []Calendar for rows.Next() { var c Calendar - if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &c.CreatedAt); err != nil { + if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt); err != nil { return nil, fmt.Errorf("scan calendar: %w", err) } out = append(out, c) @@ -51,10 +52,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) { var c Calendar err := s.db.QueryRow(ctx, - `SELECT id, owner_id, name, color, description, supports_todo, timezone, ctag, created_at + `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id, - ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &c.CreatedAt) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -64,6 +65,60 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend return &c, nil } +// GetCalendarByRef findet einen Kalender über sein Pfadsegment: entweder die +// vom Client gewählte Adresse oder – wenn keine gewählt wurde – die UUID. +// +// Beides in einer Abfrage, damit der Aufruf nicht raten muss, welche Art +// Adresse er gerade in der Hand hält. Die gewählte Adresse hat Vorrang: Nur +// so bleibt eine Adresse eindeutig, selbst wenn sie zufällig aussieht wie +// die UUID eines anderen Kalenders. +func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) { + var c Calendar + err := s.db.QueryRow(ctx, + `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at + FROM cal_calendars + WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2)) + ORDER BY path_segment NULLS LAST + LIMIT 1`, + ownerID, ref, + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query calendar by ref: %w", err) + } + return &c, nil +} + +// CreateCalendarAt legt einen Kalender unter einer **vom Client gewählten** +// Adresse an – der Fall MKCALENDAR/MKCOL. Ist die Adresse schon vergeben, +// kommt ErrConflict: In WebDAV ist das ein 405, kein stilles Überschreiben. +func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment, name, color, description string) (*Calendar, error) { + if color == "" { + color = "#4285f4" + } + var c Calendar + err := s.db.QueryRow(ctx, + `INSERT INTO cal_calendars (owner_id, name, color, description, path_segment) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`, + ownerID, name, color, description, segment, + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt) + if isUniqueViolation(err) { + return nil, ErrConflict + } + if err != nil { + return nil, fmt.Errorf("insert calendar at %q: %w", segment, err) + } + return &c, nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + // EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei // Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort // ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder @@ -87,9 +142,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col err := s.db.QueryRow(ctx, `INSERT INTO cal_calendars (owner_id, name, color, description) VALUES ($1, $2, $3, $4) - RETURNING id, owner_id, name, color, description, supports_todo, timezone, ctag, created_at`, + RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`, ownerID, name, color, description, - ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &c.CreatedAt) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt) if err != nil { return nil, fmt.Errorf("insert calendar: %w", err) } @@ -109,9 +164,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, description = COALESCE($5, description), timezone = COALESCE($6, timezone) WHERE owner_id = $1 AND id = $2 - RETURNING id, owner_id, name, color, description, supports_todo, timezone, ctag, created_at`, + RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`, ownerID, id, name, color, description, timezone, - ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &c.CreatedAt) + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } diff --git a/backend/pkg/db/migrations/000023_calendar_path_segment.down.sql b/backend/pkg/db/migrations/000023_calendar_path_segment.down.sql new file mode 100644 index 0000000..217ff6d --- /dev/null +++ b/backend/pkg/db/migrations/000023_calendar_path_segment.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS cal_calendars_owner_path_segment; +ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment; diff --git a/backend/pkg/db/migrations/000023_calendar_path_segment.up.sql b/backend/pkg/db/migrations/000023_calendar_path_segment.up.sql new file mode 100644 index 0000000..a96fb41 --- /dev/null +++ b/backend/pkg/db/migrations/000023_calendar_path_segment.up.sql @@ -0,0 +1,15 @@ +-- Bei MKCALENDAR sucht der **Client** die Adresse des neuen Kalenders aus +-- ("…/calendars/max/arbeit/"). Unsere Kalender liegen sonst unter ihrer +-- UUID. Ohne diese Spalte müsste der Server die gewählte Adresse verwerfen +-- und der Client bekäme auf seine eigene Adresse ein 404. +-- +-- NULL heißt: keine gewählte Adresse, es gilt weiterhin die UUID. Alle +-- bestehenden Kalender bleiben damit unter genau der Adresse erreichbar, +-- unter der die eingerichteten Geräte sie schon kennen. +ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS path_segment TEXT; + +-- Teilweiser Index: Zwei Kalender desselben Kontos dürfen nicht dieselbe +-- Adresse haben, aber beliebig viele dürfen gar keine haben. +CREATE UNIQUE INDEX IF NOT EXISTS cal_calendars_owner_path_segment + ON cal_calendars (owner_id, path_segment) + WHERE path_segment IS NOT NULL; diff --git a/scripts/caldav-selbsttest.py b/scripts/caldav-selbsttest.py index 58d76c5..a2b79ed 100644 --- a/scripts/caldav-selbsttest.py +++ b/scripts/caldav-selbsttest.py @@ -345,6 +345,79 @@ try: st, _, _ = dav("GET", alt) check("unter der alten Adresse weg (404)", st == 404, st) + print("\n── Kalender verwalten (vom Client aus) ──") + EIGEN = f"/caldav/calendars/{USER}/selbsttest-arbeit/" + mk = ('' + '' + 'Arbeit' + '#FF6D00FF' + '') + st, _, _ = dav("MKCALENDAR", EIGEN, mk, {"Content-Type": "application/xml"}) + check("MKCALENDAR legt an (201)", st == 201, st) + + # Der Kern der Sache: Der Client sucht die Adresse aus, und unter genau + # dieser Adresse muss der Kalender danach auch erreichbar sein. + st, _, x = propfind(EIGEN, "") + check("unter der gewählten Adresse erreichbar", st == 207 and "Arbeit" in x, st) + check("Farbe aus dem MKCALENDAR übernommen", + wert(x, "calendar-color") == "#FF6D00FF", wert(x, "calendar-color")) + + _, cals_jetzt = api("GET", "/api/v1/calendars") + eigen = [c for c in cals_jetzt or [] if c["name"] == "Arbeit"] + check("taucht in der Weboberfläche auf", len(eigen) == 1, [c["name"] for c in cals_jetzt or []]) + + st, _, _ = dav("PUT", EIGEN + "mk1.ics", + ICS.format(uid="mk1", start="20261201T090000Z", + end="20261201T100000Z", summary="Im eigenen Kalender"), + {"Content-Type": "text/calendar"}) + check("Termin darin anlegen", st == 201, st) + st, _, body = dav("GET", EIGEN + "mk1.ics") + check("und wieder abrufen", st == 200 and "Im eigenen Kalender" in body, st) + + st, _, _ = dav("MKCALENDAR", EIGEN, mk, {"Content-Type": "application/xml"}) + check("zweites MKCALENDAR auf dieselbe Adresse: 405", st == 405, st) + + pp = ('' + '' + 'Arbeit neu' + '#34A853FF' + 'x' + '') + st, _, x = dav("PROPPATCH", EIGEN, pp, {"Content-Type": "application/xml"}) + check("PROPPATCH antwortet mit 207", st == 207, st) + # PROPPATCH kann teilweise gelingen. Deshalb ein Status je Eigenschaft – + # ein nacktes 200 verschwiege, welcher Teil angekommen ist. + check("Name und Farbe mit 200", + prop_status(x, "displayname") == 200 and prop_status(x, "calendar-color") == 200, x) + check("unveränderliche Eigenschaft mit 403", prop_status(x, "getetag") == 403, x) + + _, _, x = propfind(EIGEN, "") + check("Umbenennen ist angekommen", "Arbeit neu" in x, x[:200]) + check("Umfärben ist angekommen", wert(x, "calendar-color") == "#34A853FF", + wert(x, "calendar-color")) + + st, _, _ = dav("DELETE", EIGEN) + check("DELETE löscht den Kalender (204)", st == 204, st) + st, _, _ = propfind(EIGEN, "") + check("danach 404", st == 404, st) + _, cals_jetzt = api("GET", "/api/v1/calendars") + check("auch in der Weboberfläche weg", + all(c["name"] != "Arbeit neu" for c in cals_jetzt or []), + [c["name"] for c in cals_jetzt or []]) + + # DAVx5 nimmt nicht MKCALENDAR, sondern den erweiterten MKCOL. + MKCOL_PFAD = f"/caldav/calendars/{USER}/selbsttest-mkcol/" + mkcol = ('' + '' + '' + 'Per MKCOL') + st, _, _ = dav("MKCOL", MKCOL_PFAD, mkcol, {"Content-Type": "application/xml"}) + check("MKCOL legt ebenfalls an (201)", st == 201, st) + st, _, x = propfind(MKCOL_PFAD, "") + check("auch dieser unter seiner Adresse erreichbar", st == 207 and "Per MKCOL" in x, st) + dav("DELETE", MKCOL_PFAD) + print("\n── Fremde Kalender ──") st, _, _ = dav("PROPFIND", "/caldav/calendars/gibtsnicht/", headers={"Depth": "0", "Content-Type": "application/xml"})