diff --git a/TESTING.md b/TESTING.md index 97d2e48..efb392c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -202,6 +202,36 @@ Ruf ein `403` und sucht die Ursache bei den Rechten. Die Weboberfläche zieht den neuen Wert an allen drei Stellen nach (`hooks/useAuth.ts`); ein Skript muss es auch. +## Kalender teilen + + python3 scripts/kalender-teilen-selbsttest.py + +Eine Rechtefunktion ist so viel wert wie ihre engste Stelle. Aether hat **zwei** +Zugänge zu denselben Kalendern – die Weboberfläche und CalDAV –, und beide +prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist keines; +deshalb geht hier jede Prüfung über beide Wege. + +41 Prüfungen in sieben Gruppen, und die Reihenfolge ist der Punkt: **vorher +sieht der Empfänger nichts** (ohne diesen Anfang beweist der Rest nichts – ein +Kalender, den ohnehin jeder sieht, ist auch nach dem Freigeben sichtbar), dann +nur lesend (sehen ja, ändern nein – und beim Eigentümer kommt nachweislich +nichts an), schreibend, die **Grenzen** des Schreibrechts (fremden Kalender +umbenennen, löschen oder weiterverschenken geht auch dann nicht), Freigabe über +eine **Gruppe**, `sync-collection` auf einem fremden Kalender, und der +Widerruf. + +Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am Ende +alles weg. + +### Eine Falle, die der Test selbst hatte + +Die Terminliste hat einen Standardzeitraum (ein Monat zurück bis ein Jahr +voraus). Mit einem festen Testdatum lieferte sie irgendwann eine leere Liste – +und die Prüfung „beim Eigentümer ist nichts angekommen" **bestand dann leer**. +Eine Prüfung, die ohne Daten besteht, prüft nichts. Jetzt liegen die Testtermine +relativ zum heutigen Tag, der Zeitraum wird ausdrücklich mitgegeben, und eine +Zeile davor stellt sicher, dass überhaupt etwas in der Liste steht. + ## Notfall: Migration hängt fest ("Dirty database version") Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 1caf298..5dfdb3c 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -280,7 +280,7 @@ func main() { davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore) // CalDAV - calStore := calendar.NewStore(pool) + calStore := calendar.NewStore(pool, aclStore) calHandler := calendar.NewHandler(authStore, calStore) calAPIHandler := calendar.NewAPIHandler(calStore) @@ -550,6 +550,10 @@ func main() { r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL) r.Get("/files/{id}/acl", aclHandler.ListFileACL) r.Post("/files/{id}/acl", aclHandler.GrantFileACL) + // Kalender liegen in derselben Rechtetabelle – deshalb dieselben + // Endpunkte, und /acl/{id} widerruft alle drei Arten. + r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL) + r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL) r.Delete("/acl/{id}", aclHandler.RevokeACL) // App passwords (WebDAV/sync clients) diff --git a/backend/db/migrations/000025_calendar_share.down.sql b/backend/db/migrations/000025_calendar_share.down.sql new file mode 100644 index 0000000..491b89e --- /dev/null +++ b/backend/db/migrations/000025_calendar_share.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_acl_calendar; +DROP INDEX IF EXISTS idx_acl_group_calendar; +DROP INDEX IF EXISTS idx_acl_user_calendar; +ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource; +ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id; +ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource + CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1); diff --git a/backend/db/migrations/000025_calendar_share.up.sql b/backend/db/migrations/000025_calendar_share.up.sql new file mode 100644 index 0000000..06863b8 --- /dev/null +++ b/backend/db/migrations/000025_calendar_share.up.sql @@ -0,0 +1,31 @@ +-- Kalender an andere Konten freigeben. +-- +-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und +-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine +-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit +-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei +-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst +-- auf, wenn jemand sieht, was er nicht sehen soll. +-- +-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe** +-- kommt damit ohne eine einzige Zeile zusaetzlichen Code. +ALTER TABLE acl_entries + ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE; + +-- Genau eine Ressource je Eintrag -- jetzt zu dritt. +ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource; +ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource + CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1); + +-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren +-- mehrdeutig: welches Recht gilt dann? +CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar + ON acl_entries (subject_user_id, resource_calendar_id) + WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar + ON acl_entries (subject_group_id, resource_calendar_id) + WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL; + +-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang. +CREATE INDEX IF NOT EXISTS idx_acl_calendar + ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL; diff --git a/backend/internal/acl/acl.go b/backend/internal/acl/acl.go index 63c5ff2..8fe111a 100644 --- a/backend/internal/acl/acl.go +++ b/backend/internal/acl/acl.go @@ -349,3 +349,119 @@ func stronger(a, b Permission) Permission { } return "" } + +// ── Kalender ───────────────────────────────────────────────────────────────── +// +// Kalender liegen in derselben Rechtetabelle wie Ordner und Dateien. Nicht aus +// Sparsamkeit, sondern weil zwei Tabellen zwei Antworten auf dieselbe Frage +// wären – und die Lücke zwischen ihnen fällt erst auf, wenn jemand sieht, was +// er nicht sehen soll. Die Auflösung über Gruppen ist deshalb wörtlich +// dieselbe Bedingung wie bei Ordnern. + +// ResolveCalendar klärt, was userID mit diesem Kalender darf. +// +// Der Eigentümer darf schreiben. Alle anderen nur, wenn es eine Freigabe gibt – +// direkt oder über eine Gruppe, in der sie sind. Gibt es keine, ist die +// Antwort ErrNoAccess, und der Aufrufer macht daraus ein 404: Ein 403 verriete, +// dass es den Kalender gibt. +func (s *Store) ResolveCalendar(ctx context.Context, userID, calendarID uuid.UUID) (Permission, error) { + var ownerID uuid.UUID + err := s.db.QueryRow(ctx, + `SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&ownerID) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("load calendar owner: %w", err) + } + if ownerID == userID { + return PermWrite, nil + } + + rows, err := s.db.Query(ctx, + `SELECT e.permission + FROM acl_entries e + WHERE e.resource_calendar_id = $1 + AND ( e.subject_user_id = $2 + OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2) )`, + calendarID, userID) + if err != nil { + return "", fmt.Errorf("resolve calendar permission: %w", err) + } + defer rows.Close() + + // Mehrere Wege zum selben Kalender sind möglich – persönlich *und* über + // eine Gruppe. Dann gilt das weitergehende Recht; alles andere wäre für + // den Betroffenen nicht nachvollziehbar. + var best Permission + for rows.Next() { + var p Permission + if err := rows.Scan(&p); err != nil { + return "", fmt.Errorf("scan permission: %w", err) + } + if p == PermWrite { + best = PermWrite + } else if best == "" { + best = p + } + } + if err := rows.Err(); err != nil { + return "", err + } + if best == "" { + return "", ErrNoAccess + } + return best, nil +} + +// SharedCalendar ist ein fremder Kalender, den jemand sehen darf. +type SharedCalendar struct { + CalendarID uuid.UUID + Permission Permission + OwnerName string + // ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert + // man sich über einen Kalender, den einem nie jemand persönlich gab. + ViaGroup string +} + +// SharedCalendars liefert alle **fremden** Kalender, die userID sehen darf. +// Die eigenen stehen nicht darin; die kennt der Kalenderteil selbst. +func (s *Store) SharedCalendars(ctx context.Context, userID uuid.UUID) ([]SharedCalendar, error) { + rows, err := s.db.Query(ctx, + `SELECT c.id, e.permission, + COALESCE(NULLIF(o.display_name, ''), o.username, ''), + COALESCE(g.name, '') + FROM acl_entries e + JOIN cal_calendars c ON c.id = e.resource_calendar_id + JOIN users o ON o.id = c.owner_id + LEFT JOIN groups g ON g.id = e.subject_group_id + WHERE c.owner_id <> $1 + AND ( e.subject_user_id = $1 + OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1) ) + ORDER BY o.username, c.created_at`, + userID) + if err != nil { + return nil, fmt.Errorf("list shared calendars: %w", err) + } + defer rows.Close() + + // Zwei Wege zum selben Kalender ergeben zwei Zeilen – angezeigt wird er + // einmal, mit dem weitergehenden Recht. + byID := map[uuid.UUID]int{} + out := []SharedCalendar{} + for rows.Next() { + var sc SharedCalendar + if err := rows.Scan(&sc.CalendarID, &sc.Permission, &sc.OwnerName, &sc.ViaGroup); err != nil { + return nil, fmt.Errorf("scan shared calendar: %w", err) + } + if i, da := byID[sc.CalendarID]; da { + if sc.Permission == PermWrite { + out[i].Permission = PermWrite + } + continue + } + byID[sc.CalendarID] = len(out) + out = append(out, sc) + } + return out, rows.Err() +} diff --git a/backend/internal/acl/handler.go b/backend/internal/acl/handler.go index 2f9fdd7..92d26b7 100644 --- a/backend/internal/acl/handler.go +++ b/backend/internal/acl/handler.go @@ -89,6 +89,63 @@ func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID, return id, user.ID, true } +// ownedCalendar: Nur der Eigentümer verwaltet die Freigaben seines Kalenders. +// Wer ihn freigegeben *bekommen* hat – auch schreibend –, darf ihn nicht +// weiterreichen. Ein Recht weiterzugeben, das man selbst nur geliehen hat, +// ist etwas anderes als es zu benutzen. +func (h *Handler) ownedCalendar(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) { + user := auth.UserFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid calendar id") + return uuid.Nil, uuid.Nil, false + } + owner, err := h.store.CalendarOwner(r.Context(), id) + if err != nil { + response.Error(w, http.StatusNotFound, "calendar not found") + return uuid.Nil, uuid.Nil, false + } + if owner != user.ID { + // 404 statt 403: Wer den Kalender nicht besitzt, soll aus der Antwort + // nicht erfahren, dass es ihn gibt. + response.Error(w, http.StatusNotFound, "calendar not found") + return uuid.Nil, uuid.Nil, false + } + return id, user.ID, true +} + +// GET /api/v1/calendars/{id}/acl +func (h *Handler) ListCalendarACL(w http.ResponseWriter, r *http.Request) { + id, _, ok := h.ownedCalendar(w, r) + if !ok { + return + } + entries, err := h.store.ListForCalendar(r.Context(), id) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not load shares") + return + } + response.JSON(w, http.StatusOK, entries) +} + +// POST /api/v1/calendars/{id}/acl +func (h *Handler) GrantCalendarACL(w http.ResponseWriter, r *http.Request) { + id, owner, ok := h.ownedCalendar(w, r) + if !ok { + return + } + req, ok := decodeGrant(w, r) + if !ok { + return + } + entry, err := h.store.GrantCalendar(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner) + if err == nil { + h.logShare(r, audit.ActionShareGrant, "calendar", id.String(), + string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String()) + } + writeGrantResult(w, entry, err) +} + func userFrom(r *http.Request) uuid.UUID { if u := auth.UserFromContext(r.Context()); u != nil { return u.ID diff --git a/backend/internal/acl/store.go b/backend/internal/acl/store.go index 06574aa..bde4be8 100644 --- a/backend/internal/acl/store.go +++ b/backend/internal/acl/store.go @@ -41,17 +41,30 @@ type SharedResource struct { // ── Freigaben verwalten ────────────────────────────────────────────────────── +// resource benennt, worum es bei einer Freigabe geht: genau eines der drei +// Felder ist gesetzt. Als Typ statt als drei Zeigerparameter, seit der +// Kalender dazukam – drei nebeneinanderstehende `*uuid.UUID` in einer +// Signatur sind eine Einladung, sie zu vertauschen. +type resource struct{ folder, file, calendar *uuid.UUID } + // GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID // ein Nutzer oder eine Gruppe ist. func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) { - return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy) + return s.grant(ctx, resource{folder: &folderID}, subjectID, subjectIsGroup, perm, grantedBy) } func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) { - return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy) + return s.grant(ctx, resource{file: &fileID}, subjectID, subjectIsGroup, perm, grantedBy) } -func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) { +// GrantCalendar gibt einen Kalender frei – an ein Konto oder an eine Gruppe. +// Dass Gruppen ohne Zutun mitfunktionieren, ist der Grund, warum Kalender in +// *dieser* Tabelle liegen und nicht in einer eigenen. +func (s *Store) GrantCalendar(ctx context.Context, calendarID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) { + return s.grant(ctx, resource{calendar: &calendarID}, subjectID, subjectIsGroup, perm, grantedBy) +} + +func (s *Store) grant(ctx context.Context, res resource, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) { if !perm.valid() { return nil, fmt.Errorf("invalid permission %q", perm) } @@ -71,10 +84,11 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI var e Entry err := s.db.QueryRow(ctx, `INSERT INTO acl_entries - (subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by) - VALUES ($1, $2, $3, $4, $5, $6) + (subject_user_id, subject_group_id, + resource_folder_id, resource_file_id, resource_calendar_id, permission, granted_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, permission, created_at`, - subjUser, subjGroup, folderID, fileID, perm, grantedBy, + subjUser, subjGroup, res.folder, res.file, res.calendar, perm, grantedBy, ).Scan(&e.ID, &e.Permission, &e.CreatedAt) // Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine @@ -84,7 +98,7 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI // über den Fehlercode. var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { - return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm) + return s.updateExisting(ctx, res, subjUser, subjGroup, perm) } if err != nil { return nil, fmt.Errorf("grant access: %w", err) @@ -95,16 +109,17 @@ func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectI return &e, nil } -func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) { +func (s *Store) updateExisting(ctx context.Context, res resource, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) { var e Entry err := s.db.QueryRow(ctx, `UPDATE acl_entries SET permission = $1 - WHERE resource_folder_id IS NOT DISTINCT FROM $2 - AND resource_file_id IS NOT DISTINCT FROM $3 - AND subject_user_id IS NOT DISTINCT FROM $4 - AND subject_group_id IS NOT DISTINCT FROM $5 + WHERE resource_folder_id IS NOT DISTINCT FROM $2 + AND resource_file_id IS NOT DISTINCT FROM $3 + AND resource_calendar_id IS NOT DISTINCT FROM $4 + AND subject_user_id IS NOT DISTINCT FROM $5 + AND subject_group_id IS NOT DISTINCT FROM $6 RETURNING id, permission, created_at`, - perm, folderID, fileID, subjUser, subjGroup, + perm, res.folder, res.file, res.calendar, subjUser, subjGroup, ).Scan(&e.ID, &e.Permission, &e.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -126,8 +141,9 @@ func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error { tag, err := s.db.Exec(ctx, `DELETE FROM acl_entries e WHERE e.id = $1 - AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2) - OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`, + AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2) + OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) + OR EXISTS (SELECT 1 FROM cal_calendars c WHERE c.id = e.resource_calendar_id AND c.owner_id = $2) )`, entryID, ownerID) if err != nil { return fmt.Errorf("revoke access: %w", err) @@ -147,6 +163,10 @@ func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, err return s.list(ctx, `e.resource_file_id = $1`, fileID) } +func (s *Store) ListForCalendar(ctx context.Context, calendarID uuid.UUID) ([]Entry, error) { + return s.list(ctx, `e.resource_calendar_id = $1`, calendarID) +} + func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) { rows, err := s.db.Query(ctx, ` SELECT e.id, @@ -286,3 +306,18 @@ func subjectTypeName(isGroup bool) string { } return "user" } + +// CalendarOwner liefert, wem ein Kalender gehört – die Frage vor jeder +// Verwaltung seiner Freigaben. +func (s *Store) CalendarOwner(ctx context.Context, calendarID uuid.UUID) (uuid.UUID, error) { + var owner uuid.UUID + err := s.db.QueryRow(ctx, + `SELECT owner_id FROM cal_calendars WHERE id = $1`, calendarID).Scan(&owner) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, ErrNotFound + } + if err != nil { + return uuid.Nil, fmt.Errorf("load calendar owner: %w", err) + } + return owner, nil +} diff --git a/backend/internal/calendar/access.go b/backend/internal/calendar/access.go new file mode 100644 index 0000000..33c044b --- /dev/null +++ b/backend/internal/calendar/access.go @@ -0,0 +1,174 @@ +package calendar + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + + "aether/internal/acl" +) + +// Wer darf was mit einem Kalender. +// +// **Alle Wege laufen hier zusammen.** CalDAV und die Weboberfläche sind zwei +// völlig verschiedene Zugänge zu denselben Daten – ein Recht, das nur einer +// von beiden prüft, ist kein Recht. Deshalb gibt es genau eine Antwort auf +// „darf dieses Konto diesen Kalender", und beide Zugänge holen sie hier. + +// Access ist ein Kalender samt dem, was der Anfragende damit darf. +type Access struct { + Calendar + Permission acl.Permission `json:"permission"` + // Shared: Der Kalender gehört jemand anderem. + Shared bool `json:"shared"` + // OwnerName steht in der Oberfläche neben dem Namen – ein fremder + // Kalender ohne sichtbaren Absender ist ein Rätsel. + OwnerName string `json:"owner_name,omitempty"` + // ViaGroup nennt die Gruppe, über die der Zugriff kommt. + ViaGroup string `json:"via_group,omitempty"` +} + +func (a *Access) CanWrite() bool { return a.Permission == acl.PermWrite } + +// Ref ist die Adresse in CalDAV. +// +// Bei einem **fremden** Kalender immer die UUID, nie das Pfadsegment: Das +// Segment hat sich der Eigentümer ausgesucht ("…/arbeit/"), und zwei Leute +// dürfen denselben Namen wählen. Unter dem Kalenderbestand des Empfängers +// stünden dann zwei Kalender an derselben Adresse – und welcher gemeint ist, +// entschiede der Zufall. +func (a *Access) Ref() string { + if a.Shared { + return a.Calendar.ID.String() + } + return a.Calendar.Ref() +} + +// ListAccessible liefert alles, was userID sehen darf: die eigenen Kalender +// und die ihm freigegebenen. Eigene zuerst – sie sind die, die man sucht. +func (s *Store) ListAccessible(ctx context.Context, userID uuid.UUID) ([]Access, error) { + own, err := s.ListCalendars(ctx, userID) + if err != nil { + return nil, err + } + out := make([]Access, 0, len(own)) + for i := range own { + out = append(out, Access{Calendar: own[i], Permission: acl.PermWrite}) + } + + if s.rights == nil { + return out, nil + } + geteilt, err := s.rights.SharedCalendars(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list shared calendars: %w", err) + } + for _, sc := range geteilt { + cal, err := s.getCalendarAny(ctx, sc.CalendarID) + if errors.Is(err, ErrNotFound) { + continue // zwischenzeitlich gelöscht + } + if err != nil { + return nil, err + } + out = append(out, Access{ + Calendar: *cal, + Permission: sc.Permission, + Shared: true, + OwnerName: sc.OwnerName, + ViaGroup: sc.ViaGroup, + }) + } + return out, nil +} + +// ResolveRef findet den Kalender hinter einer CalDAV-Adresse – eigener oder +// freigegebener. +// +// Die Reihenfolge ist Absicht: **erst die eigenen.** Sonst könnte ein fremder +// Kalender, dessen UUID zufällig wie ein eigenes Pfadsegment aussieht, den +// eigenen verdecken. +func (s *Store) ResolveRef(ctx context.Context, userID uuid.UUID, ref string) (*Access, error) { + cal, err := s.GetCalendarByRef(ctx, userID, ref) + if err == nil { + return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } + + // Ein freigegebener Kalender steht unter seiner UUID. Alles andere kann + // hier nichts mehr treffen. + id, parseErr := uuid.Parse(ref) + if parseErr != nil { + return nil, ErrNotFound + } + return s.RequireCalendar(ctx, userID, id, false) +} + +// RequireCalendar ist die Prüfung, die jeder schreibende und lesende Zugriff +// durchläuft – aus CalDAV wie aus der Weboberfläche. +// +// Ohne Zugriff kommt **ErrNotFound**, nicht „verboten": Ein 403 verriete, dass +// es den Kalender gibt. Dieselbe Regel wie überall sonst in Aether. +func (s *Store) RequireCalendar(ctx context.Context, userID, calendarID uuid.UUID, needWrite bool) (*Access, error) { + cal, err := s.getCalendarAny(ctx, calendarID) + if err != nil { + return nil, err + } + if cal.OwnerID == userID { + return &Access{Calendar: *cal, Permission: acl.PermWrite}, nil + } + if s.rights == nil { + return nil, ErrNotFound + } + + perm, err := s.rights.ResolveCalendar(ctx, userID, calendarID) + if errors.Is(err, acl.ErrNoAccess) || errors.Is(err, acl.ErrNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if needWrite && perm != acl.PermWrite { + // Hier **darf** es ein 403 sein: Dass es den Kalender gibt, weiß der + // Anfragende bereits – er darf ihn ja lesen. + return nil, ErrReadOnly + } + + a := &Access{Calendar: *cal, Permission: perm, Shared: true} + // Name des Eigentümers nur nachschlagen, wenn er auch angezeigt wird. + if name, err := s.ownerName(ctx, cal.OwnerID); err == nil { + a.OwnerName = name + } + return a, nil +} + +// ErrReadOnly: Der Kalender ist sichtbar, aber nur lesend freigegeben. +var ErrReadOnly = errors.New("calendar: read-only share") + +// getCalendarAny lädt einen Kalender **ohne** Rechteprüfung. Privat, und das +// muss so bleiben: Jeder Aufruf von außen ginge an der Prüfung vorbei. +func (s *Store) getCalendarAny(ctx context.Context, id uuid.UUID) (*Calendar, error) { + var c Calendar + err := s.db.QueryRow(ctx, + `SELECT id, owner_id, name, color, description, supports_todo, timezone, + COALESCE(path_segment, ''), ctag, sync_floor, created_at + FROM cal_calendars WHERE id = $1`, id, + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, + &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) + if err != nil { + return nil, ErrNotFound + } + return &c, nil +} + +func (s *Store) ownerName(ctx context.Context, ownerID uuid.UUID) (string, error) { + var name string + err := s.db.QueryRow(ctx, + `SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = $1`, + ownerID).Scan(&name) + return name, err +} diff --git a/backend/internal/calendar/api.go b/backend/internal/calendar/api.go index 3ebd5c4..3b66bf3 100644 --- a/backend/internal/calendar/api.go +++ b/backend/internal/calendar/api.go @@ -40,7 +40,10 @@ func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusInternalServerError, "list calendars failed") return } - cals, err := h.store.ListCalendars(r.Context(), user.ID) + // Eigene **und** freigegebene. Jeder Eintrag trägt sein Recht mit, damit + // die Oberfläche einen nur lesbaren Kalender gar nicht erst zum + // Bearbeiten anbietet. + cals, err := h.store.ListAccessible(r.Context(), user.ID) if err != nil { response.Error(w, http.StatusInternalServerError, "list calendars failed") return @@ -125,7 +128,8 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusNotFound, "calendar not found") return } - cal, err := h.store.GetCalendar(r.Context(), user.ID, id) + // Ausgeben darf, wer lesen darf – auch bei einem freigegebenen Kalender. + zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, false) if err == ErrNotFound { response.Error(w, http.StatusNotFound, "calendar not found") return @@ -134,6 +138,7 @@ func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusInternalServerError, "load calendar failed") return } + cal := &zugriff.Calendar objs, err := h.store.ListObjects(r.Context(), cal.ID) if err != nil { response.Error(w, http.StatusInternalServerError, "list objects failed") @@ -187,16 +192,22 @@ func (h *APIHandler) Import(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusNotFound, "calendar not found") return } - cal, err := h.store.GetCalendar(r.Context(), user.ID, id) + // Hineinschreiben darf nur, wer schreiben darf. + zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, true) if err == ErrNotFound { response.Error(w, http.StatusNotFound, "calendar not found") return } + if err == ErrReadOnly { + response.Error(w, http.StatusForbidden, "calendar is shared read-only") + return + } if err != nil { response.Error(w, http.StatusInternalServerError, "load calendar failed") return } + cal := &zugriff.Calendar r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MiB – reicht für sehr große Kalender src, err := ical.NewDecoder(r.Body).Decode() if err != nil { diff --git a/backend/internal/calendar/backend.go b/backend/internal/calendar/backend.go index 13c6534..66f2e40 100644 --- a/backend/internal/calendar/backend.go +++ b/backend/internal/calendar/backend.go @@ -120,7 +120,10 @@ func parseCalendarPath(p string) (ref string, rest string, err error) { return parts[1], rest, nil } -func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *Calendar, error) { +// 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 @@ -129,7 +132,7 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C if err != nil { return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err) } - cal, err := b.store.GetCalendarByRef(ctx, u.ID, ref) + cal, err := b.store.ResolveRef(ctx, u.ID, ref) if errors.Is(err, ErrNotFound) { return nil, nil, webdav.NewHTTPError(http.StatusNotFound, err) } @@ -139,14 +142,36 @@ func (b *Backend) resolveCalendar(ctx context.Context, p string) (*auth.User, *C return u, cal, nil } -func toCalDAVCalendar(username string, c *Calendar) caldav.Calendar { +// 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: c.Name, + Name: name, Description: c.Description, MaxResourceSize: 5 * 1024 * 1024, SupportedComponentSet: comps, @@ -194,13 +219,13 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) return nil, fmt.Errorf("ensure default calendar: %w", err) } - all, err := b.store.ListCalendars(ctx, u.ID) + 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, c := range all { - out[i] = toCalDAVCalendar(u.Username, &c) + for i := range all { + out[i] = toCalDAVCalendar(u.Username, &all[i]) } return out, nil } @@ -323,7 +348,7 @@ func etagFor(data string) string { } func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { - u, calRow, err := b.resolveCalendar(ctx, p) + u, calRow, err := b.resolveWritable(ctx, p) if err != nil { return nil, err } @@ -366,7 +391,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Cal } func (b *Backend) DeleteCalendarObject(ctx context.Context, p string) error { - _, calRow, err := b.resolveCalendar(ctx, p) + _, calRow, err := b.resolveWritable(ctx, p) if err != nil { return err } diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go index bb953aa..b66acaf 100644 --- a/backend/internal/calendar/events_api.go +++ b/backend/internal/calendar/events_api.go @@ -393,7 +393,8 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusNotFound, "calendar not found") return } - if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { + // Lesen genügt: Ein freigegebener Kalender soll sich ansehen lassen. + if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, false); err != nil { response.Error(w, http.StatusNotFound, "calendar not found") return } @@ -552,13 +553,17 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption { return opt } +// calendarOf steht vor **jedem schreibenden** Zugriff der Weboberfläche – +// Anlegen, Ändern, Umhängen, Löschen. Deshalb verlangt es das Schreibrecht: +// Ein nur lesend freigegebener Kalender darf sich ansehen lassen und trotzdem +// nichts annehmen. func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) { user := auth.UserFromContext(r.Context()) calID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { return uuid.UUID{}, false } - if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { + if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, true); err != nil { return uuid.UUID{}, false } return calID, true @@ -658,10 +663,10 @@ func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusBadRequest, "calendar_id is required") return } - // Der Zielkalender muss demselben Konto gehören. Ohne diese Prüfung + // In den Zielkalender muss geschrieben werden dürfen. Ohne diese Prüfung // schöbe man Termine in fremde Kalender – und ein fremder Kalender darf // sich nicht einmal als vorhanden verraten, deshalb 404 statt 403. - if _, err := h.store.GetCalendar(r.Context(), user.ID, toCal); err != nil { + if _, err := h.store.RequireCalendar(r.Context(), user.ID, toCal, true); err != nil { response.Error(w, http.StatusNotFound, "calendar not found") return } diff --git a/backend/internal/calendar/propfind.go b/backend/internal/calendar/propfind.go index b119316..b1ebac5 100644 --- a/backend/internal/calendar/propfind.go +++ b/backend/internal/calendar/propfind.go @@ -211,15 +211,19 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string { // 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 { - cals, err := h.store.ListCalendars(ctx, user.ID) + // **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] + byRef[cals[i].Ref()] = &cals[i].Calendar } const closeTag = "" diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go index cf2384d..580a8b7 100644 --- a/backend/internal/calendar/store.go +++ b/backend/internal/calendar/store.go @@ -10,6 +10,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" + + "aether/internal/acl" ) var ErrNotFound = errors.New("calendar: not found") @@ -21,10 +23,14 @@ var ErrConflict = errors.New("calendar: uid already exists in target calendar") type Store struct { db *pgxpool.Pool + // rights beantwortet „wer darf was" – dieselbe Tabelle, die auch über + // Ordner und Dateien entscheidet. Darf nil sein: Dann gibt es schlicht + // keine freigegebenen Kalender, und alles Übrige funktioniert weiter. + rights *acl.Store } -func NewStore(db *pgxpool.Pool) *Store { - return &Store{db: db} +func NewStore(db *pgxpool.Pool, rights *acl.Store) *Store { + return &Store{db: db, rights: rights} } func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) { diff --git a/backend/internal/calendar/sync.go b/backend/internal/calendar/sync.go index e63c0c4..7664cd7 100644 --- a/backend/internal/calendar/sync.go +++ b/backend/internal/calendar/sync.go @@ -99,11 +99,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *aut http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden) return true } - cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref) + 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 { @@ -124,7 +125,7 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *aut return true } - writeSyncResponse(w, user, cal, changes, req.wantsCalendarData()) + writeSyncResponse(w, user, ref, changes, req.wantsCalendarData()) return true } @@ -175,8 +176,12 @@ func writeInvalidToken(w http.ResponseWriter) { ``) } +// 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, - cal *Calendar, changes *Changes, withData bool) { + ref string, changes *Changes, withData bool) { var b strings.Builder b.WriteString(xml.Header) @@ -189,7 +194,7 @@ func writeSyncResponse(w http.ResponseWriter, user *auth.User, // CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die // sähen dann bei jedem Durchgang eine Änderung, wo keine ist. fmt.Fprintf(&b, `%s%s`, - xmlText(objectPath(user.Username, cal.Ref(), o.UID)), quoteETag(o.ETag)) + xmlText(objectPath(user.Username, ref, o.UID)), quoteETag(o.ETag)) if withData { fmt.Fprintf(&b, `%s`, xmlText(o.Data)) } @@ -201,7 +206,7 @@ func writeSyncResponse(w http.ResponseWriter, user *auth.User, // aber eine Eigenschaft nicht liefern". for _, uid := range changes.Deleted { fmt.Fprintf(&b, `%sHTTP/1.1 404 Not Found`, - xmlText(objectPath(user.Username, cal.Ref(), uid))) + xmlText(objectPath(user.Username, ref, uid))) } fmt.Fprintf(&b, `%s`, syncToken(changes.Token)) diff --git a/backend/pkg/db/migrations/000025_calendar_share.down.sql b/backend/pkg/db/migrations/000025_calendar_share.down.sql new file mode 100644 index 0000000..491b89e --- /dev/null +++ b/backend/pkg/db/migrations/000025_calendar_share.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_acl_calendar; +DROP INDEX IF EXISTS idx_acl_group_calendar; +DROP INDEX IF EXISTS idx_acl_user_calendar; +ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource; +ALTER TABLE acl_entries DROP COLUMN IF EXISTS resource_calendar_id; +ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource + CHECK (num_nonnulls(resource_folder_id, resource_file_id) = 1); diff --git a/backend/pkg/db/migrations/000025_calendar_share.up.sql b/backend/pkg/db/migrations/000025_calendar_share.up.sql new file mode 100644 index 0000000..06863b8 --- /dev/null +++ b/backend/pkg/db/migrations/000025_calendar_share.up.sql @@ -0,0 +1,31 @@ +-- Kalender an andere Konten freigeben. +-- +-- **Keine zweite Rechtetabelle.** acl_entries beantwortet fuer Ordner und +-- Dateien bereits genau diese Frage -- wer darf was, direkt oder ueber eine +-- Gruppe. Ein eigenes cal_shares daneben waere ein zweites Rechtesystem mit +-- eigener Gruppenaufloesung, eigenem Widerrufen, eigenem Pruefpfad; zwei +-- Systeme fuer dieselbe Frage laufen auseinander, und die Luecke faellt erst +-- auf, wenn jemand sieht, was er nicht sehen soll. +-- +-- Nebeneffekt, der die Entscheidung traegt: Das Freigeben an eine **Gruppe** +-- kommt damit ohne eine einzige Zeile zusaetzlichen Code. +ALTER TABLE acl_entries + ADD COLUMN IF NOT EXISTS resource_calendar_id UUID REFERENCES cal_calendars(id) ON DELETE CASCADE; + +-- Genau eine Ressource je Eintrag -- jetzt zu dritt. +ALTER TABLE acl_entries DROP CONSTRAINT IF EXISTS acl_one_resource; +ALTER TABLE acl_entries ADD CONSTRAINT acl_one_resource + CHECK (num_nonnulls(resource_folder_id, resource_file_id, resource_calendar_id) = 1); + +-- Zwei Freigaben derselben Ressource an denselben Empfaenger waeren +-- mehrdeutig: welches Recht gilt dann? +CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_user_calendar + ON acl_entries (subject_user_id, resource_calendar_id) + WHERE subject_user_id IS NOT NULL AND resource_calendar_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_acl_group_calendar + ON acl_entries (subject_group_id, resource_calendar_id) + WHERE subject_group_id IS NOT NULL AND resource_calendar_id IS NOT NULL; + +-- Die Zugriffspruefung laeuft bei jedem CalDAV-Durchgang. +CREATE INDEX IF NOT EXISTS idx_acl_calendar + ON acl_entries (resource_calendar_id) WHERE resource_calendar_id IS NOT NULL; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 396b288..482d4a0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -355,12 +355,15 @@ export const aclApi = { listForFolder: (id: string) => request('GET', `/folders/${id}/acl`), listForFile: (id: string) => request('GET', `/files/${id}/acl`), + listForCalendar: (id: string) => request('GET', `/calendars/${id}/acl`), grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) => request('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }), grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) => request('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }), + grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') => + request('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }), revoke: (entryId: string) => request('DELETE', `/acl/${entryId}`), } @@ -959,6 +962,20 @@ export interface CalendarItem { timezone: string ctag: number created_at: string + /** Was man mit diesem Kalender darf. Bei eigenen immer 'write'. */ + permission: 'read' | 'write' + /** Der Kalender gehört jemand anderem. */ + shared: boolean + /** Wem – steht in der Liste neben dem Namen. Ein fremder Kalender ohne + * sichtbaren Absender ist ein Rätsel. */ + owner_name?: string + /** Über welche Gruppe der Zugriff kommt, falls nicht persönlich. */ + via_group?: string +} + +/** Kurz: Darf ich diesen Kalender ändern? */ +export function canEditCalendar(c: CalendarItem): boolean { + return c.permission !== 'read' } /** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/ diff --git a/frontend/src/components/ShareWithDialog.tsx b/frontend/src/components/ShareWithDialog.tsx index 0d100b1..1032304 100644 --- a/frontend/src/components/ShareWithDialog.tsx +++ b/frontend/src/components/ShareWithDialog.tsx @@ -7,7 +7,7 @@ import { useOverlayDismiss } from '../hooks/useOverlayDismiss' interface Props { /** Was freigegeben wird – Ordner oder einzelne Datei. */ - resource: { type: 'folder' | 'file'; id: string; name: string } + resource: { type: 'folder' | 'file' | 'calendar'; id: string; name: string } onClose: () => void } @@ -35,8 +35,8 @@ export default function ShareWithDialog({ resource, onClose }: Props) { setLoading(true) try { const [list, people] = await Promise.all([ - resource.type === 'folder' - ? aclApi.listForFolder(resource.id) + resource.type === 'folder' ? aclApi.listForFolder(resource.id) + : resource.type === 'calendar' ? aclApi.listForCalendar(resource.id) : aclApi.listForFile(resource.id), aclApi.recipients(), ]) @@ -55,7 +55,9 @@ export default function ShareWithDialog({ resource, onClose }: Props) { setSaving(true) setError('') try { - if (resource.type === 'folder') { + if (resource.type === 'calendar') { + await aclApi.grantCalendar(resource.id, chosen.id, chosen.type, permission) + } else if (resource.type === 'folder') { await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission) } else { await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission) @@ -167,6 +169,13 @@ export default function ShareWithDialog({ resource, onClose }: Props) { + {resource.type === 'calendar' && ( +

