Bisher konnte ein CalDAV-Client Termine verwalten, aber keine Kalender:
MKCALENDAR -> 405 anlegen
PROPPATCH -> 501 umbenennen, umfaerben
DELETE -> 404 loeschen
go-webdav deckt davon nur MKCOL ab, und selbst da verwarf unser Backend
die vom Client gewaehlte Adresse. Alle drei Verben faengt der Handler
jetzt vorher ab (collections.go).
Der Kern steckt aber nicht in den Verben, sondern in der Adresse: Bei
MKCALENDAR sucht *der Client* aus, wo der Kalender liegen soll
("…/calendars/max/arbeit/"). Unsere Kalender lagen bisher zwingend unter
ihrer UUID -- der Client haette angelegt und auf seine eigene Adresse
sofort ein 404 bekommen.
Deshalb Migration 23 mit einer Spalte path_segment: gesetzt = die
gewaehlte Adresse, NULL = es gilt weiterhin die UUID. Bestehende
Kalender bleiben damit unter genau der Adresse erreichbar, unter der die
eingerichteten Geraete sie kennen -- ein Wechsel haette jedes Telefon
dazu gebracht, seine Termine fuer geloescht zu halten. Aufgeloest wird
beides in einer Abfrage (GetCalendarByRef), die gewaehlte Adresse hat
Vorrang.
PROPPATCH antwortet mit einem Status je Eigenschaft, auch wenn alles
geklappt hat: Es kann teilweise gelingen, und ein nacktes 200
verschwiege, welcher Teil angekommen ist. Was wir nicht aendern koennen,
bekommt 403 statt stillem Schlucken -- ein Client, der seine Aenderung
fuer angekommen haelt, ist schlimmer dran als einer mit klarer Absage.
Ebenso eine unlesbare Farbe: absagen statt raten.
DELETE auf eine Sammlung loescht den Kalender mitsamt allen Terminen --
das ist in WebDAV seine Bedeutung.
CalDAV-Selbsttest von 45 auf 62 Pruefungen: anlegen, unter der
gewaehlten Adresse erreichbar, Farbe aus dem MKCALENDAR uebernommen,
Termin darin ablegen und abrufen, zweites Anlegen auf dieselbe Adresse
405, umbenennen und umfaerben mit Status je Eigenschaft, loeschen, danach
404 -- und dasselbe noch einmal ueber MKCOL, das Verb, das DAVx5 nimmt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
8.2 KiB
Go
275 lines
8.2 KiB
Go
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 <set><prop>.
|
||
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(`<?xml version="1.0" encoding="utf-8"?>` +
|
||
`<multistatus xmlns="DAV:"><response><href>` + xmlText(r.URL.Path) + `</href>`)
|
||
writePropstat := func(namen []xml.Name, status string) {
|
||
if len(namen) == 0 {
|
||
return
|
||
}
|
||
b.WriteString("<propstat><prop>")
|
||
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("</prop><status>" + status + "</status></propstat>")
|
||
}
|
||
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("</response></multistatus>")
|
||
|
||
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 <set> und <remove> 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()
|
||
}
|