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>
231 lines
8.0 KiB
Go
231 lines
8.0 KiB
Go
package calendar
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"aether/internal/auth"
|
||
)
|
||
|
||
// Abgleich nur der Änderungen: REPORT sync-collection, RFC 6578.
|
||
//
|
||
// **Wozu.** Bis hierher lief jeder Durchgang so: Der Client fragt das ctag
|
||
// ab, sieht es verändert und holt daraufhin die *volle* Terminliste. Eine
|
||
// verschobene Besprechung kostet damit denselben Verkehr wie ein Neuanfang –
|
||
// bei einem Jahr Termine jedes Mal. Am Telefon zahlt man das in Akku und
|
||
// Datenvolumen, und zwar bei jedem der viertelstündlichen Durchgänge.
|
||
//
|
||
// Mit sync-collection sagt der Client stattdessen „ich bin auf Stand 41" und
|
||
// bekommt zurück, was seither geschah – und ein neues Token.
|
||
//
|
||
// **Warum hier und nicht in der Bibliothek.** go-webdav kennt für REPORT nur
|
||
// calendar-query und calendar-multiget; alles andere beantwortet es mit 400.
|
||
// Der Weg geht deshalb an ihr vorbei, wie schon bei PROPFIND und MKCALENDAR.
|
||
//
|
||
// **Was ein Token ist.** Eine Zahl: der Stand des Kalenders, also sein ctag.
|
||
// Kein Zeitstempel – zwei Änderungen in derselben Sekunde wären damit nicht
|
||
// zu trennen, und eine Uhr, die zurückspringt, ließe Änderungen verschwinden.
|
||
|
||
// Präfix des Tokens. Ein Token ist laut RFC ein URI; der Präfix macht
|
||
// zugleich erkennbar, dass ein Token einer *fremden* Instanz nicht passt.
|
||
const syncTokenPrefix = "urn:aether:sync:"
|
||
|
||
// Ein sync-collection-Rumpf ist eine Handvoll Elemente. Alles darüber ist
|
||
// kein Abgleich mehr.
|
||
const maxReportBody = 1 << 20
|
||
|
||
type syncReport struct {
|
||
XMLName xml.Name `xml:"DAV: sync-collection"`
|
||
SyncToken string `xml:"DAV: sync-token"`
|
||
SyncLevel string `xml:"DAV: sync-level"`
|
||
Limit *struct {
|
||
NResults int `xml:"DAV: nresults"`
|
||
} `xml:"DAV: limit"`
|
||
Prop struct {
|
||
Any []struct {
|
||
XMLName xml.Name
|
||
} `xml:",any"`
|
||
} `xml:"DAV: prop"`
|
||
}
|
||
|
||
// wantsCalendarData: Fragt der Client die Termindaten gleich mit ab? Die
|
||
// meisten holen erst die ETags und danach gezielt per multiget – wer aber
|
||
// beides in einem Zug will, soll es bekommen.
|
||
func (s *syncReport) wantsCalendarData() bool {
|
||
for _, el := range s.Prop.Any {
|
||
if el.XMLName.Space == nsCalDAV && el.XMLName.Local == "calendar-data" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
const nsCalDAV = "urn:ietf:params:xml:ns:caldav"
|
||
|
||
// handleReport nimmt sync-collection an sich und lässt alles andere durch.
|
||
// Rückgabe: true, wenn die Antwort geschrieben wurde.
|
||
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, maxReportBody))
|
||
r.Body.Close()
|
||
// Was wir nicht selbst beantworten, muss die Bibliothek unverändert
|
||
// vorfinden – also den gelesenen Rumpf wieder unterlegen.
|
||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||
if err != nil || !isSyncCollection(body) {
|
||
return false
|
||
}
|
||
|
||
var req syncReport
|
||
if err := xml.Unmarshal(body, &req); err != nil {
|
||
http.Error(w, "malformed sync-collection request", http.StatusBadRequest)
|
||
return true
|
||
}
|
||
|
||
// sync-level ist Pflicht (RFC 6578, 3.3). Bei einem Kalender gibt es nur
|
||
// eine Ebene; "infinite" bedeutet hier dasselbe wie "1" und wird
|
||
// angenommen, statt den Client an einer Formalie scheitern zu lassen.
|
||
if lvl := strings.TrimSpace(req.SyncLevel); lvl != "1" && lvl != "infinite" {
|
||
http.Error(w, "sync-level must be 1", http.StatusBadRequest)
|
||
return true
|
||
}
|
||
|
||
ref, rest, err := parseCalendarPath(r.URL.Path)
|
||
if err != nil || rest != "" {
|
||
// sync-collection gilt für eine Sammlung, nicht für einen Termin.
|
||
http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden)
|
||
return true
|
||
}
|
||
zugriff, err := h.store.ResolveRef(r.Context(), user.ID, ref)
|
||
if err != nil {
|
||
http.Error(w, "Not Found", http.StatusNotFound)
|
||
return true
|
||
}
|
||
cal := &zugriff.Calendar
|
||
|
||
since, ok := parseSyncToken(req.SyncToken)
|
||
if !ok {
|
||
writeInvalidToken(w)
|
||
return true
|
||
}
|
||
|
||
changes, gueltig, err := h.store.ChangesSince(r.Context(), cal, since)
|
||
if err != nil {
|
||
http.Error(w, "sync failed", http.StatusInternalServerError)
|
||
return true
|
||
}
|
||
if !gueltig {
|
||
// Das Token ist zu alt oder stammt nicht von uns. Eine unvollständige
|
||
// Antwort wäre hier das Schlimmste: Der Client hielte sie für den
|
||
// ganzen Unterschied und behielte gelöschte Termine für immer.
|
||
writeInvalidToken(w)
|
||
return true
|
||
}
|
||
|
||
writeSyncResponse(w, user, ref, changes, req.wantsCalendarData())
|
||
return true
|
||
}
|
||
|
||
// isSyncCollection sieht nach, ob das Wurzelelement sync-collection ist –
|
||
// ohne den ganzen Rumpf zu deuten. Alles andere geht unverändert weiter an
|
||
// go-webdav.
|
||
func isSyncCollection(body []byte) bool {
|
||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if start, ok := tok.(xml.StartElement); ok {
|
||
return start.Name.Space == "DAV:" && start.Name.Local == "sync-collection"
|
||
}
|
||
}
|
||
}
|
||
|
||
// parseSyncToken liest den Stand aus dem Token. Ein leeres Token heißt
|
||
// „ich fange bei null an" – der erste Abgleich eines Clients.
|
||
func parseSyncToken(tok string) (int64, bool) {
|
||
tok = strings.TrimSpace(tok)
|
||
if tok == "" {
|
||
return 0, true
|
||
}
|
||
if !strings.HasPrefix(tok, syncTokenPrefix) {
|
||
return 0, false
|
||
}
|
||
n, err := strconv.ParseInt(strings.TrimPrefix(tok, syncTokenPrefix), 10, 64)
|
||
if err != nil || n < 0 {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
func syncToken(seq int64) string {
|
||
return syncTokenPrefix + strconv.FormatInt(seq, 10)
|
||
}
|
||
|
||
// writeInvalidToken schickt die Absage, die der RFC dafür vorsieht. Sie ist
|
||
// kein Fehler im üblichen Sinn: Jeder Client kennt sie und macht daraufhin
|
||
// einen Vollabgleich – genau das, was wir wollen.
|
||
func writeInvalidToken(w http.ResponseWriter) {
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.WriteHeader(http.StatusForbidden)
|
||
io.WriteString(w, xml.Header+
|
||
`<error xmlns="DAV:"><valid-sync-token/></error>`)
|
||
}
|
||
|
||
// ref ist die Adresse, unter der **dieser** Anfragende den Kalender sieht –
|
||
// bei einem freigegebenen also dessen UUID, nicht das Pfadsegment des
|
||
// Eigentümers. Die Antwort muss Adressen nennen, die der Client auch abrufen
|
||
// kann.
|
||
func writeSyncResponse(w http.ResponseWriter, user *auth.User,
|
||
ref string, changes *Changes, withData bool) {
|
||
|
||
var b strings.Builder
|
||
b.WriteString(xml.Header)
|
||
b.WriteString(`<multistatus xmlns="DAV:" xmlns:C="` + nsCalDAV + `">`)
|
||
|
||
for i := range changes.Changed {
|
||
o := &changes.Changed[i]
|
||
// Die Anführungszeichen stehen **außerhalb** der Maskierung: Sonst
|
||
// macht xml.EscapeText daraus `"`. Gültiges XML – aber genug
|
||
// CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die
|
||
// sähen dann bei jedem Durchgang eine Änderung, wo keine ist.
|
||
fmt.Fprintf(&b, `<response><href>%s</href><propstat><prop><getetag>%s</getetag>`,
|
||
xmlText(objectPath(user.Username, ref, o.UID)), quoteETag(o.ETag))
|
||
if withData {
|
||
fmt.Fprintf(&b, `<C:calendar-data>%s</C:calendar-data>`, xmlText(o.Data))
|
||
}
|
||
b.WriteString(`</prop><status>HTTP/1.1 200 OK</status></propstat></response>`)
|
||
}
|
||
|
||
// Gelöschtes trägt **keinen** propstat, sondern einen Status direkt an der
|
||
// Antwort – so unterscheidet der Client „ist weg" von „gibt es, konnte
|
||
// aber eine Eigenschaft nicht liefern".
|
||
for _, uid := range changes.Deleted {
|
||
fmt.Fprintf(&b, `<response><href>%s</href><status>HTTP/1.1 404 Not Found</status></response>`,
|
||
xmlText(objectPath(user.Username, ref, uid)))
|
||
}
|
||
|
||
fmt.Fprintf(&b, `<sync-token>%s</sync-token></multistatus>`, syncToken(changes.Token))
|
||
|
||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||
w.WriteHeader(http.StatusMultiStatus)
|
||
io.WriteString(w, b.String())
|
||
}
|
||
|
||
// quoteETag: In der Datenbank steht der nackte Wert, über die Leitung gehört
|
||
// er in Anführungszeichen (RFC 9110). go-webdav macht das an anderer Stelle
|
||
// selbst – hier bauen wir die Antwort von Hand, also hier auch.
|
||
//
|
||
// Der Inhalt wird maskiert, die Anführungszeichen nicht: Sie sind Teil der
|
||
// Syntax, nicht des Werts.
|
||
func quoteETag(etag string) string {
|
||
if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/`) {
|
||
return xmlText(etag)
|
||
}
|
||
return `"` + xmlText(etag) + `"`
|
||
}
|