package acl import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) var ErrSelfShare = errors.New("cannot share with yourself") // Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht. type Entry struct { ID uuid.UUID `json:"id"` SubjectType string `json:"subject_type"` // "user" | "group" SubjectID uuid.UUID `json:"subject_id"` SubjectName string `json:"subject_name"` Permission Permission `json:"permission"` CreatedAt time.Time `json:"created_at"` } // SharedResource ist ein Eintrag in „Geteilt mit mir". type SharedResource struct { Type string `json:"type"` // "folder" | "file" ID uuid.UUID `json:"id"` Name string `json:"name"` OwnerID uuid.UUID `json:"owner_id"` OwnerName string `json:"owner_name"` Permission Permission `json:"permission"` Size int64 `json:"size"` MimeType string `json:"mime_type"` SharedAt time.Time `json:"shared_at"` // ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert // man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat. ViaGroup string `json:"via_group"` } // ── Freigaben verwalten ────────────────────────────────────────────────────── // 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) } 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) } func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) { if !perm.valid() { return nil, fmt.Errorf("invalid permission %q", perm) } // Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die // eigenen Ordner doppelt anzeigen. if !subjectIsGroup && subjectID == grantedBy { return nil, ErrSelfShare } var subjUser, subjGroup *uuid.UUID if subjectIsGroup { subjGroup = &subjectID } else { subjUser = &subjectID } 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) RETURNING id, permission, created_at`, subjUser, subjGroup, folderID, fileID, perm, grantedBy, ).Scan(&e.ID, &e.Permission, &e.CreatedAt) // Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine // Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen" // auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen // sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier // über den Fehlercode. var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm) } if err != nil { return nil, fmt.Errorf("grant access: %w", err) } e.SubjectID = subjectID e.SubjectType = subjectTypeName(subjectIsGroup) return &e, nil } func (s *Store) updateExisting(ctx context.Context, folderID, fileID, 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 RETURNING id, permission, created_at`, perm, folderID, fileID, subjUser, subjGroup, ).Scan(&e.ID, &e.Permission, &e.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("update access: %w", err) } if subjGroup != nil { e.SubjectID, e.SubjectType = *subjGroup, "group" } else if subjUser != nil { e.SubjectID, e.SubjectType = *subjUser, "user" } return &e, nil } // Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der // Eigentümer der Ressource widerrufen kann. 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) )`, entryID, ownerID) if err != nil { return fmt.Errorf("revoke access: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat. func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) { return s.list(ctx, `e.resource_folder_id = $1`, folderID) } func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) { return s.list(ctx, `e.resource_file_id = $1`, fileID) } func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) { rows, err := s.db.Query(ctx, ` SELECT e.id, CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END, COALESCE(e.subject_group_id, e.subject_user_id), COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''), e.permission, e.created_at FROM acl_entries e LEFT JOIN users u ON u.id = e.subject_user_id LEFT JOIN groups g ON g.id = e.subject_group_id WHERE `+where+` ORDER BY e.created_at`, resourceID) if err != nil { return nil, fmt.Errorf("list access entries: %w", err) } defer rows.Close() out := []Entry{} for rows.Next() { var e Entry if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName, &e.Permission, &e.CreatedAt); err != nil { return nil, fmt.Errorf("scan access entry: %w", err) } out = append(out, e) } return out, rows.Err() } // ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt // oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir". // // Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin // eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin, // nicht beides nebeneinander. func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) { const q = ` SELECT 'folder', f.id, f.name, f.owner_id, COALESCE(NULLIF(o.display_name, ''), o.username), e.permission, 0::bigint, '', e.created_at, COALESCE(g.name, '') FROM acl_entries e JOIN folders f ON f.id = e.resource_folder_id JOIN users o ON o.id = f.owner_id LEFT JOIN groups g ON g.id = e.subject_group_id WHERE (e.subject_user_id = $1 OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1)) -- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist. AND NOT EXISTS ( SELECT 1 FROM acl_entries pe JOIN folders pf ON pf.id = pe.resource_folder_id WHERE pf.id = f.parent_id AND (pe.subject_user_id = $1 OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1)) ) UNION ALL SELECT 'file', x.id, x.name, x.owner_id, COALESCE(NULLIF(o.display_name, ''), o.username), e.permission, x.size, x.mime_type, e.created_at, COALESCE(g.name, '') FROM acl_entries e JOIN files x ON x.id = e.resource_file_id JOIN users o ON o.id = x.owner_id LEFT JOIN groups g ON g.id = e.subject_group_id WHERE x.is_deleted = false AND (e.subject_user_id = $1 OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1)) -- Datei weglassen, wenn ihr Ordner bereits freigegeben ist. AND NOT EXISTS ( SELECT 1 FROM acl_entries fe WHERE fe.resource_folder_id = x.folder_id AND (fe.subject_user_id = $1 OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1)) ) ORDER BY 1, 3` rows, err := s.db.Query(ctx, q, userID) if err != nil { return nil, fmt.Errorf("list shared resources: %w", err) } defer rows.Close() out := []SharedResource{} for rows.Next() { var r SharedResource if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName, &r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil { return nil, fmt.Errorf("scan shared resource: %w", err) } out = append(out, r) } return out, rows.Err() } // ── Verzeichnis für den Freigabedialog ─────────────────────────────────────── type Recipient struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Type string `json:"type"` // "user" | "group" } // Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art – // Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch // wenn er teilen darf. func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) { rows, err := s.db.Query(ctx, ` SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user' FROM users WHERE id <> $1 UNION ALL SELECT id, name, 'group' FROM groups ORDER BY 3 DESC, 2`, exclude) if err != nil { return nil, fmt.Errorf("list recipients: %w", err) } defer rows.Close() out := []Recipient{} for rows.Next() { var r Recipient if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil { return nil, fmt.Errorf("scan recipient: %w", err) } out = append(out, r) } return out, rows.Err() } func subjectTypeName(isGroup bool) string { if isGroup { return "group" } return "user" }