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 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 { 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