Files
Aether/backend/internal/calendar/backend.go
T
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

411 lines
13 KiB
Go
Raw 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"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"aether/internal/auth"
)
// Pfadschema. Zwei Eigenheiten der Bibliothek bestimmen es:
//
// 1. Zurückgegebene Pfade (CurrentUserPrincipal, CalendarHomeSetPath,
// Calendar.Path, ...) vergleicht sie direkt mit r.URL.Path – also
// inklusive Präfix. Deshalb tragen alle hier gebauten Pfade davPrefix
// von Anfang an mit.
//
// 2. Welche Art Ressource ein Pfad ist, entscheidet sie allein an der
// Anzahl der Pfadsegmente hinter dem Präfix (resourceTypeAtPath in
// server.go) – nicht am Namen. Ein Segment = Principal, zwei = Calendar-
// Home-Set, drei = Kalender, vier = Objekt. Deshalb steckt der
// Nutzername beim Principal *in* dem einen Segment ("principals-max"),
// nicht als eigenes Unterverzeichnis – sonst läse die Bibliothek ihn als
// Calendar-Home-Set und die PROPFIND-Antwort bliebe leer.
//
// /caldav/principals-{username}/ – 1 Segment
// /caldav/calendars/{username}/ – 2 Segmente, Calendar-Home-Set
// /caldav/calendars/{username}/{calendar-id}/ – 3 Segmente, ein Kalender
// /caldav/calendars/{username}/{calendar-id}/{uid}.ics – 4 Segmente, ein Objekt
const davPrefix = "/caldav"
type contextKey int
const keyUser contextKey = iota
func contextWithUser(ctx context.Context, u *auth.User) context.Context {
return context.WithValue(ctx, keyUser, u)
}
func userFromContext(ctx context.Context) (*auth.User, error) {
u, _ := ctx.Value(keyUser).(*auth.User)
if u == nil {
return nil, webdav.NewHTTPError(http.StatusUnauthorized, errors.New("no authenticated user"))
}
return u, nil
}
// Backend implementiert caldav.Backend. Phase 1: jeder Nutzer sieht nur die
// eigenen Kalender – Freigaben (cal_shares) kommen erst in einer späteren
// Phase, siehe kalender-plan.md.
type Backend struct {
store *Store
}
func NewBackend(store *Store) *Backend {
return &Backend{store: store}
}
func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) {
u, err := userFromContext(ctx)
if err != nil {
return "", err
}
return davPrefix + "/principals-" + u.Username + "/", nil
}
func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) {
u, err := userFromContext(ctx)
if err != nil {
return "", err
}
return davPrefix + "/calendars/" + u.Username + "/", nil
}
// 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, ref, uid string) string {
return calendarPath(username, ref) + uid + ".ics"
}
// 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.
// Ohne diese Prüfung würde z. B. "/caldav/principals-max/" das Wort
// "caldav" für eine Kalender-ID halten und einen kryptischen
// UUID-Parsing-Fehler nach außen geben.
const calPrefix = davPrefix + "/calendars/"
if !strings.HasPrefix(p, calPrefix) {
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 "", "", fmt.Errorf("calendar: malformed path %q", p)
}
if len(parts) == 3 {
rest = parts[2]
}
return parts[1], rest, nil
}
// resolveCalendar ist der **eine** Weg von einer Adresse zu einem Kalender –
// und damit die Stelle, an der die Rechteprüfung sitzt. Jede CalDAV-Operation
// läuft hier durch; eine zweite Auflösung daneben wäre eine Tür ohne Schloss.
func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Access, error) {
u, err := userFromContext(ctx)
if err != nil {
return nil, nil, err
}
ref, _, err := parseCalendarPath(p)
if err != nil {
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
cal, err := b.store.ResolveRef(ctx, u.ID, ref)
if errors.Is(err, ErrNotFound) {
return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
if err != nil {
return nil, nil, err
}
return u, cal, nil
}
// resolveWritable: dasselbe, aber der Aufrufer will schreiben. Ein nur lesend
// freigegebener Kalender darf sichtbar bleiben und trotzdem ablehnen – 403
// verrät hier nichts, was der Anfragende nicht ohnehin sieht.
func (b *Backend) resolveWritable(ctx context.Context, p string) (*auth.User, *Access, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, nil, err
}
if !cal.CanWrite() {
return nil, nil, webdav.NewHTTPError(http.StatusForbidden,
errors.New("calendar is shared read-only"))
}
return u, cal, nil
}
func toCalDAVCalendar(username string, c *Access) caldav.Calendar {
comps := []string{"VEVENT"}
if c.SupportsTodo {
comps = append(comps, "VTODO")
}
// Ein fremder Kalender trägt den Eigentümer im Namen. Ohne das stehen in
// der Liste des Clients zwei „Privat" nebeneinander, und welches welches
// ist, weiß niemand.
name := c.Name
if c.Shared && c.OwnerName != "" {
name = c.Name + " (" + c.OwnerName + ")"
}
return caldav.Calendar{
Path: calendarPath(username, c.Ref()),
Name: name,
Description: c.Description,
MaxResourceSize: 5 * 1024 * 1024,
SupportedComponentSet: comps,
}
}
func (b *Backend) CreateCalendar(ctx context.Context, cal *caldav.Calendar) error {
u, err := userFromContext(ctx)
if err != nil {
return err
}
name := cal.Name
if name == "" {
name = "Kalender"
}
// 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.Ref())
return nil
}
func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
u, err := userFromContext(ctx)
if err != nil {
return nil, err
}
if _, err := b.store.EnsureDefaultCalendar(ctx, u.ID); err != nil {
return nil, fmt.Errorf("ensure default calendar: %w", err)
}
all, err := b.store.ListAccessible(ctx, u.ID)
if err != nil {
return nil, fmt.Errorf("list calendars: %w", err)
}
out := make([]caldav.Calendar, len(all))
for i := range all {
out[i] = toCalDAVCalendar(u.Username, &all[i])
}
return out, nil
}
func (b *Backend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
c := toCalDAVCalendar(u.Username, cal)
return &c, nil
}
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, calRef, o.UID),
ModTime: o.UpdatedAt,
ContentLength: int64(len(o.Data)),
ETag: o.ETag,
Data: data,
}, nil
}
func (b *Backend) GetCalendarObject(ctx context.Context, p string, _ *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
_, rest, err := parseCalendarPath(p)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
uid := strings.TrimSuffix(rest, ".ics")
obj, err := b.store.GetObjectByUID(ctx, cal.ID, uid)
if errors.Is(err, ErrNotFound) {
return nil, webdav.NewHTTPError(http.StatusNotFound, err)
}
if err != nil {
return nil, err
}
return toCalendarObject(u.Username, cal.Ref(), obj)
}
func (b *Backend) ListCalendarObjects(ctx context.Context, p string, _ *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
u, cal, err := b.resolveCalendar(ctx, p)
if err != nil {
return nil, err
}
objs, err := b.store.ListObjects(ctx, cal.ID)
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
}
out := make([]caldav.CalendarObject, 0, len(objs))
for i := range objs {
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.
continue
}
out = append(out, *co)
}
return out, nil
}
func (b *Backend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
all, err := b.ListCalendarObjects(ctx, p, &query.CompRequest)
if err != nil {
return nil, err
}
return caldav.Filter(query, all)
}
// firstUID/firstComponent lesen UID und Komponententyp (VEVENT/VTODO) aus
// dem ersten Termin-/Aufgaben-Kind der .ics-Datei. VTIMEZONE-Kinder werden
// übersprungen, die tragen keine UID.
func firstUIDAndComponent(cal *ical.Calendar) (uid, component string, err error) {
for _, child := range cal.Children {
if child.Name != "VEVENT" && child.Name != "VTODO" {
continue
}
v, err := child.Props.Text("UID")
if err != nil || v == "" {
return "", "", fmt.Errorf("calendar object missing UID")
}
return v, child.Name, nil
}
return "", "", fmt.Errorf("calendar object has no VEVENT/VTODO component")
}
func timeRange(cal *ical.Calendar) (start, end *time.Time) {
for _, ev := range cal.Events() {
if t, err := ev.DateTimeStart(time.Local); err == nil {
start = &t
}
if t, err := ev.DateTimeEnd(time.Local); err == nil {
end = &t
}
break
}
return
}
func encodeICal(cal *ical.Calendar) (string, error) {
var buf bytes.Buffer
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
return "", fmt.Errorf("encode object: %w", err)
}
return buf.String(), nil
}
func etagFor(data string) string {
sum := sha256.Sum256([]byte(data))
return hex.EncodeToString(sum[:])
}
func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
u, calRow, err := b.resolveWritable(ctx, p)
if err != nil {
return nil, err
}
uid, component, err := firstUIDAndComponent(cal)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
}
existing, err := b.store.GetObjectByUID(ctx, calRow.ID, uid)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if opts != nil {
if opts.IfNoneMatch.IsSet() && opts.IfNoneMatch.IsWildcard() && existing != nil {
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object already exists"))
}
if opts.IfMatch.IsSet() {
if existing == nil {
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("object not found"))
}
ok, err := opts.IfMatch.MatchETag(existing.ETag)
if err != nil || !ok {
return nil, webdav.NewHTTPError(http.StatusPreconditionFailed, errors.New("etag mismatch"))
}
}
}
data, err := encodeICal(cal)
if err != nil {
return nil, err
}
etag := etagFor(data)
start, end := timeRange(cal)
obj, err := b.store.PutObject(ctx, calRow.ID, uid, component, data, etag, start, end)
if err != nil {
return nil, fmt.Errorf("put object: %w", err)
}
return toCalendarObject(u.Username, calRow.Ref(), obj)
}
func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error {
_, calRow, err := b.resolveWritable(ctx, p)
if err != nil {
return err
}
_, rest, err := parseCalendarPath(p)
if err != nil {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
uid := strings.TrimSuffix(rest, ".ics")
if err := b.store.DeleteObject(ctx, calRow.ID, uid); err != nil {
if errors.Is(err, ErrNotFound) {
return webdav.NewHTTPError(http.StatusNotFound, err)
}
return fmt.Errorf("delete object: %w", err)
}
return nil
}