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 }