From ec395501fa363affc48fbe023a8a1e574b2e1593 Mon Sep 17 00:00:00 2001 From: Erwan Guyader Date: Tue, 28 Jul 2026 21:13:53 +0200 Subject: [PATCH 1/3] refactor(sharing): Extract applicableSharings Factor the resolution of sharings applying to a target out of `scopesFor`, without the membership filter, so the effective recipients computation can reuse it. The root info now carries the root name, and sharing IDs are sorted to keep a deterministic order between identical calls. --- model/sharing/effective_access.go | 89 +++++++++++++++++++------------ 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/model/sharing/effective_access.go b/model/sharing/effective_access.go index 74cf3450e0b..d5278834271 100644 --- a/model/sharing/effective_access.go +++ b/model/sharing/effective_access.go @@ -3,6 +3,7 @@ package sharing import ( "os" "path" + "sort" "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/permission" @@ -82,19 +83,58 @@ func (r *AccessResolver) Resolve(targetID string) (*EffectiveAccess, error) { return ea, nil } +// rootInfo describes a shared root applying to a target: the directory (or +// file) that is the root of a sharing scope. +type rootInfo struct { + RootID string + RootPath string + RootName string +} + // scopesFor loads the target, builds ancestor paths, finds shared roots on // the path, adds the target's own file share if any, bulk-loads sharings, // filters to active additive ones where the current instance is a member, // and returns the resulting scopes. func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) { + sharings, rootBySharing, err := r.applicableSharings(targetID) + if err != nil { + return nil, err + } + + scopes := make([]SharingScope, 0, len(sharings)) + for _, s := range sharings { + info, ok := rootBySharing[s.SID] + if !ok { + continue + } + member := s.MemberFor(r.inst) + if member == nil { + continue + } + scopes = append(scopes, SharingScope{ + SharingID: s.SID, + RootID: info.RootID, + RootPath: info.RootPath, + AccessMode: s.EffectiveAccessMode(), + ReadOnly: member.ReadOnly, + }) + } + return scopes, nil +} + +// applicableSharings resolves the active additive sharings applying to the +// target: its own share (if it is a shared file) plus the shares of every +// ancestor directory, without any membership filtering. It also returns the +// root info of each sharing, keyed by sharing ID. +func (r *AccessResolver) applicableSharings(targetID string) ([]*Sharing, map[string]rootInfo, error) { fs := r.inst.VFS() dir, file, err := fs.DirOrFileByID(targetID) if err != nil { - return nil, err + return nil, nil, err } if dir == nil && file == nil { - return nil, os.ErrNotExist + return nil, nil, os.ErrNotExist } var targetPath string @@ -103,7 +143,7 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) { } else { targetPath, err = file.Path(fs) if err != nil { - return nil, err + return nil, nil, err } } @@ -119,6 +159,7 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) { type sharedRootDoc struct { ID string `json:"_id"` Path string `json:"path"` + Name string `json:"name"` ReferencedBy []couchdb.DocReference `json:"referenced_by"` } var roots []sharedRootDoc @@ -130,25 +171,21 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) { mango.Equal("type", consts.DirType), mango.Exists(couchdb.SelectorReferencedBy), ), - Fields: []string{"_id", "path", "referenced_by"}, + Fields: []string{"_id", "path", "name", "referenced_by"}, Limit: len(paths), } if err := couchdb.FindDocs(r.inst, consts.Files, req, &roots); err != nil { - return nil, err + return nil, nil, err } } - // Collect (sharingID -> {rootID, rootPath}) from ancestor dir roots. - type rootInfo struct { - RootID string - RootPath string - } + // Collect (sharingID -> rootInfo) from ancestor dir roots. rootBySharing := make(map[string]rootInfo) for _, root := range roots { for _, ref := range root.ReferencedBy { if ref.Type == consts.Sharings { if _, ok := rootBySharing[ref.ID]; !ok { - rootBySharing[ref.ID] = rootInfo{RootID: root.ID, RootPath: root.Path} + rootBySharing[ref.ID] = rootInfo{RootID: root.ID, RootPath: root.Path, RootName: root.Name} } } } @@ -161,45 +198,29 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) { for _, ref := range file.ReferencedBy { if ref.Type == consts.Sharings { if _, ok := rootBySharing[ref.ID]; !ok { - rootBySharing[ref.ID] = rootInfo{RootID: file.DocID, RootPath: targetPath} + rootBySharing[ref.ID] = rootInfo{RootID: file.DocID, RootPath: targetPath, RootName: file.DocName} } } } } if len(rootBySharing) == 0 { - return nil, nil + return nil, nil, nil } sharingIDs := make([]string, 0, len(rootBySharing)) for id := range rootBySharing { sharingIDs = append(sharingIDs, id) } + // Stable order: the recipient list derived from these sharings must not + // change between identical calls. + sort.Strings(sharingIDs) sharings, err := r.loadSharings(sharingIDs) if err != nil { - return nil, err + return nil, nil, err } - - scopes := make([]SharingScope, 0, len(sharings)) - for _, s := range sharings { - info, ok := rootBySharing[s.SID] - if !ok { - continue - } - member := s.MemberFor(r.inst) - if member == nil { - continue - } - scopes = append(scopes, SharingScope{ - SharingID: s.SID, - RootID: info.RootID, - RootPath: info.RootPath, - AccessMode: s.EffectiveAccessMode(), - ReadOnly: member.ReadOnly, - }) - } - return scopes, nil + return sharings, rootBySharing, nil } // ancestorPaths returns the directory paths to query for shared roots. When From 84bb21a943105fb545dccb56c709e80ccb6f0117 Mon Sep 17 00:00:00 2001 From: Erwan Guyader Date: Tue, 28 Jul 2026 21:14:02 +0200 Subject: [PATCH 2/3] feat(sharing): Compute effective recipients Add a read-only view combining the people who can access a file or folder through its own share and through inherited ancestor shares. Recipients are deduplicated by instance with an email fallback, read-write wins over read-only, the most advanced status is kept via `statusRank`, and each recipient exposes the sources through which the access is granted. No sharing document is mutated. --- model/sharing/effective_recipients.go | 191 ++++++++ model/sharing/effective_recipients_test.go | 518 +++++++++++++++++++++ model/sharing/member.go | 19 + 3 files changed, 728 insertions(+) create mode 100644 model/sharing/effective_recipients.go create mode 100644 model/sharing/effective_recipients_test.go diff --git a/model/sharing/effective_recipients.go b/model/sharing/effective_recipients.go new file mode 100644 index 00000000000..abf2c177e5e --- /dev/null +++ b/model/sharing/effective_recipients.go @@ -0,0 +1,191 @@ +package sharing + +import ( + "fmt" + "strings" +) + +// RecipientSource describes one sharing scope through which a recipient has +// access to the target: which sharing, which root, and how the access can be +// managed from the target's share modal. +type RecipientSource struct { + SharingID string `json:"sharing_id"` + RootID string `json:"root_id"` + RootName string `json:"root_name"` + Kind string `json:"kind"` // "self" | "ancestor" + MemberIndex int `json:"member_index"` + ReadOnly bool `json:"read_only"` + Manageable bool `json:"manageable"` +} + +// EffectiveRecipient is a deduplicated person who can access the target, +// either through the target's own share ("self") or inherited from a shared +// ancestor ("ancestor"). ReadOnly is merged across sources with read-write +// winning over read-only. CanEditHere is true when at least one source is +// the target's own share. +type EffectiveRecipient struct { + Name string `json:"name"` + Email string `json:"email"` + Instance string `json:"instance"` + Status string `json:"status"` + ReadOnly bool `json:"read_only"` + CanEditHere bool `json:"can_edit_here"` + Sources []RecipientSource `json:"sources"` +} + +// EffectiveRecipients returns the combined list of people who can access the +// given file or folder: the direct members of every active additive sharing +// scope applying to the target (its own share plus inherited ancestor +// shares). Revoked members are excluded. Recipients are deduplicated by +// instance, with an email fallback. It is a read-only view: no sharing +// document is mutated and no inherited member is copied anywhere. +func (r *AccessResolver) EffectiveRecipients(targetID string) ([]EffectiveRecipient, error) { + sharings, rootBySharing, err := r.applicableSharings(targetID) + if err != nil { + return nil, err + } + + var recipients []EffectiveRecipient + index := make(map[string]int) + dropped := make(map[int]bool) + for _, s := range sharings { + info, ok := rootBySharing[s.SID] + if !ok { + continue + } + kind := "ancestor" + if info.RootID == targetID { + kind = "self" + } + // Same condition as authorizeRevokeRecipient: the owner can manage + // members, and a drive recipient can too (delegated to the owner) + // only with write access — read-only drive recipients get a 403 + // from hasSharingWritePermissions when they try. A classic-sharing + // recipient cannot revoke. + canManage := s.Owner + if !canManage && s.Drive { + if self := s.MemberFor(r.inst); self != nil && !self.ReadOnly { + canManage = true + } + } + for i := range s.Members { + m := &s.Members[i] + if m.Status == MemberStatusRevoked { + continue + } + candidate := EffectiveRecipient{ + Name: m.PrimaryName(), + Email: m.Email, + Instance: m.Instance, + Status: m.Status, + ReadOnly: m.ReadOnly, + Sources: []RecipientSource{{ + SharingID: s.SID, + RootID: info.RootID, + RootName: info.RootName, + Kind: kind, + MemberIndex: i, + ReadOnly: m.ReadOnly, + // i != 0: RevokeRecipient rejects the owner entry (index 0), + // so it must not be reported as manageable. + Manageable: kind == "self" && canManage && i != 0, + }}, + } + candidate.CanEditHere = kind == "self" + + key, aliases := recipientKeys(s.SID, i, m) + pos, ok := index[key] + if !ok { + // The canonical key may miss when an earlier sharing + // only registered an alias for this person: fall back + // to the aliases before creating a duplicate. + for _, alias := range aliases { + if p, found := index[alias]; found { + pos, ok = p, true + break + } + } + } + if ok { + recipients[pos].absorb(&candidate) + index[key] = pos + for _, alias := range aliases { + if q, found := index[alias]; found && q != pos { + // The alias was registered by another recipient that + // turns out to be the same person (e.g. known by + // instance in one share, by email in another, and a + // third share bridges the two): merge it instead of + // leaving an orphaned duplicate behind. + recipients[pos].absorb(&recipients[q]) + dropped[q] = true + for k, v := range index { + if v == q { + index[k] = pos + } + } + } + index[alias] = pos + } + continue + } + pos = len(recipients) + index[key] = pos + for _, alias := range aliases { + index[alias] = pos + } + recipients = append(recipients, candidate) + } + } + if len(dropped) > 0 { + kept := recipients[:0] + for i := range recipients { + if !dropped[i] { + kept = append(kept, recipients[i]) + } + } + recipients = kept + } + return recipients, nil +} + +// absorb merges another occurrence of the same person into the recipient: +// read-write wins over read-only, the most advanced status is kept, missing +// identity fields are filled in, and sources are combined. +func (rc *EffectiveRecipient) absorb(other *EffectiveRecipient) { + rc.ReadOnly = rc.ReadOnly && other.ReadOnly // read-write wins + rc.CanEditHere = rc.CanEditHere || other.CanEditHere + if statusRank(other.Status) > statusRank(rc.Status) { + rc.Status = other.Status + } + if (rc.Name == "" || rc.Name == rc.Email) && other.Name != "" { + // The current name is empty or just the email fallback: + // prefer an actual name when another source has one. + rc.Name = other.Name + } + if rc.Email == "" { + rc.Email = other.Email + } + if rc.Instance == "" { + rc.Instance = other.Instance + } + rc.Sources = append(rc.Sources, other.Sources...) +} + +// recipientKeys builds the dedup keys for a member: the instance host when +// known plus the lowercased email, so a person invited by email in one share +// and known by instance in another is still merged. The first key is the +// canonical one (instance preferred over email). When neither is set, a +// per-member unique key prevents merging nameless pending members. +func recipientKeys(sharingID string, memberIndex int, m *Member) (string, []string) { + var keys []string + if host := m.InstanceHost(); host != "" { + keys = append(keys, "instance:"+host) + } + if m.Email != "" { + keys = append(keys, "email:"+strings.ToLower(m.Email)) + } + if len(keys) == 0 { + return fmt.Sprintf("member:%s:%d", sharingID, memberIndex), nil + } + return keys[0], keys[1:] +} diff --git a/model/sharing/effective_recipients_test.go b/model/sharing/effective_recipients_test.go new file mode 100644 index 00000000000..cc1ad0c6950 --- /dev/null +++ b/model/sharing/effective_recipients_test.go @@ -0,0 +1,518 @@ +package sharing + +import ( + "os" + "testing" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEffectiveRecipients_Self(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + s := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + require.Len(t, recipients, 2) + + byEmail := map[string]EffectiveRecipient{} + for _, r := range recipients { + byEmail[r.Email] = r + } + + bob := byEmail["bob@cozy.tools"] + assert.Equal(t, "Bob", bob.Name) + assert.Equal(t, "https://bob.cozy.tools", bob.Instance) + assert.Equal(t, MemberStatusReady, bob.Status) + assert.False(t, bob.ReadOnly) + assert.True(t, bob.CanEditHere) + require.Len(t, bob.Sources, 1) + assert.Equal(t, s.SID, bob.Sources[0].SharingID) + assert.Equal(t, parent.ID(), bob.Sources[0].RootID) + assert.Equal(t, parent.DocName, bob.Sources[0].RootName) + assert.Equal(t, "self", bob.Sources[0].Kind) + assert.Equal(t, 1, bob.Sources[0].MemberIndex) + assert.True(t, bob.Sources[0].Manageable) + + email, err := inst.SettingsEMail() + require.NoError(t, err) + owner := byEmail[email] + assert.Equal(t, MemberStatusOwner, owner.Status) + assert.True(t, owner.CanEditHere) + // The owner cannot revoke themselves (RevokeRecipient rejects index 0). + assert.False(t, owner.Sources[0].Manageable) +} + +func TestEffectiveRecipients_Ancestor(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{"child/": H{}}} + parent := createTree(t, fs, tree, consts.RootDirID) + child, err := fs.DirByPath(parent.Fullpath + "/child") + require.NoError(t, err) + + s := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + ReadOnly: true, + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(child.ID()) + require.NoError(t, err) + require.Len(t, recipients, 2) + + var bob *EffectiveRecipient + for i := range recipients { + if recipients[i].Email == "bob@cozy.tools" { + bob = &recipients[i] + } + } + require.NotNil(t, bob) + assert.True(t, bob.ReadOnly) + assert.False(t, bob.CanEditHere) + require.Len(t, bob.Sources, 1) + assert.Equal(t, s.SID, bob.Sources[0].SharingID) + assert.Equal(t, parent.ID(), bob.Sources[0].RootID) + assert.Equal(t, parent.DocName, bob.Sources[0].RootName) + assert.Equal(t, "ancestor", bob.Sources[0].Kind) + assert.False(t, bob.Sources[0].Manageable) +} + +func TestEffectiveRecipients_DedupReadWriteWins(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{"child/": H{}}} + parent := createTree(t, fs, tree, consts.RootDirID) + child, err := fs.DirByPath(parent.Fullpath + "/child") + require.NoError(t, err) + + sParent := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, sParent, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + ReadOnly: true, + }) + sChild := createActiveDirSharing(t, inst, child.ID()) + addMemberToSharing(t, inst, sChild, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + ReadOnly: false, + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(child.ID()) + require.NoError(t, err) + + var bobs []EffectiveRecipient + for _, r := range recipients { + if r.Instance == "https://bob.cozy.tools" { + bobs = append(bobs, r) + } + } + require.Len(t, bobs, 1) + bob := bobs[0] + assert.False(t, bob.ReadOnly) // read-write wins over read-only + assert.True(t, bob.CanEditHere) + assert.Len(t, bob.Sources, 2) +} + +func TestEffectiveRecipients_DedupByEmailFallback(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{"child/": H{}}} + parent := createTree(t, fs, tree, consts.RootDirID) + child, err := fs.DirByPath(parent.Fullpath + "/child") + require.NoError(t, err) + + sParent := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, sParent, Member{ + Status: MemberStatusMailNotSent, + Name: "Bob", + Email: "Bob@Cozy.Tools", + }) + sChild := createActiveDirSharing(t, inst, child.ID()) + addMemberToSharing(t, inst, sChild, Member{ + Status: MemberStatusMailNotSent, + Name: "Bob", + Email: "bob@cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(child.ID()) + require.NoError(t, err) + + var bobs []EffectiveRecipient + for _, r := range recipients { + if r.Name == "Bob" { + bobs = append(bobs, r) + } + } + require.Len(t, bobs, 1) + assert.Len(t, bobs[0].Sources, 2) +} + +func TestEffectiveRecipients_DedupInstanceThenEmail(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{"child/": H{}}} + parent := createTree(t, fs, tree, consts.RootDirID) + child, err := fs.DirByPath(parent.Fullpath + "/child") + require.NoError(t, err) + + // Bob has accepted the parent share (instance known)… + sParent := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, sParent, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + // …but was only invited by email to the child share (no instance yet). + sChild := createActiveDirSharing(t, inst, child.ID()) + addMemberToSharing(t, inst, sChild, Member{ + Status: MemberStatusMailNotSent, + Name: "Bob", + Email: "bob@cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(child.ID()) + require.NoError(t, err) + + var bobs []EffectiveRecipient + for _, r := range recipients { + if r.Email == "bob@cozy.tools" { + bobs = append(bobs, r) + } + } + require.Len(t, bobs, 1) + assert.Equal(t, "https://bob.cozy.tools", bobs[0].Instance) + assert.Len(t, bobs[0].Sources, 2) +} + +func TestEffectiveRecipients_DedupTransitiveBridge(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + // The same person appears with instance only, then email only, then both: + // the third occurrence bridges the first two and must merge all three + // into a single recipient instead of orphaning the email-only one. + // Members of one sharing are processed in order and sharings are + // processed in sorted ID order, so this is deterministic. + s := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusReady, + Name: "Bob", + Instance: "https://bob.cozy.tools", + }) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusMailNotSent, + Name: "Bob", + Email: "bob@cozy.tools", + }) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + + var bobs []EffectiveRecipient + for _, r := range recipients { + if r.Name == "Bob" || r.Email == "bob@cozy.tools" { + bobs = append(bobs, r) + } + } + require.Len(t, bobs, 1) + assert.Equal(t, "bob@cozy.tools", bobs[0].Email) + assert.Equal(t, "https://bob.cozy.tools", bobs[0].Instance) + assert.Len(t, bobs[0].Sources, 3) +} + +func TestEffectiveRecipients_MergePrefersAdvancedStatus(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{"child/": H{}}} + parent := createTree(t, fs, tree, consts.RootDirID) + child, err := fs.DirByPath(parent.Fullpath + "/child") + require.NoError(t, err) + + sParent := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, sParent, Member{ + Status: MemberStatusMailNotSent, + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + sChild := createActiveDirSharing(t, inst, child.ID()) + addMemberToSharing(t, inst, sChild, Member{ + Status: MemberStatusReady, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(child.ID()) + require.NoError(t, err) + + var bobs []EffectiveRecipient + for _, r := range recipients { + if r.Instance == "https://bob.cozy.tools" { + bobs = append(bobs, r) + } + } + require.Len(t, bobs, 1) + assert.Equal(t, MemberStatusReady, bobs[0].Status) + assert.Equal(t, "Bob", bobs[0].Name) // filled from the occurrence that has it +} + +func TestEffectiveRecipients_AbsorbKeepsEmailFallbackName(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + // Bob is known by email+instance (no name: PrimaryName falls back to + // the email), then by instance only. The second occurrence merges into + // the first via the instance key and must not erase the email fallback + // name with its own empty name. Members of one sharing are processed + // in order, so this is deterministic. + s := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusReady, + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusReady, + Instance: "https://bob.cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + + var bobs []EffectiveRecipient + for _, r := range recipients { + if r.Instance == "https://bob.cozy.tools" { + bobs = append(bobs, r) + } + } + require.Len(t, bobs, 1) + assert.Equal(t, "bob@cozy.tools", bobs[0].Name) // email fallback preserved + assert.Equal(t, "bob@cozy.tools", bobs[0].Email) + assert.Len(t, bobs[0].Sources, 2) +} + +func TestEffectiveRecipients_RevokedExcluded(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + s := createActiveDirSharing(t, inst, parent.ID()) + addMemberToSharing(t, inst, s, Member{ + Status: MemberStatusRevoked, + Name: "Bob", + Email: "bob@cozy.tools", + Instance: "https://bob.cozy.tools", + }) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + for _, r := range recipients { + assert.NotEqual(t, "bob@cozy.tools", r.Email) + } +} + +func TestEffectiveRecipients_NotShared(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + assert.Empty(t, recipients) +} + +func TestEffectiveRecipients_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + + recipients, err := NewAccessResolver(inst).EffectiveRecipients("does-not-exist") + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) + assert.Nil(t, recipients) +} + +// On a recipient instance of a drive, member management is delegated to the +// owner (AddRecipients, RevokeRecipient), so a "self" source stays +// manageable — same condition as authorizeRevokeRecipient. +func TestEffectiveRecipients_ManageableDriveRecipient(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + createActiveRecipientSharing(t, inst, parent.ID(), false) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + require.NotEmpty(t, recipients) + + for _, r := range recipients { + require.Len(t, r.Sources, 1) + assert.Equal(t, "self", r.Sources[0].Kind) + if r.Sources[0].MemberIndex == 0 { + // The owner cannot revoke themselves (RevokeRecipient + // rejects index 0). + assert.False(t, r.Sources[0].Manageable) + } else { + assert.True(t, r.Sources[0].Manageable) + } + assert.True(t, r.CanEditHere) + } +} + +// A read-only drive recipient cannot manage members: authorizeRevokeRecipient +// requires write access on the sharing for the delegated revoke path, so the +// "self" sources must not be flagged as manageable. +func TestEffectiveRecipients_NotManageableDriveRecipientReadOnly(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + fs := inst.VFS() + + tree := H{"parent/": H{}} + parent := createTree(t, fs, tree, consts.RootDirID) + + createActiveRecipientSharing(t, inst, parent.ID(), true) + + recipients, err := NewAccessResolver(inst).EffectiveRecipients(parent.ID()) + require.NoError(t, err) + require.NotEmpty(t, recipients) + + for _, r := range recipients { + require.Len(t, r.Sources, 1) + assert.Equal(t, "self", r.Sources[0].Kind) + assert.False(t, r.Sources[0].Manageable) + assert.True(t, r.CanEditHere) + } +} + +// addMemberToSharing appends a member to a persisted sharing document. +func addMemberToSharing(t *testing.T, inst *instance.Instance, s *Sharing, m Member) { + t.Helper() + s.Members = append(s.Members, m) + require.NoError(t, couchdb.UpdateDoc(inst, s)) +} diff --git a/model/sharing/member.go b/model/sharing/member.go index 6f9bcb80a40..e820a2278dd 100644 --- a/model/sharing/member.go +++ b/model/sharing/member.go @@ -47,6 +47,25 @@ const ( MemberStatusRevoked = "revoked" ) +// statusRank orders member statuses from the least to the most advanced, so +// that merging a person present in several sharings keeps their most +// advanced status deterministically. +func statusRank(status string) int { + switch status { + case MemberStatusOwner: + return 5 + case MemberStatusReady: + return 4 + case MemberStatusSeen: + return 3 + case MemberStatusPendingInvitation: + return 2 + case MemberStatusMailNotSent: + return 1 + } + return 0 +} + const maximalNumberOfMembers = 90 func maxNumberOfMembers(inst *instance.Instance) int { From 939b53722f1665f28aef0bcaaaacbf0db3bf2cdf Mon Sep 17 00:00:00 2001 From: Erwan Guyader Date: Tue, 28 Jul 2026 21:14:07 +0200 Subject: [PATCH 3/3] feat(sharings): Effective recipients endpoints Expose `GET /sharings/recipients/:file-id` and `GET /sharings/drives/:id/recipients/:file-id` returning the effective recipients of a file or folder as JSON-API. Public share tokens (share-by-link, share preview) are rejected: a link holder must not enumerate sharing members. The response meta carries the target `file_id`. The drive route goes through the shared-drive `proxy()`: on a recipient instance the request is forwarded to the owner with the drive token, so the access check and the recipients resolution run where every sharing applying to the target is known. Membership is enforced owner-side by the `share-interact` permission. --- pkg/jsonapi/data.go | 1 + web/sharings/drives.go | 1 + web/sharings/drives_test.go | 21 ++ web/sharings/effective_recipients.go | 128 ++++++++++ web/sharings/effective_recipients_test.go | 278 ++++++++++++++++++++++ web/sharings/sharings.go | 1 + 6 files changed, 430 insertions(+) create mode 100644 web/sharings/effective_recipients.go create mode 100644 web/sharings/effective_recipients_test.go diff --git a/pkg/jsonapi/data.go b/pkg/jsonapi/data.go index c82ddae43ef..60955ae8842 100644 --- a/pkg/jsonapi/data.go +++ b/pkg/jsonapi/data.go @@ -20,6 +20,7 @@ type Meta struct { Rev string `json:"rev,omitempty"` Warning string `json:"warning,omitempty"` Count *int `json:"count,omitempty"` + FileID string `json:"file_id,omitempty"` ExecutionStats *couchdb.ExecutionStats `json:"execution_stats,omitempty"` } diff --git a/web/sharings/drives.go b/web/sharings/drives.go index 09ab795ee85..ff8732f9638 100644 --- a/web/sharings/drives.go +++ b/web/sharings/drives.go @@ -1356,6 +1356,7 @@ func drivesRoutes(router *echo.Group) { drive.POST("/notes", proxy(CreateNote, true)) drive.GET("/notes/:file-id/open", OpenNoteURL) + drive.GET("/recipients/:file-id", proxy(GetDriveEffectiveRecipients, true)) drive.GET("/office/:file-id/open", OpenOffice) drive.GET("/editor/:file-id/open", OpenEditor) diff --git a/web/sharings/drives_test.go b/web/sharings/drives_test.go index eca6274f192..8f47ab932f6 100644 --- a/web/sharings/drives_test.go +++ b/web/sharings/drives_test.go @@ -1338,6 +1338,27 @@ func runCoreSharedDrivesTests(t *testing.T, method DriveCreationMethod) { Expect().Status(404) }) }) + + t.Run("GetEffectiveRecipients", func(t *testing.T) { + // A recipient gets the members list through the proxy: the + // resolution happens on the owner instance. + obj := eBetty.GET("/sharings/drives/"+sharingID+"/recipients/"+meetingsID). + WithHeader("Authorization", "Bearer "+bettyAppToken). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + obj.Path("$.meta.file_id").String().IsEqual(meetingsID) + data := obj.Value("data").Array() + found := false + for _, v := range data.Iter() { + attrs := v.Object().Value("attributes").Object() + if attrs.Value("email").String().Raw() == "betty@example.net" { + found = true + } + } + require.True(t, found, "betty should be in the recipients list") + }) } // TestCoreSharedDrivesWithBothMethods runs core shared drive tests with both diff --git a/web/sharings/effective_recipients.go b/web/sharings/effective_recipients.go new file mode 100644 index 00000000000..61016c9c611 --- /dev/null +++ b/web/sharings/effective_recipients.go @@ -0,0 +1,128 @@ +package sharings + +import ( + "errors" + "net/http" + "os" + "strconv" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/permission" + "github.com/cozy/cozy-stack/model/sharing" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/cozy/cozy-stack/web/middlewares" + "github.com/labstack/echo/v4" +) + +// apiEffectiveRecipient serializes a sharing.EffectiveRecipient as JSON-API. +type apiEffectiveRecipient struct { + *sharing.EffectiveRecipient + id string +} + +func (a *apiEffectiveRecipient) ID() string { return a.id } +func (a *apiEffectiveRecipient) Rev() string { return "" } +func (a *apiEffectiveRecipient) DocType() string { return constsSharingsRecipients } +func (a *apiEffectiveRecipient) Clone() couchdb.Doc { c := *a; return &c } +func (a *apiEffectiveRecipient) SetID(id string) { a.id = id } +func (a *apiEffectiveRecipient) SetRev(_ string) {} +func (a *apiEffectiveRecipient) Links() *jsonapi.LinksList { return nil } +func (a *apiEffectiveRecipient) Relationships() jsonapi.RelationshipMap { return nil } +func (a *apiEffectiveRecipient) Included() []jsonapi.Object { return nil } + +const constsSharingsRecipients = "io.cozy.sharings.recipients" + +// GetEffectiveRecipients handles GET /sharings/recipients/:file-id. It +// returns the combined list of people who can access the file or folder, +// including recipients inherited from parent shared folders. The caller +// needs at least read access to the target, and public share-by-link tokens +// are rejected: a link holder must not be able to enumerate sharing members. +// Recipients of ancestor sharings are included by design, even when the +// caller is not a member of those sharings: their additive access is real. +func GetEffectiveRecipients(c echo.Context) error { + inst := middlewares.GetInstance(c) + if _, _, err := loadDirOrFileFromParam(c, inst, permission.GET); err != nil { + return err + } + return respondEffectiveRecipients(c, inst, c.Param("file-id")) +} + +// GetDriveEffectiveRecipients handles GET +// /sharings/drives/:id/recipients/:file-id. It is wrapped in proxy(): the +// handler always runs on the owner instance of the drive (directly, or via +// the drive token for recipients), so the access check and the resolution +// see every sharing applying to the target. The target must belong to this +// drive. Recipients inherited from sharings above the drive root are +// included by design: their additive access is real. +func GetDriveEffectiveRecipients(c echo.Context, inst *instance.Instance, s *sharing.Sharing) error { + if _, _, err := loadDirOrFileFromParam(c, inst, permission.GET); err != nil { + return err + } + if err := checkFileInsideDrive(inst, s, c.Param("file-id")); err != nil { + return err + } + return respondEffectiveRecipients(c, inst, c.Param("file-id")) +} + +func respondEffectiveRecipients(c echo.Context, inst *instance.Instance, fileID string) error { + // Public share tokens (share-by-link, share preview) are anonymous + // access: they must not enumerate the members of the applicable + // sharings, including unrelated ancestor sharings. + pdoc, err := middlewares.GetPermission(c) + if err != nil { + return err + } + switch pdoc.Type { + case permission.TypeShareByLink, permission.TypeSharePreview: + return jsonapi.Forbidden(errors.New("public share token cannot list sharing members")) + } + recipients, err := sharing.NewAccessResolver(inst).EffectiveRecipients(fileID) + if err != nil { + if os.IsNotExist(err) { + return jsonapi.NotFound(err) + } + return wrapErrors(err) + } + objs := make([]jsonapi.Object, len(recipients)) + for i := range recipients { + r := recipients[i] + objs[i] = &apiEffectiveRecipient{EffectiveRecipient: &r, id: effectiveRecipientID(&r)} + } + return jsonapi.DataListWithMeta(c, http.StatusOK, jsonapi.Meta{FileID: fileID}, objs, nil) +} + +// effectiveRecipientID builds a stable, URL-safe JSON-API id for a +// recipient from its first source. The member's real identity stays +// available in the instance/email attributes: the id only needs to be +// unique and must be safe to interpolate into a URL (a raw instance URL +// or email would not be). +func effectiveRecipientID(r *sharing.EffectiveRecipient) string { + src := r.Sources[0] + return src.SharingID + ":" + strconv.Itoa(src.MemberIndex) +} + +// checkFileInsideDrive verifies that the target belongs to the shared drive +// rooted at the drive root directory. It runs on the owner instance, where +// the ID in the sharing rule is the local ID. A target outside the drive is +// reported as not found to avoid leaking its existence. +func checkFileInsideDrive(inst *instance.Instance, s *sharing.Sharing, fileID string) error { + rootID, err := s.DriveRootID() + if err != nil { + return wrapErrors(err) + } + if s.HasFileDriveRoot() { + if fileID != rootID { + return jsonapi.NotFound(errors.New("file does not belong to this drive")) + } + return nil + } + root, err := s.GetSharingDir(inst) + if err != nil { + return jsonapi.NotFound(errors.New("shared drive root directory not found")) + } + if err := isWithinDirectory(inst.VFS(), fileID, root); err != nil { + return jsonapi.NotFound(errors.New("file does not belong to this drive")) + } + return nil +} diff --git a/web/sharings/effective_recipients_test.go b/web/sharings/effective_recipients_test.go new file mode 100644 index 00000000000..21ca2b49f1a --- /dev/null +++ b/web/sharings/effective_recipients_test.go @@ -0,0 +1,278 @@ +package sharings_test + +import ( + "net/http" + "testing" + "time" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/permission" + "github.com/cozy/cozy-stack/model/sharing" + "github.com/cozy/cozy-stack/model/vfs" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/tests/testutils" + "github.com/cozy/cozy-stack/web/errors" + "github.com/cozy/cozy-stack/web/middlewares" + "github.com/cozy/cozy-stack/web/sharings" + "github.com/cozy/cozy-stack/web/statik" + "github.com/gavv/httpexpect/v2" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +type effectiveRecipientsEnv struct { + inst *instance.Instance + token string + e *httpexpect.Expect +} + +func setupEffectiveRecipientsEnv(t *testing.T, doctype string) *effectiveRecipientsEnv { + t.Helper() + config.UseTestFile(t) + testutils.NeedCouchdb(t) + render, _ := statik.NewDirRenderer("../../assets") + middlewares.BuildTemplates() + + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance() + token := generateAppToken(inst, "testapp", doctype) + ts := setup.GetTestServerMultipleRoutes(map[string]func(*echo.Group){ + "/sharings": sharings.Routes, + }) + ts.Config.Handler.(*echo.Echo).Renderer = render + ts.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler + t.Cleanup(ts.Close) + + return &effectiveRecipientsEnv{ + inst: inst, + token: token, + e: httpexpect.Default(t, ts.URL), + } +} + +func createTestDir(t *testing.T, inst *instance.Instance, name, parentID string) *vfs.DirDoc { + t.Helper() + dir, err := vfs.NewDirDoc(inst.VFS(), name, parentID, nil) + require.NoError(t, err) + require.NoError(t, inst.VFS().CreateDir(dir)) + return dir +} + +// createTestSharing persists an active additive sharing on the given root, +// owned by the instance, with Bob as an extra member. +func createTestSharing(t *testing.T, inst *instance.Instance, rootID string, drive bool) *sharing.Sharing { + t.Helper() + now := time.Now() + s := &sharing.Sharing{ + Active: true, + Owner: true, + Drive: drive, + DriveRootType: sharing.DriveRootTypeDirectory, + AppSlug: "test", + AccessMode: sharing.AccessModeAdditive, + Members: []sharing.Member{ + { + Status: sharing.MemberStatusOwner, + Name: "Owner", + Email: "owner@example.net", + Instance: "https://" + inst.Domain, + }, + { + Status: sharing.MemberStatusReady, + Name: "Bob", + Email: "bob@example.net", + Instance: "https://bob.example.net", + }, + }, + Rules: []sharing.Rule{ + { + Title: "test", + DocType: consts.Files, + Values: []string{rootID}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + require.NoError(t, s.AddReferenceForSharing(inst, &s.Rules[0])) + return s +} + +func TestEffectiveRecipientsEndpoint_OK(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + parent := createTestDir(t, env.inst, "parent", consts.RootDirID) + createTestSharing(t, env.inst, parent.ID(), false) + + obj := env.e.GET("/sharings/recipients/"+parent.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusOK). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + obj.Path("$.meta.file_id").String().IsEqual(parent.ID()) + data := obj.Value("data").Array() + data.Length().IsEqual(2) + found := false + for _, v := range data.Iter() { + attrs := v.Object().Value("attributes").Object() + if attrs.Value("email").String().Raw() == "bob@example.net" { + found = true + attrs.Value("name").String().IsEqual("Bob") + attrs.Value("can_edit_here").Boolean().IsTrue() + attrs.Value("read_only").Boolean().IsFalse() + sources := attrs.Value("sources").Array() + sources.Length().IsEqual(1) + sources.First().Object().Value("kind").String().IsEqual("self") + } + } + require.True(t, found, "bob should be in the recipients list") +} + +func TestEffectiveRecipientsEndpoint_Forbidden(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, iocozytests) + + parent := createTestDir(t, env.inst, "parent", consts.RootDirID) + + env.e.GET("/sharings/recipients/"+parent.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusForbidden) +} + +// A public share-by-link token grants read access to the file, but it must +// not allow enumerating the members of the sharings applying to it. +func TestEffectiveRecipientsEndpoint_PublicLinkForbidden(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + parent := createTestDir(t, env.inst, "parent", consts.RootDirID) + createTestSharing(t, env.inst, parent.ID(), false) + + publicToken, err := env.inst.MakeJWT(consts.ShareAudience, "email", consts.Files, "", time.Now()) + require.NoError(t, err) + expires := time.Now().Add(2 * time.Minute) + rules := permission.Set{permission.Rule{ + Type: consts.Files, + Verbs: permission.Verbs(permission.GET), + Values: []string{parent.ID()}, + }} + _, err = permission.CreateShareSet(env.inst, + &permission.Permission{Type: "app", Permissions: rules}, + "", map[string]string{"email": publicToken}, nil, + permission.Permission{Permissions: rules}, &expires, false) + require.NoError(t, err) + + env.e.GET("/sharings/recipients/"+parent.ID()). + WithHeader("Authorization", "Bearer "+publicToken). + Expect().Status(http.StatusForbidden) +} + +func TestEffectiveRecipientsEndpoint_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + env.e.GET("/sharings/recipients/does-not-exist"). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusNotFound) +} + +func TestDriveEffectiveRecipientsEndpoint_OK(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + root := createTestDir(t, env.inst, "drive-root", consts.RootDirID) + child := createTestDir(t, env.inst, "child", root.ID()) + s := createTestSharing(t, env.inst, root.ID(), true) + + obj := env.e.GET("/sharings/drives/"+s.SID+"/recipients/"+child.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusOK). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + obj.Path("$.meta.file_id").String().IsEqual(child.ID()) + data := obj.Value("data").Array() + data.Length().IsEqual(2) + for _, v := range data.Iter() { + attrs := v.Object().Value("attributes").Object() + if attrs.Value("email").String().Raw() == "bob@example.net" { + attrs.Value("sources").Array().First().Object().Value("kind").String().IsEqual("ancestor") + attrs.Value("can_edit_here").Boolean().IsFalse() + return + } + } + t.Fatal("bob should be in the recipients list") +} + +func TestDriveEffectiveRecipientsEndpoint_OutsideDrive(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + root := createTestDir(t, env.inst, "drive-root", consts.RootDirID) + outside := createTestDir(t, env.inst, "outside", consts.RootDirID) + s := createTestSharing(t, env.inst, root.ID(), true) + + env.e.GET("/sharings/drives/"+s.SID+"/recipients/"+outside.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusNotFound) +} + +func TestDriveEffectiveRecipientsEndpoint_InactiveDrive(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + root := createTestDir(t, env.inst, "drive-root", consts.RootDirID) + s := createTestSharing(t, env.inst, root.ID(), true) + // A revoked drive is rejected, like on the other drive endpoints. + s.Active = false + require.NoError(t, couchdb.UpdateDoc(env.inst, s)) + + env.e.GET("/sharings/drives/"+s.SID+"/recipients/"+root.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusForbidden) +} + +func TestDriveEffectiveRecipientsEndpoint_FileDriveRoot(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + env := setupEffectiveRecipientsEnv(t, consts.Files) + + filedoc, err := vfs.NewFileDoc("shared-file", consts.RootDirID, -1, nil, "text/plain", "text", time.Now(), false, false, false, nil) + require.NoError(t, err) + f, err := env.inst.VFS().CreateFile(filedoc, nil) + require.NoError(t, err) + require.NoError(t, f.Close()) + + s := createTestSharing(t, env.inst, filedoc.ID(), true) + s.DriveRootType = sharing.DriveRootTypeFile + require.NoError(t, couchdb.UpdateDoc(env.inst, s)) + + env.e.GET("/sharings/drives/"+s.SID+"/recipients/"+filedoc.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusOK) + + other := createTestDir(t, env.inst, "other", consts.RootDirID) + env.e.GET("/sharings/drives/"+s.SID+"/recipients/"+other.ID()). + WithHeader("Authorization", "Bearer "+env.token). + Expect().Status(http.StatusNotFound) +} diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go index ca63943c8cb..86ffc7321db 100644 --- a/web/sharings/sharings.go +++ b/web/sharings/sharings.go @@ -1167,6 +1167,7 @@ func Routes(router *echo.Group) { // Misc router.GET("/news", CountNewShortcuts) router.GET("/doctype/:doctype", GetSharingsInfoByDocType) + router.GET("/recipients/:file-id", GetEffectiveRecipients) router.GET("/:sharing-id/recipients/:index/avatar", GetAvatar) // Register the URL of their Cozy for recipients