+ The calendar shows up in their app too — web and CalDAV alike. + With “can edit” they may add and change events; renaming or + deleting the calendar stays with you. +

+ )} {resource.type === 'folder' && (

Everything inside this folder is shared too, including anything added later. diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index b377bc4..87f0067 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -3,7 +3,7 @@ import { type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent, } from 'react' import { - calendarApi, APIError, + calendarApi, APIError, canEditCalendar, type CalendarItem, type CalendarEvent, type EventInput, } from '../api/client' import PageHeader from '../components/PageHeader' @@ -16,7 +16,8 @@ import { addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek, } from '../components/calendar/dates' import { useEventDrag } from '../components/calendar/useEventDrag' -import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons' +import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons' +import ShareWithDialog from '../components/ShareWithDialog' type ViewMode = 'month' | 'week' | 'list' @@ -109,6 +110,8 @@ export default function CalendarPage() { const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null) const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null) const [deleteCalendar, setDeleteCalendar] = useState(null) + /** Kalender, dessen Freigaben gerade verwaltet werden. */ + const [shareCalendar, setShareCalendar] = useState(null) const [deleteEvent, setDeleteEvent] = useState< { calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null) @@ -182,6 +185,15 @@ export default function CalendarPage() { const byDay = useMemo(() => groupByDay(allEvents), [allEvents]) + /** + * Kalender, in die dieses Konto schreiben darf. + * + * Die Kalenderwahl im Termin-Dialog zeigt nur diese. Einen nur lesbaren + * Kalender dort anzubieten hieße, jemanden ein Formular ausfüllen zu lassen, + * das der Server danach ablehnt. + */ + const writableCalendars = useMemo(() => calendars.filter(canEditCalendar), [calendars]) + function toggleVisible(id: string) { setHidden(prev => { const next = new Set(prev) @@ -412,7 +424,10 @@ export default function CalendarPage() { const on = !hidden.has(cal.id) // Ablegefläche fürs Ziehen. Der eigene Kalender des gezogenen // Termins ist keins – dorthin gibt es nichts zu verschieben. - const isTarget = !!drag && drag.ev.calendarId !== cal.id + // Ablegefläche nur, wo auch geschrieben werden darf. Ein Ziel + // anzubieten, das der Server danach ablehnt, ist schlechter als + // gar keines: Man hat den Termin schon losgelassen. + const isTarget = !!drag && drag.ev.calendarId !== cal.id && canEditCalendar(cal) const isOver = isTarget && drag!.overId === cal.id return (

{cal.name} + {/* Fremde Kalender tragen ihren Absender. Zwei „Privat" + nebeneinander wären sonst nicht zu unterscheiden. */} + {cal.shared && cal.owner_name && ( + + {' · '}{cal.owner_name}{cal.permission === 'read' ? ' (nur lesen)' : ''} + + )} setCalDialog({ calendar: cal })} + onShare={() => setShareCalendar(cal)} onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')} onImport={() => triggerImport(cal.id)} onDelete={() => setDeleteCalendar(cal)} @@ -485,7 +510,7 @@ export default function CalendarPage() { background: 'var(--color-surface)', borderTop: '1px solid var(--color-border)', boxShadow: '0 -4px 16px rgba(0,0,0,0.3)', }}> - {calendars.filter(c => c.id !== drag.ev.calendarId).map(cal => ( + {calendars.filter(c => c.id !== drag.ev.calendarId && canEditCalendar(c)).map(cal => (
setDeleteEvent({ @@ -547,6 +572,13 @@ export default function CalendarPage() { {calDialog && ( setCalDialog(null)} /> )} + {shareCalendar && ( + setShareCalendar(null)} + /> + )} + {deleteCalendar && ( void; onExport: () => void; onImport: () => void; onDelete: () => void +/** + * Das Menü eines Kalenders. + * + * Bei einem **fremden** Kalender bleiben nur die Punkte stehen, die auch + * gehen: ansehen und ausgeben. Bearbeiten, Löschen, Freigeben gehören dem + * Eigentümer – ein Menüpunkt, der immer scheitert, ist schlimmer als keiner. + * Der Server lehnt sie ohnehin ab; hier geht es darum, gar nicht erst + * hinzuführen. + */ +function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrite }: { + onRename: () => void; onExport: () => void; onImport: () => void + onDelete: () => void; onShare: () => void + own: boolean; canWrite: boolean }) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -609,10 +652,11 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: { background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8, boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden', }}> - } label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} /> + {own && } label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />} + {own && } label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />} } label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} /> - { setOpen(false); onImport() }} /> - } label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} /> + {canWrite && { setOpen(false); onImport() }} />} + {own && } label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />}
)}
diff --git a/kalender-plan.md b/kalender-plan.md index e8fddfa..30e7edb 100644 --- a/kalender-plan.md +++ b/kalender-plan.md @@ -86,7 +86,7 @@ Die abgeleiteten Spalten sind nur zum Vorsortieren da. | **1** | CalDAV lesend und schreibend, ein Kalender, Anmeldung über App-Passwörter, Auffindpfade (`/.well-known/caldav`, `current-user-principal`, `calendar-home-set`). Ziel: DAVx5 gleicht ab. | ✅ | | **2** | Mehrere Kalender, Farbe, Umbenennen, Löschen. Export als `.ics`, Import einer bestehenden Datei. | ✅ | | **3** | Weboberfläche in Aether: Monat, Woche, Liste; Termin anlegen, ändern, löschen. Der Teil, der Spaß macht – und dessen Fehler man **sieht**. | ✅ | -| **4** | `sync-collection` ✅, Aufgabenansicht, Teilen mit anderen Nutzern, öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | läuft | +| **4** | `sync-collection` ✅, Teilen mit anderen Nutzern ✅, Aufgabenansicht (vertagt), öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | läuft | | **5** | Kontakte (CardDAV). Dieselbe Maschinerie, dieselbe Bibliothek – und **einfacher**, weil es dort keine Wiederholungen gibt. | später entscheiden | Über die Phasen hinaus dazugekommen, weil es sich beim Prüfen als nötig @@ -162,10 +162,35 @@ Fall, auf den es ankommt: *ohne Änderung kommt nichts zurück*. 1. **Derselbe Kalender auf mehreren Geräten.** Das ist der Sinn der Sache und ab Phase 1 da. 2. **Ein Kalender, den ein anderer Nutzer sieht** – lesend oder schreibend, wie - das Teilen in Nextcloud. Geht, und es ist keine Protokollarbeit: Wir kennen - unsere Nutzer und unsere Kalender, also ist es eine Tabelle - (`kalender × nutzer × recht`) und eine Bedingung in `ListCalendars`. Der - Client sieht danach schlicht einen Kalender mehr in seiner Liste. **Phase 4.** + das Teilen in Nextcloud. ✅ **12.09.2026.** + + Gebaut wurde es **nicht** als eigene Tabelle, wie hier ursprünglich geplant. + `acl_entries` beantwortet für Ordner und Dateien längst genau diese Frage – + wer darf was, direkt oder über eine Gruppe. Ein zweites Rechtesystem daneben + hieße zwei Antworten auf dieselbe Frage, mit eigener Gruppenauflösung, + eigenem Widerruf, eigenem Prüfpfad; die Lücke dazwischen fällt erst auf, + wenn jemand sieht, was er nicht sehen soll. Migration 25 hängt deshalb nur + eine dritte Ressourcenspalte an (`resource_calendar_id`). + + Der Gewinn dabei: **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 Eigentümers. Das Segment hat sich der Eigentümer + ausgesucht („…/arbeit/"), und zwei Leute dürfen denselben Namen wählen – + im Kalenderbestand des Empfängers lägen dann zwei Kalender auf derselben + Adresse (`Access.Ref`). + * **Alle Wege laufen durch `RequireCalendar`.** CalDAV und die + Weboberfläche sind zwei getrennte Zugänge zu denselben Daten; ein Recht, + das nur einer von beiden prüft, ist kein Recht. Im CalDAV-Teil genügte + dafür, `resolveCalendar` umzustellen – dort kommt jede Operation vorbei. + * **Wer ein Recht geliehen hat, darf es nicht weiterverleihen.** Auch mit + Schreibrecht bleiben Umbenennen, Löschen und Freigeben beim Eigentümer. + + Geprüft in `scripts/kalender-teilen-selbsttest.py` (41 Prüfungen), und zwar + jede über **beide** Zugänge. Der Test beginnt damit, dass der Empfänger + vorher nichts sieht – ohne diesen Anfang beweist der Rest nichts. 3. **Ein Kalender an der Adresse eines Links**, den jemand ohne Konto abonnieren kann – nur lesend, wie ein Freigabelink für Dateien. Klein, und Aether hat die Freigabelinks schon. **Phase 4.** diff --git a/scripts/kalender-teilen-selbsttest.py b/scripts/kalender-teilen-selbsttest.py new file mode 100644 index 0000000..37534c2 --- /dev/null +++ b/scripts/kalender-teilen-selbsttest.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Selbsttest: Kalender an andere Konten freigeben. + + python3 scripts/kalender-teilen-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT] + +Eine Rechtefunktion ist genau so viel wert wie ihre engste Stelle. Aether hat +**zwei** Zugänge zu denselben Kalendern – die Weboberfläche (JSON) und CalDAV –, +und beide prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist +kein Recht; deshalb geht hier jede Prüfung über **beide** Wege. + +Geprüft wird in dieser Reihenfolge, und die Reihenfolge ist der Punkt: + + 1. **Vorher sieht der Empfänger nichts.** Ohne diesen Anfang beweist der + Rest nichts – ein Kalender, den ohnehin jeder sieht, ist auch nach dem + Freigeben sichtbar. + 2. Nur lesend: sehen ja, ändern nein. + 3. Schreibend: ändern ja. + 4. Was auch schreibend **nicht** geht: den fremden Kalender umbenennen, + löschen oder weiterverschenken. + 5. Über eine Gruppe – derselbe Weg, ohne eine Zeile eigenen Code. + 6. Widerruf: danach wieder unsichtbar, auf beiden Wegen. + +Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am +Ende alles weg. +""" +import base64 +import datetime +import http.cookiejar +import json +import re +import sys +import urllib.error +import urllib.request +import uuid + +BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/") +USER = sys.argv[2] if len(sys.argv) > 2 else "admin" +PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123" + +ok_count = 0 +fail = [] + + +def check(name, cond, detail=""): + global ok_count + if cond: + ok_count += 1 + print(f" \033[32mok\033[0m {name}") + else: + fail.append(name) + print(f" \033[31mFEHL\033[0m {name} {detail}") + + +def sitzung(): + jar = http.cookiejar.CookieJar() + return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + + +def ruf(opener, method, path, body=None, headers=None): + hdrs = dict(headers or {}) + data = None + if body is not None: + data = json.dumps(body).encode() + hdrs["Content-Type"] = "application/json" + req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method) + try: + with opener.open(req) as r: + text = r.read().decode() + return r.status, (json.loads(text).get("data") if text else None) + except urllib.error.HTTPError as e: + return e.code, None + + +def anmelden(user, passwort): + o = sitzung() + st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort}) + assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}" + return o, {"X-CSRF-Token": d["csrf_token"]} + + +def dav(basic, method, path, body=None, headers=None): + hdrs = {"Authorization": basic} + hdrs.update(headers or {}) + data = body.encode() if isinstance(body, str) else body + req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method) + try: + with urllib.request.urlopen(req) as r: + return r.status, r.read().decode(errors="replace") + except urllib.error.HTTPError as e: + return e.code, e.read().decode(errors="replace") + + +def kalenderliste_dav(basic, username): + """Die Kalender, die dieser Zugang über CalDAV sieht.""" + body = ('' + '') + st, text = dav(basic, "PROPFIND", f"/caldav/calendars/{username}/", body, + {"Depth": "1", "Content-Type": "application/xml"}) + return st, re.findall(r"]*>([^<]+)", text), text + + +# Die Terminliste hat einen Standardzeitraum (ein Monat zurueck bis ein Jahr +# voraus). Ein Testtermin mit festem Datum faellt irgendwann heraus -- und dann +# besteht die Pruefung "nichts angekommen" **leer**, was schlimmer ist als ein +# Fehlschlag. Deshalb relativ zum heutigen Tag, und der Zeitraum wird bei jeder +# Abfrage ausdruecklich mitgegeben. +_morgen = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1) +START = _morgen.strftime("%Y%m%dT090000Z") +ENDE = _morgen.strftime("%Y%m%dT100000Z") +ZEITRAUM = ("?from=" + (_morgen - datetime.timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z") + + "&to=" + (_morgen + datetime.timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z")) + +ICS = ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Selbsttest//DE\r\n" + "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n" + "DTSTART:{start}\r\nDTEND:{ende}\r\n" + "SUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n") + +print(f"Kalender-Freigabe-Selbsttest gegen {BASE}\n") + +besitzer, BES_CSRF = anmelden(USER, PASSWORT) + +marke = uuid.uuid4().hex[:6] +empf_name = f"teilen-empf-{marke}" +empf_pw = "Empf!" + uuid.uuid4().hex[:12] +angelegt = {"user": None, "group": None, "cal": None, "acl": []} + + +def aufraeumen(): + for eid in angelegt["acl"]: + ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF) + if angelegt["cal"]: + ruf(besitzer, "DELETE", f"/api/v1/calendars/{angelegt['cal']}", None, BES_CSRF) + if angelegt["group"]: + ruf(besitzer, "DELETE", f"/api/v1/admin/groups/{angelegt['group']}", None, BES_CSRF) + if angelegt["user"]: + ruf(besitzer, "DELETE", f"/api/v1/admin/users/{angelegt['user']}", None, BES_CSRF) + + +try: + st, empf = ruf(besitzer, "POST", "/api/v1/admin/users", + {"username": empf_name, "display_name": "Empfaenger", + "password": empf_pw, "is_admin": False, "quota_bytes": 0}, BES_CSRF) + assert st in (200, 201), f"Empfängerkonto anlegen fehlgeschlagen: {st}" + angelegt["user"] = empf["id"] + EMPF_ID = empf["id"] + + empfaenger, EMPF_CSRF = anmelden(empf_name, empf_pw) + + # App-Passwörter für den CalDAV-Weg – das Kontopasswort nimmt /caldav/ nicht. + def app_passwort(opener, csrf): + st, d = ruf(opener, "POST", "/api/v1/app-passwords", + {"name": "teilen-test-" + marke}, csrf) + assert st in (200, 201), f"App-Passwort fehlgeschlagen: {st}" + return d["password"] + + BES_DAV = "Basic " + base64.b64encode(f"{USER}:{app_passwort(besitzer, BES_CSRF)}".encode()).decode() + EMPF_DAV = "Basic " + base64.b64encode(f"{empf_name}:{app_passwort(empfaenger, EMPF_CSRF)}".encode()).decode() + + st, kal = ruf(besitzer, "POST", "/api/v1/calendars", + {"name": "Geteilt " + marke, "color": "#aa3366", "description": ""}, BES_CSRF) + assert st in (200, 201), f"Kalender anlegen fehlgeschlagen: {st}" + angelegt["cal"] = kal["id"] + CAL = kal["id"] + + uid = "teilen-" + marke + st, _ = dav(BES_DAV, "PUT", f"/caldav/calendars/{USER}/{CAL}/{uid}.ics", + ICS.format(uid=uid, summary="Besprechung", start=START, ende=ENDE), {"Content-Type": "text/calendar"}) + assert st in (201, 204), f"Termin anlegen fehlgeschlagen: {st}" + + def sichtbar_json(): + st, cals = ruf(empfaenger, "GET", "/api/v1/calendars", None, EMPF_CSRF) + return [c for c in (cals or []) if c["id"] == CAL] + + def sichtbar_dav(): + st, hrefs, _ = kalenderliste_dav(EMPF_DAV, empf_name) + return [h for h in hrefs if CAL in h] + + # ── 1. Vorher ──────────────────────────────────────────────────────────── + print("── Vorher: der Empfänger sieht nichts ──") + check("nicht in seiner Kalenderliste (Web)", sichtbar_json() == [], sichtbar_json()) + check("nicht in seinem Kalenderbestand (CalDAV)", sichtbar_dav() == [], sichtbar_dav()) + st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF) + check("Termine direkt abrufen: 404", st == 404, st) + st, _ = dav(EMPF_DAV, "GET", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics") + check("Termin über CalDAV: 404", st == 404, st) + + # ── 2. Nur lesend ──────────────────────────────────────────────────────── + print("\n── Nur lesend freigegeben ──") + st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl", + {"subject_id": EMPF_ID, "subject_type": "user", "permission": "read"}, + BES_CSRF) + check("Freigabe angelegt", st in (200, 201), st) + if eintrag: + angelegt["acl"].append(eintrag["id"]) + + treffer = sichtbar_json() + check("jetzt in der Kalenderliste (Web)", len(treffer) == 1, treffer) + if treffer: + check("als fremd gekennzeichnet", treffer[0].get("shared") is True, treffer[0]) + check("mit Recht „read\"", treffer[0].get("permission") == "read", treffer[0].get("permission")) + check("mit Namen des Eigentümers", bool(treffer[0].get("owner_name")), treffer[0].get("owner_name")) + check("jetzt auch im Kalenderbestand (CalDAV)", len(sichtbar_dav()) >= 1, sichtbar_dav()) + + st, ev = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF) + check("Termine lesbar (Web)", st == 200, st) + st, text = dav(EMPF_DAV, "GET", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics") + check("Termin lesbar (CalDAV)", st == 200 and "Besprechung" in text, st) + + print(" … und nichts davon lässt sich ändern:") + st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/events", + {"summary": "Eingeschmuggelt", "start": "2026-08-02T09:00:00Z", + "end": "2026-08-02T10:00:00Z", "all_day": False}, EMPF_CSRF) + check("Termin anlegen (Web) abgelehnt", st in (403, 404), st) + fremd_uid = "fremd-" + marke + st, _ = dav(EMPF_DAV, "PUT", f"/caldav/calendars/{empf_name}/{CAL}/{fremd_uid}.ics", + ICS.format(uid=fremd_uid, summary="Eingeschmuggelt", start=START, ende=ENDE), + {"Content-Type": "text/calendar"}) + check("Termin anlegen (CalDAV) abgelehnt", st == 403, st) + st, _ = dav(EMPF_DAV, "DELETE", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics") + check("Termin löschen (CalDAV) abgelehnt", st == 403, st) + st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/import", None, EMPF_CSRF) + check("Import abgelehnt", st in (400, 403, 404), st) + + # Und der Termin steht wirklich noch da – nicht nur der Statuscode stimmt. + st, ev = ruf(besitzer, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, BES_CSRF) + namen = [e.get("summary") for e in (ev or [])] + # **Erst prüfen, dass überhaupt etwas in der Liste steht.** Sonst besteht + # die nächste Zeile auch dann, wenn die Abfrage schlicht nichts liefert – + # und eine Prüfung, die leer besteht, prüft nichts. + check("die Terminliste des Eigentümers ist nicht leer", "Besprechung" in namen, namen) + check("beim Eigentümer ist nichts angekommen", "Eingeschmuggelt" not in namen, namen) + + # ── 3. Schreibend ──────────────────────────────────────────────────────── + print("\n── Auf schreibend hochgestuft ──") + st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl", + {"subject_id": EMPF_ID, "subject_type": "user", "permission": "write"}, + BES_CSRF) + check("Recht geändert statt doppelt angelegt", st in (200, 201), st) + + treffer = sichtbar_json() + check("Recht jetzt „write\"", treffer and treffer[0].get("permission") == "write", + treffer[0].get("permission") if treffer else None) + st, _ = dav(EMPF_DAV, "PUT", f"/caldav/calendars/{empf_name}/{CAL}/{fremd_uid}.ics", + ICS.format(uid=fremd_uid, summary="Vom Empfaenger", start=START, ende=ENDE), + {"Content-Type": "text/calendar"}) + check("Termin anlegen (CalDAV) geht jetzt", st in (201, 204), st) + st, ev = ruf(besitzer, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, BES_CSRF) + namen = [e.get("summary") for e in (ev or [])] + check("und kommt beim Eigentümer an", "Vom Empfaenger" in namen, namen) + + # ── 4. Was auch schreibend nicht geht ──────────────────────────────────── + print("\n── Grenzen des Schreibrechts ──") + st, _ = ruf(empfaenger, "PATCH", f"/api/v1/calendars/{CAL}", + {"name": "Uebernommen"}, EMPF_CSRF) + check("fremden Kalender umbenennen: abgelehnt", st in (403, 404), st) + st, _ = ruf(empfaenger, "DELETE", f"/api/v1/calendars/{CAL}", None, EMPF_CSRF) + check("fremden Kalender löschen: abgelehnt", st in (403, 404), st) + st, _ = dav(EMPF_DAV, "DELETE", f"/caldav/calendars/{empf_name}/{CAL}/") + check("fremden Kalender löschen (CalDAV): abgelehnt", st not in (200, 204), st) + st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/acl", None, EMPF_CSRF) + check("fremde Freigabeliste sehen: abgelehnt", st == 404, st) + st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/acl", + {"subject_id": EMPF_ID, "subject_type": "user", "permission": "read"}, EMPF_CSRF) + check("fremden Kalender weiterverschenken: abgelehnt", st == 404, st) + + # Der Kalender heißt noch, wie er hieß. + st, kals = ruf(besitzer, "GET", "/api/v1/calendars", None, BES_CSRF) + meiner = [c for c in (kals or []) if c["id"] == CAL] + check("Name unverändert", meiner and meiner[0]["name"] == "Geteilt " + marke, + meiner[0]["name"] if meiner else None) + + # ── 5. Über eine Gruppe ────────────────────────────────────────────────── + print("\n── Freigabe über eine Gruppe ──") + for eid in angelegt["acl"]: + ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF) + angelegt["acl"] = [] + check("nach Widerruf wieder unsichtbar", sichtbar_json() == [], sichtbar_json()) + + st, grp = ruf(besitzer, "POST", "/api/v1/admin/groups", + {"name": "teilen-gruppe-" + marke, "quota_bytes": 0}, BES_CSRF) + check("Gruppe angelegt", st in (200, 201), st) + if grp: + angelegt["group"] = grp["id"] + st, _ = ruf(besitzer, "POST", f"/api/v1/admin/groups/{grp['id']}/members", + {"user_id": EMPF_ID}, BES_CSRF) + check("Empfänger ist Mitglied", st in (200, 201, 204), st) + st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl", + {"subject_id": grp["id"], "subject_type": "group", "permission": "read"}, + BES_CSRF) + check("an die Gruppe freigegeben", st in (200, 201), st) + if eintrag: + angelegt["acl"].append(eintrag["id"]) + treffer = sichtbar_json() + check("über die Gruppe sichtbar (Web)", len(treffer) == 1, treffer) + check("über die Gruppe sichtbar (CalDAV)", len(sichtbar_dav()) >= 1, sichtbar_dav()) + if treffer: + check("Herkunft wird genannt", bool(treffer[0].get("via_group")), treffer[0].get("via_group")) + + # ── 6. Abgleich auf einem geteilten Kalender ───────────────────────────── + print("\n── sync-collection auf einem fremden Kalender ──") + body = ('' + '' + '1') + st, text = dav(EMPF_DAV, "REPORT", f"/caldav/calendars/{empf_name}/{CAL}/", body, + {"Depth": "1", "Content-Type": "application/xml"}) + check("Abgleich läuft (207)", st == 207, st) + check("die Adressen zeigen auf den Empfänger", + f"/caldav/calendars/{empf_name}/{CAL}/" in text, text[:200]) + + # ── 7. Widerruf ────────────────────────────────────────────────────────── + print("\n── Widerruf ──") + for eid in angelegt["acl"]: + st, _ = ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF) + check("Freigabe widerrufen", st in (200, 204), st) + angelegt["acl"] = [] + check("wieder unsichtbar (Web)", sichtbar_json() == [], sichtbar_json()) + check("wieder unsichtbar (CalDAV)", sichtbar_dav() == [], sichtbar_dav()) + st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF) + check("Termine wieder 404", st == 404, st) + +finally: + aufraeumen() + +print() +if fail: + print(f"\033[31m{len(fail)} fehlgeschlagen\033[0m: " + ", ".join(fail)) +print(f"{ok_count} bestanden, {len(fail)} fehlgeschlagen") +sys.exit(1 if fail else 0)