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() }