Files
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00

330 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package calendar
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"aether/internal/auth"
)
// Zwei Eigenschaften, die jeder verbreitete CalDAV-Client abfragt und die
// go-webdav v0.7.0 nicht kennt:
//
// - getctag (Calendarserver): die billige Frage „hat sich in diesem
// Kalender etwas geändert?". Ohne sie lädt ein Client bei jedem Abgleich
// die Liste aller Termine herunter, um sie zu vergleichen.
// - calendar-color (Apple): die Farbe des Kalenders. Ohne sie zeigt der
// Client ihn in seiner eigenen Vorgabefarbe, und die Farbgebung im
// Telefon hat mit der in der Cloud nichts mehr zu tun.
//
// Die Bibliothek beantwortet in PROPFIND einen festen Satz Eigenschaften und
// bietet keinen Haken, eigene dazuzulegen (server.go, propFindCalendar).
// Deshalb dieser Umweg: Wir nehmen die beiden aus der Anfrage heraus, bevor
// go-webdav sie zu sehen bekommt, und hängen sie der Antwort wieder an.
//
// Warum herausnehmen und nicht einfach anhängen? Weil go-webdav sie sonst
// als „nicht gefunden" meldet und dieselbe Eigenschaft in derselben Antwort
// zweimal stünde – einmal mit 404, einmal mit 200. Was ein Client daraus
// macht, ist Glückssache.
const (
nsCalendarServer = "http://calendarserver.org/ns/"
nsApple = "http://apple.com/ns/ical/"
)
// Ein PROPFIND-Rumpf ist eine Liste leerer Elemente. Alles darüber ist kein
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
const maxPropfindBody = 1 << 20
// Vier Eigenschaften, die go-webdav nicht kennt. Die ersten beiden sind
// herstellereigen, die letzten beiden stehen im RFC – die Bibliothek liefert
// sie trotzdem nicht.
type wantedExtras struct{ ctag, color, syncToken, reports bool }
func (w wantedExtras) any() bool { return w.ctag || w.color || w.syncToken || w.reports }
// alleExtras: Antwort auf <allprop> und auf einen leeren Rumpf.
func alleExtras() wantedExtras {
return wantedExtras{ctag: true, color: true, syncToken: true, reports: true}
}
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
// und baut den Rumpf ohne sie neu auf.
//
// Der Neuaufbau ist verlustfrei, weil in einem PROPFIND ausschließlich leere
// Elemente stehen: Name und Namensraum sind alles, was eine Eigenschaft dort
// ausmacht.
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
if len(bytes.TrimSpace(body)) == 0 {
return alleExtras(), body
}
dec := xml.NewDecoder(bytes.NewReader(body))
var (
inProp bool
depth int
keep []xml.Name
)
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
return alleExtras(), body
}
if !inProp {
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
inProp, depth = true, 0
}
continue
}
depth++
if depth > 1 {
continue // verschachtelte Inhalte gibt es in PROPFIND nicht
}
switch {
case t.Name.Space == nsCalendarServer && t.Name.Local == "getctag":
want.ctag = true
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
want.color = true
case t.Name.Space == "DAV:" && t.Name.Local == "sync-token":
want.syncToken = true
case t.Name.Space == "DAV:" && t.Name.Local == "supported-report-set":
want.reports = true
default:
keep = append(keep, t.Name)
}
case xml.EndElement:
if inProp {
if depth == 0 {
inProp = false
} else {
depth--
}
}
}
}
if !want.any() {
return want, body // nichts zu tun – Rumpf unverändert weiterreichen
}
return want, buildPropfind(keep)
}
func buildPropfind(props []xml.Name) []byte {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop>`)
for _, n := range props {
if n.Space == "DAV:" {
fmt.Fprintf(&b, "<%s/>", xmlName(n.Local))
} else {
fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space))
}
}
// Fragt ein Client *nur* nach unseren beiden Eigenschaften, bliebe hier
// ein leeres <prop> stehen – damit weist go-webdav die Anfrage ab.
// resourcetype kennt jede Ressource und stört keinen Client.
if len(props) == 0 {
b.WriteString("<resourcetype/>")
}
b.WriteString("</prop></propfind>")
return []byte(b.String())
}
// Elementnamen und Namensräume stammen aus fremdem XML und wandern hier
// zurück in XML. Alles, was dort etwas anderes bedeuten könnte, fliegt raus –
// gültige Namen und URIs enthalten davon ohnehin nichts.
func xmlName(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.':
return r
}
return -1
}, s)
}
func xmlAttr(s string) string {
r := strings.NewReplacer("&", "&amp;", "\"", "&quot;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
// appleColor bringt eine Farbe in die Schreibweise, die Apple-Clients und
// DAVx5 erwarten: #RRGGBBAA. Ohne den Alpha-Anteil zeigen manche Clients
// die Farbe gar nicht an.
func appleColor(hex string) string {
h := normalizeHex(hex)
if h == "" {
return ""
}
return strings.ToUpper(h) + "FF"
}
// Die Berichte, die dieser Server beantwortet. Ohne diese Liste probiert ein
// Client sync-collection gar nicht erst aus – DAVx5 sieht hier nach, bevor es
// entscheidet, ob es die volle Terminliste holt oder nur die Änderungen.
const supportedReports = `<supported-report-set xmlns="DAV:">` +
`<supported-report><report><sync-collection/></report></supported-report>` +
`<supported-report><report><C:calendar-query xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
`<supported-report><report><C:calendar-multiget xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
`</supported-report-set>`
func extrasPropstat(cal *Calendar, want wantedExtras) string {
var b strings.Builder
if want.ctag {
fmt.Fprintf(&b, `<getctag xmlns="%s">%d</getctag>`, nsCalendarServer, cal.CTag)
}
if want.color {
if c := appleColor(cal.Color); c != "" {
fmt.Fprintf(&b, `<calendar-color xmlns="%s">%s</calendar-color>`, nsApple, c)
}
}
if want.syncToken {
// Derselbe Zähler wie im ctag – eine Zahl, zwei Namen, weil die
// Clients historisch nach beidem fragen.
fmt.Fprintf(&b, `<sync-token xmlns="DAV:">%s</sync-token>`, syncToken(cal.CTag))
}
if want.reports {
b.WriteString(supportedReports)
}
if b.Len() == 0 {
return ""
}
// Mehrere propstat je Antwort sind der Normalfall in WebDAV – so trennt
// go-webdav ohnehin schon gefundene von nicht gefundenen Eigenschaften.
return `<propstat xmlns="DAV:"><prop>` + b.String() +
`</prop><status>HTTP/1.1 200 OK</status></propstat>`
}
// injectExtras hängt jeder Antwort, die einen Kalender beschreibt, unsere
// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine
// bleiben unangetastet.
func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte {
// **Accessible, nicht nur eigene**: Ein freigegebener Kalender steht mit
// in der Liste und braucht dieselben Eigenschaften – ohne ctag und
// sync-token hielte ihn der Client für unveränderlich.
cals, err := h.store.ListAccessible(ctx, user.ID)
if err != nil {
return body // im Zweifel die Antwort der Bibliothek, unverändert
}
// Nach Pfadsegment, nicht nach UUID: Unter welcher Adresse ein Kalender
// erreichbar ist, entscheidet der Client, wenn er ihn selbst angelegt hat.
// Bei fremden Kalendern ist es immer die UUID – siehe Access.Ref.
byRef := make(map[string]*Calendar, len(cals))
for i := range cals {
byRef[cals[i].Ref()] = &cals[i].Calendar
}
const closeTag = "</response>"
parts := strings.Split(string(body), closeTag)
var out strings.Builder
for i, part := range parts {
out.WriteString(part)
if i == len(parts)-1 {
break // hinter dem letzten </response> steht nur noch </multistatus>
}
if cal := calendarOfResponse(part, byRef); cal != nil {
out.WriteString(extrasPropstat(cal, want))
}
out.WriteString(closeTag)
}
return []byte(out.String())
}
// calendarOfResponse findet den Kalender, den ein <response>-Abschnitt
// beschreibt – oder nil, wenn er etwas anderes beschreibt.
func calendarOfResponse(part string, byRef map[string]*Calendar) *Calendar {
start := strings.Index(part, "<href")
if start < 0 {
return nil
}
open := strings.Index(part[start:], ">")
if open < 0 {
return nil
}
rest := part[start+open+1:]
end := strings.Index(rest, "</href>")
if end < 0 {
return nil
}
href := rest[:end]
if p, err := url.PathUnescape(href); err == nil {
href = p
}
ref, objekt, err := parseCalendarPath(href)
if err != nil || objekt != "" {
return nil // Home-Set, Principal oder ein einzelner Termin
}
return byRef[ref]
}
// recorder hält die Antwort der Bibliothek fest, bis feststeht, ob noch
// etwas hineingehört. Nötig, weil die Länge sich ändert und Content-Length
// erst danach stimmt.
type recorder struct {
header http.Header
body bytes.Buffer
status int
}
func (rec *recorder) Header() http.Header { return rec.header }
func (rec *recorder) WriteHeader(code int) {
if rec.status == 0 {
rec.status = code
}
}
func (rec *recorder) Write(p []byte) (int, error) {
if rec.status == 0 {
rec.status = http.StatusOK
}
return rec.body.Write(p)
}
// servePropfind reicht die Anfrage an go-webdav weiter und ergänzt die
// Antwort um die Eigenschaften, die die Bibliothek nicht kennt.
func (h *Handler) servePropfind(w http.ResponseWriter, r *http.Request, user *auth.User) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody))
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
want, rewritten := parsePropfind(body)
r.Body = io.NopCloser(bytes.NewReader(rewritten))
r.ContentLength = int64(len(rewritten))
if !want.any() {
h.dav.ServeHTTP(w, r)
return
}
rec := &recorder{header: http.Header{}}
h.dav.ServeHTTP(rec, r)
out := rec.body.Bytes()
if rec.status == http.StatusMultiStatus {
out = h.injectExtras(r.Context(), user, out, want)
}
for k, v := range rec.header {
w.Header()[k] = v
}
w.Header().Set("Content-Length", strconv.Itoa(len(out)))
w.WriteHeader(rec.status)
if _, err := w.Write(out); err != nil {
return
}
}