feat(calendar): Kalender anlegen, umbenennen, loeschen -- vom Client aus
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>
This commit is contained in:
1 parent
820ffdde6a
commit
f71ab710e1
13 files changed
+551
-53
No files matched your search
+18
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "</response>"
|
||||
@@ -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 </response> steht nur noch </multistatus>
|
||||
}
|
||||
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 <response>-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, "<href")
|
||||
if start < 0 {
|
||||
return nil
|
||||
@@ -229,11 +229,11 @@ func calendarOfResponse(part string, byID map[uuid.UUID]*Calendar) *Calendar {
|
||||
if p, err := url.PathUnescape(href); err == nil {
|
||||
href = p
|
||||
}
|
||||
calID, objekt, err := parseCalendarPath(href)
|
||||
ref, objekt, err := parseCalendarPath(href)
|
||||
if err != nil || objekt != "" {
|
||||
return nil // Home-Set, Principal oder ein einzelner Termin
|
||||
}
|
||||
return byID[calID]
|
||||
return byRef[ref]
|
||||
}
|
||||
|
||||
// recorder hält die Antwort der Bibliothek fest, bis feststeht, ob noch
|
||||
|
||||
@@ -72,7 +72,10 @@ func TestParsePropfindAllpropUndLeererRumpf(t *testing.T) {
|
||||
func TestCalendarOfResponseTrifftNurKalender(t *testing.T) {
|
||||
id := uuid.New()
|
||||
cal := &Calendar{ID: id, Color: "#a142f4", CTag: 7}
|
||||
byID := map[uuid.UUID]*Calendar{id: cal}
|
||||
// Ein Kalender unter seiner UUID, einer unter einer vom Client
|
||||
// gewählten Adresse – beide müssen gefunden werden.
|
||||
eigen := &Calendar{ID: uuid.New(), PathSegment: "arbeit", Color: "#34a853"}
|
||||
byRef := map[string]*Calendar{id.String(): cal, "arbeit": eigen}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -84,10 +87,12 @@ func TestCalendarOfResponseTrifftNurKalender(t *testing.T) {
|
||||
{"Principal", "/caldav/principals-max/", nil},
|
||||
{"einzelner Termin", "/caldav/calendars/max/" + id.String() + "/abc.ics", nil},
|
||||
{"fremder Kalender", "/caldav/calendars/max/" + uuid.New().String() + "/", nil},
|
||||
{"selbst gewählte Adresse", "/caldav/calendars/max/arbeit/", eigen},
|
||||
{"Termin darin", "/caldav/calendars/max/arbeit/abc.ics", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
part := `<response xmlns="DAV:"><href>` + tc.href + `</href><propstat/>`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS cal_calendars_owner_path_segment;
|
||||
ALTER TABLE cal_calendars DROP COLUMN IF EXISTS path_segment;
|
||||
@@ -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;
|
||||
@@ -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 = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
||||
'xmlns:i="http://apple.com/ns/ical/"><d:set><d:prop>'
|
||||
'<d:displayname>Arbeit</d:displayname>'
|
||||
'<i:calendar-color>#FF6D00FF</i:calendar-color>'
|
||||
'</d:prop></d:set></c:mkcalendar>')
|
||||
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, "<d:displayname/><i:calendar-color/>")
|
||||
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 = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<d:propertyupdate xmlns:d="DAV:" xmlns:i="http://apple.com/ns/ical/">'
|
||||
'<d:set><d:prop><d:displayname>Arbeit neu</d:displayname>'
|
||||
'<i:calendar-color>#34A853FF</i:calendar-color></d:prop></d:set>'
|
||||
'<d:set><d:prop><d:getetag>x</d:getetag></d:prop></d:set>'
|
||||
'</d:propertyupdate>')
|
||||
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, "<d:displayname/><i:calendar-color/>")
|
||||
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, "<d:displayname/>")
|
||||
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 = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||
'<d:set><d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||
'<d:displayname>Per MKCOL</d:displayname></d:prop></d:set></d: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, "<d:displayname/>")
|
||||
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"})
|
||||
|
||||
Reference in new issue
Block a user