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>
This commit is contained in:
1 parent
8fdde182b7
commit
5d90c0a2d2
21 files changed
+1031
-60
No files matched your search
+30
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = "</response>"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
`<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,
|
||||
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, `<response><href>%s</href><propstat><prop><getetag>%s</getetag>`,
|
||||
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, `<C:calendar-data>%s</C:calendar-data>`, 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, `<response><href>%s</href><status>HTTP/1.1 404 Not Found</status></response>`,
|
||||
xmlText(objectPath(user.Username, cal.Ref(), uid)))
|
||||
xmlText(objectPath(user.Username, ref, uid)))
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<sync-token>%s</sync-token></multistatus>`, syncToken(changes.Token))
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -355,12 +355,15 @@ export const aclApi = {
|
||||
|
||||
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
|
||||
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
|
||||
listForCalendar: (id: string) => request<AclEntry[]>('GET', `/calendars/${id}/acl`),
|
||||
|
||||
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
|
||||
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
|
||||
grantCalendar: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: 'read' | 'write') =>
|
||||
request<AclEntry>('POST', `/calendars/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
|
||||
revoke: (entryId: string) => request<void>('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/
|
||||
|
||||
@@ -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) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resource.type === 'calendar' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{resource.type === 'folder' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Everything inside this folder is shared too, including anything added later.
|
||||
|
||||
@@ -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<CalendarItem | null>(null)
|
||||
/** Kalender, dessen Freigaben gerade verwaltet werden. */
|
||||
const [shareCalendar, setShareCalendar] = useState<CalendarItem | null>(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 (
|
||||
<div key={cal.id} className="aether-cal-row"
|
||||
@@ -442,9 +457,19 @@ export default function CalendarPage() {
|
||||
opacity: on ? 1 : 0.45,
|
||||
}}>
|
||||
{cal.name}
|
||||
{/* Fremde Kalender tragen ihren Absender. Zwei „Privat"
|
||||
nebeneinander wären sonst nicht zu unterscheiden. */}
|
||||
{cal.shared && cal.owner_name && (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 11.5 }}>
|
||||
{' · '}{cal.owner_name}{cal.permission === 'read' ? ' (nur lesen)' : ''}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<CalMenu
|
||||
own={!cal.shared}
|
||||
canWrite={canEditCalendar(cal)}
|
||||
onRename={() => 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 => (
|
||||
<div key={cal.id} data-cal-drop={cal.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
|
||||
borderRadius: 10, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0,
|
||||
@@ -529,7 +554,7 @@ export default function CalendarPage() {
|
||||
<EventDialog
|
||||
event={eventDialog.event}
|
||||
defaultStart={eventDialog.start}
|
||||
calendars={calendars}
|
||||
calendars={writableCalendars}
|
||||
calendarId={eventDialog.calId}
|
||||
onSave={saveEvent}
|
||||
onDelete={eventDialog.event ? () => setDeleteEvent({
|
||||
@@ -547,6 +572,13 @@ export default function CalendarPage() {
|
||||
{calDialog && (
|
||||
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
|
||||
)}
|
||||
{shareCalendar && (
|
||||
<ShareWithDialog
|
||||
resource={{ type: 'calendar', id: shareCalendar.id, name: shareCalendar.name }}
|
||||
onClose={() => setShareCalendar(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteCalendar && (
|
||||
<ConfirmDialog
|
||||
title="Kalender löschen"
|
||||
@@ -583,8 +615,19 @@ const navBtnStyle: CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}
|
||||
|
||||
function CalMenu({ onRename, onExport, onImport, onDelete }: {
|
||||
onRename: () => 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<HTMLDivElement>(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',
|
||||
}}>
|
||||
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
|
||||
{own && <MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />}
|
||||
{own && <MenuRow icon={<PeopleIcon />} label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />}
|
||||
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
|
||||
<MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />
|
||||
<MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
|
||||
{canWrite && <MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />}
|
||||
{own && <MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+30
-5
@@ -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.**
|
||||
|
||||
@@ -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 = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>')
|
||||
st, text = dav(basic, "PROPFIND", f"/caldav/calendars/{username}/", body,
|
||||
{"Depth": "1", "Content-Type": "application/xml"})
|
||||
return st, re.findall(r"<href[^>]*>([^<]+)</href>", 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 = ('<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<d:sync-collection xmlns:d="DAV:"><d:sync-token/>'
|
||||
'<d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>')
|
||||
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)
|
||||
Reference in new issue
Block a user