feat(fs): group folders via ACL discovery in shared_with_me (#19)

Files granted to a user via explicit ACL entries — user subjects or any
of their effective groups — now surface in the flat "shared with me"
listing alongside saved share shortcuts, and resolve as browsable
subtree roots under cloudreve://shared_with_me/<fileHash>.

- inventory: AclClient.SharedFileIDs unions per-file permission bits
  across matching user/group subject rows (memberships included via
  GroupIDsOf); entries lacking the read bit are dropped.
- childFileQuery unions AclSharedIDs into the symbolic orphan listing;
  search skips the owner pin for that listing so foreign-owned grants
  stay searchable.
- sharedWithMeNavigator resolves hash-addressed files: own symbolic
  shortcuts resolve for share-URI translation; ACL grants become
  subtree roots with the owner's parent chain rebuilt and ACL-derived
  capability sets inherited by children. Uploads charge the owner and
  inherit the folder's storage policy.
- Per-subtree navigator keys prevent capability staleness across
  folders; vault wrapper keeps owner-vaulted entries hidden.

Tests cover listing, subtree resolution, denial paths, membership-based
grants, user subjects, and symbolic shortcuts.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent 28d927cf7b
commit 361ec0736a

@ -232,6 +232,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] CLI OAuth + consent denial (ported from upstream PR #3588) — built-in `Cloudreve CLI` public client (`http://127.0.0.1/callback`, desktop scope set, empty secret + mandatory PKCE per our public-client convention); `redirectURIMatches` implements RFC 8252 loopback matching (any port on `127.0.0.1`/`[::1]` literal only — no `localhost`, no userinfo/fragments/encoded paths, strict query equality); `POST /session/oauth/consent/deny` returns `access_denied` after full client+redirect validation (`Deny` is internal, `json:"-"`); token exchange rejects PKCE downgrade (verifier without a registered challenge); migration preserves admin edits; tests cover redirect matrix, denial, downgrade, and migration idempotence
- [x] Storage-policy content audit (upstream #2178 item 9, 鉴黄) — `PolicySetting.AuditEndpoint`/`AuditMaxSize`: share creation POSTs each image entity (size-capped, deduped, version entities only) to the configured endpoint via `EntitySource` streaming — bytes never route through the charged direct-link path; `{"flagged":true}` aborts the share (`CodeContentAuditRejected` = 40098), endpoint/parse failures fail closed (`CodeContentAuditFailed` = 40097), and blocked attempts record `content_audit_blocked` (event 62, admin-toggleable under Share events); admin policy editor gains endpoint + size-limit fields; en+zh locales
- [x] Multiple user groups per user (upstream #2494 / fork #19) — new `group_memberships` join table (unique `(user_id, group_id)`, nullable `expires`) layered on the unchanged `group_users` primary column; `GroupsOf`/`GroupIDsOf`/`EffectiveGroup` compute the effective set with union semantics: permission bits OR'ed, quotas/limits most-permissive (0 = unlimited where the field uses that convention; `SourceBatchSize`/`MaxWalkedFiles`/`TrashRetention` treated as disabled-or-duration so plain max wins), allowed-policy lists unioned while the primary group's default policy is kept; memberships ride `LoadUserGroup` eager loading so every existing read site sees them automatically; ~100 permission/settings/quota reads across explorer/share/webdav/filemanager/middleware now resolve through `EffectiveGroup`; group-subject ACL entries match any effective membership; `StoragePolicyClient.ListByGroups` unions allowed sets (synthetic merged group never hits ID-keyed queries); group purchases and group gift codes upsert an expiring membership instead of switching `group_users` (UserGrant row kept for history, revert path becomes a no-op); `grant_expire` cron sweeps expired memberships and emits `membership_unsubscribe`; admin user editor gains an "Additional groups" multi-select (`memberships` field on upsert, delegated admins still barred from admin-capable groups); `CountUsers` and the admin user-list group filter count membership holders; user-facing profile group payload exposes the merged union so feature gating matches actual rights; en+zh locales
- [x] Group folders via ACL discovery (upstream #2494 / fork #19 remainder) — `AclClient.SharedFileIDs` unions user/group-subject grants (effective memberships included, everyone/anonymous excluded, read bit required) into a per-file permission map; `shared_with_me` flat listing unions those entries next to saved share shortcuts (`childFileQuery` `Or` clause + `AclSharedIDs` threading, search skips the owner pin); `sharedWithMeNavigator.To` resolves `cloudreve://shared_with_me/<fileHash>` — own symbolic shortcuts resolve for WebDAV share-URI translation, ACL-granted files become subtree roots with owner parent chain + `aclPermsToCapabilities` caps stamped through `newFile` inheritance; uploads/creates inside charge the owner's quota and the folder's storage policy; vault wrapper fail-closes on owner-vaulted entries; per-subtree navigator IDs keep ACL caps from going stale across folders; empty-state copy updated (en+zh)
## 6. Phase D — desktop, all platforms

@ -207,7 +207,7 @@
"auto": "Auto",
"default": "Default",
"shareWithMeEmpty": "No shared files found",
"shareWithMeEmptyDes": "If you need to see others' shares here, please save the shortcut to any location in your files when you visit a shared link.",
"shareWithMeEmptyDes": "Folders shared with you or your groups appear here automatically. You can also save a shortcut while visiting someone's share link.",
"selectAll": "Select all",
"selectNone": "Select none",
"invertSelection": "Invert selection",

@ -207,7 +207,7 @@
"auto": "自动",
"default": "默认",
"shareWithMeEmpty": "没有找到别人的分享",
"shareWithMeEmptyDes": "如需要在此看到别人的分享,请在访问别人的分享链接时,在右上角将快捷方式保存到你的文件中的任意位置。",
"shareWithMeEmptyDes": "分享给你或你所在用户组的文件夹会自动出现在这里。你也可以在访问别人的分享链接时,在右上角将快捷方式保存到你的文件中。",
"selectAll": "全选",
"selectNone": "取消选择",
"invertSelection": "反选",

@ -27,6 +27,12 @@ type (
// and group rows plus the everyone tier. Returns nil when no entry
// matches — callers then fall back to share-level defaults.
EffectivePermissions(ctx context.Context, fileID int, user *ent.User) (*boolset.BooleanSet, error)
// SharedFileIDs returns the unioned permissions per file for every
// entry explicitly granted to the user — user-subject rows matching
// their ID plus group-subject rows matching any of their effective
// groups. The everyone/anonymous tiers are not discovery grants and
// are excluded. Files whose union lacks the read bit are dropped.
SharedFileIDs(ctx context.Context, user *ent.User) (map[int]*boolset.BooleanSet, error)
}
UpsertAclEntryParams struct {
@ -152,3 +158,49 @@ func (c *aclClient) EffectivePermissions(ctx context.Context, fileID int, user *
}
return res, nil
}
func (c *aclClient) SharedFileIDs(ctx context.Context, user *ent.User) (map[int]*boolset.BooleanSet, error) {
if IsAnonymousUser(user) {
return nil, nil
}
entries, err := c.client.AclEntry.Query().
Where(aclentry.Or(
aclentry.And(
aclentry.SubjectTypeEQ(aclentry.SubjectTypeUser),
aclentry.SubjectIDEQ(user.ID),
),
aclentry.And(
aclentry.SubjectTypeEQ(aclentry.SubjectTypeGroup),
aclentry.SubjectIDIn(GroupIDsOf(user)...),
),
)).
All(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query shared ACL entries: %w", err)
}
res := make(map[int]*boolset.BooleanSet, len(entries))
for _, e := range entries {
bs, ok := res[e.FileID]
if !ok {
bs = &boolset.BooleanSet{}
res[e.FileID] = bs
}
if e.Permissions == nil {
continue
}
for i := 0; i <= int(types.AclPermDelete); i++ {
if e.Permissions.Enabled(i) {
boolset.Set(i, true, bs)
}
}
}
for fileID, bs := range res {
if !bs.Enabled(int(types.AclPermRead)) {
delete(res, fileID)
}
}
return res, nil
}

@ -137,6 +137,59 @@ func TestAclEffectivePermissionsNoMatchIsNil(t *testing.T) {
require.Nil(t, res)
}
func TestAclSharedFileIDs(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
user, group, file := aclFixture(t, client)
c := NewAclClient(client, conf.SQLiteDB)
other := client.File.Create().SetName("other").SetType(int(types.FileTypeFolder)).SetOwner(user).SaveX(ctx)
writeOnly := client.File.Create().SetName("wo").SetType(int(types.FileTypeFolder)).SetOwner(user).SaveX(ctx)
everyoneFile := client.File.Create().SetName("ev").SetType(int(types.FileTypeFolder)).SetOwner(user).SaveX(ctx)
aclEntry(t, client, file.ID, aclentry.SubjectTypeUser, user.ID, types.AclPermRead)
aclEntry(t, client, file.ID, aclentry.SubjectTypeGroup, group.ID, types.AclPermCreate)
aclEntry(t, client, other.ID, aclentry.SubjectTypeGroup, group.ID, types.AclPermRead)
aclEntry(t, client, writeOnly.ID, aclentry.SubjectTypeUser, user.ID, types.AclPermCreate)
aclEntry(t, client, everyoneFile.ID, aclentry.SubjectTypeEveryone, 0, types.AclPermRead)
res, err := c.SharedFileIDs(ctx, user)
require.NoError(t, err)
require.Len(t, res, 2)
// Unioned perms across user + group rows.
require.True(t, res[file.ID].Enabled(int(types.AclPermRead)))
require.True(t, res[file.ID].Enabled(int(types.AclPermCreate)))
require.True(t, res[other.ID].Enabled(int(types.AclPermRead)))
// Anonymous gets no discovery listing.
anonRes, err := c.SharedFileIDs(ctx, &ent.User{ID: 0})
require.NoError(t, err)
require.Empty(t, anonRes)
}
func TestAclSharedFileIDsViaMembership(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
user, _, file := aclFixture(t, client)
c := NewAclClient(client, conf.SQLiteDB)
// Grant targets a group the user holds via membership, not primary.
team := client.Group.Create().SetName("team").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
m := client.GroupMembership.Create().SetUserID(user.ID).SetGroupID(team.ID).SaveX(ctx)
m.Edges.Group = team
user.Edges.Memberships = []*ent.GroupMembership{m}
aclEntry(t, client, file.ID, aclentry.SubjectTypeGroup, team.ID, types.AclPermRead)
res, err := c.SharedFileIDs(ctx, user)
require.NoError(t, err)
require.Len(t, res, 1)
require.True(t, res[file.ID].Enabled(int(types.AclPermRead)))
}
func TestAclEffectivePermissionsExplicitEmptyRevokes(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })

@ -53,6 +53,11 @@ type (
FolderOnly bool
// SharedWithMe indicates whether to list files shared with the user
SharedWithMe bool
// AclSharedIDs carries file IDs the user may access through direct
// ACL grants (user/group subjects); unioned into the SharedWithMe
// orphan listing so ACL-shared folders surface next to saved share
// shortcuts.
AclSharedIDs []int
Search *SearchFileParameters
}
@ -1187,7 +1192,7 @@ func (f *fileClient) QueryMetadata(ctx context.Context, root *ent.File) error {
}
func (f *fileClient) GetChildFile(ctx context.Context, root *ent.File, ownerID int, child string, eagerLoading bool) (*ent.File, error) {
query := f.childFileQuery(ownerID, false, root)
query := f.childFileQuery(ownerID, false, nil, root)
if eagerLoading {
query = withFileEagerLoading(ctx, query)
}
@ -1197,10 +1202,14 @@ func (f *fileClient) GetChildFile(ctx context.Context, root *ent.File, ownerID i
}
func (f *fileClient) GetChildFiles(ctx context.Context, args *ListFileParameters, ownerID int, roots ...*ent.File) (*ListFileResult, error) {
rawQuery := f.childFileQuery(ownerID, args.SharedWithMe, roots...)
rawQuery := f.childFileQuery(ownerID, args.SharedWithMe, args.AclSharedIDs, roots...)
query := withFileEagerLoading(ctx, rawQuery)
if args.Search != nil {
query = f.searchQuery(query, args.Search, roots, ownerID)
searchOwnerID := ownerID
if args.SharedWithMe {
searchOwnerID = -1
}
query = f.searchQuery(query, args.Search, roots, searchOwnerID)
}
var (

@ -22,7 +22,11 @@ const (
func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, parents []*ent.File, ownerId int) *ent.FileQuery {
if len(parents) == 1 && parents[0] == nil {
q = q.Where(file.OwnerID(ownerId))
// ownerId <= 0 skips the pin — the SharedWithMe union already
// scopes ownership and must not exclude other owners' grants.
if ownerId > 0 {
q = q.Where(file.OwnerID(ownerId))
}
} else {
q = q.Where(
file.HasParentWith(
@ -115,7 +119,7 @@ func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, p
}
// ChildFileQuery generates query for child file(s) of a given set of root
func (f *fileClient) childFileQuery(ownerID int, isSymbolic bool, root ...*ent.File) *ent.FileQuery {
func (f *fileClient) childFileQuery(ownerID int, isSymbolic bool, aclSharedIDs []int, root ...*ent.File) *ent.FileQuery {
rawQuery := f.client.File.Query()
if len(root) == 1 && root[0] != nil {
// Query children of one single root
@ -126,13 +130,24 @@ func (f *fileClient) childFileQuery(ownerID int, isSymbolic bool, root ...*ent.F
file.NameNEQ(RootFolderName),
}
if ownerID > 0 {
predicates = append(predicates, file.OwnerIDEQ(ownerID))
}
if isSymbolic {
predicates = append(predicates, file.And(file.IsSymbolic(true), file.FileChildrenNotNil()))
// "Shared with me" unions the user's own symbolic share
// shortcuts with other owners' files granted to them via ACL.
shared := []predicate.File{
file.And(
file.IsSymbolic(true),
file.FileChildrenNotNil(),
file.OwnerIDEQ(ownerID),
),
}
if len(aclSharedIDs) > 0 {
shared = append(shared, file.IDIn(aclSharedIDs...))
}
predicates = append(predicates, file.Or(shared...))
} else {
if ownerID > 0 {
predicates = append(predicates, file.OwnerIDEQ(ownerID))
}
predicates = append(predicates, file.Not(file.HasParent()))
}

@ -999,7 +999,7 @@ func (f *DBFS) getNavigator(ctx context.Context, path *fs.URI, requiredCapabilit
case constants.FileSystemTrash:
n = NewTrashNavigator(f.user, f.fileClient, f.l, config, f.hasher)
case constants.FileSystemSharedWithMe:
n = NewSharedWithMeNavigator(f.user, f.fileClient, f.l, config, f.hasher)
n = NewSharedWithMeNavigator(f.user, f.fileClient, f.aclClient, f.l, config, f.hasher)
default:
return nil, fmt.Errorf("unknown file system %q", pathFs)
}
@ -1045,6 +1045,14 @@ func (f *DBFS) navigatorId(path *fs.URI) string {
return fmt.Sprintf("%s/%s/%d", constants.FileSystemShare, path.ID(uidHashed), f.user.ID)
case constants.FileSystemTrash:
return fmt.Sprintf("%s/%s", constants.FileSystemTrash, path.ID(uidHashed))
case constants.FileSystemSharedWithMe:
// Per-subtree navigators keep the resolved ACL capability set from
// going stale when a request touches several shared roots.
scope := ""
if elements := path.Elements(); len(elements) > 0 {
scope = "/" + elements[0]
}
return fmt.Sprintf("%s%s/%d", constants.FileSystemSharedWithMe, scope, f.user.ID)
default:
return fmt.Sprintf("%s/%s/%d", path.FileSystem(), path.ID(uidHashed), f.user.ID)
}

@ -0,0 +1,230 @@
package dbfs
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/aclentry"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/stretchr/testify/require"
)
// groupFolderFixture builds an owner with a populated folder and a member
// user in a separate group. Returns (member, owner, sharedDir, nestedFile).
func groupFolderFixture(t *testing.T, client *ent.Client) (*ent.User, *ent.User, *ent.File, *ent.File) {
t.Helper()
ctx := context.Background()
ownerGroup := client.Group.Create().SetName("owners").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
memberGroup := client.Group.Create().SetName("members").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
owner := client.User.Create().SetEmail("owner@example.com").SetNick("o").SetGroup(ownerGroup).SaveX(ctx)
ownerRoot := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
sharedDir := client.File.Create().SetName("team_dir").SetType(int(types.FileTypeFolder)).SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
nested := client.File.Create().SetName("nested.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(sharedDir).SaveX(ctx)
member := client.User.Create().SetEmail("member@example.com").SetNick("m").SetGroup(memberGroup).SaveX(ctx)
client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(member).SaveX(ctx)
// Attach the primary-group edge the navigator relies on.
member.Edges.Group = memberGroup
return member, owner, sharedDir, nested
}
func groupFolderNavigator(t *testing.T, client *ent.Client, user *ent.User) (*sharedWithMeNavigator, hashid.Encoder) {
t.Helper()
hasher, err := hashid.New("group-folder-test-salt")
require.NoError(t, err)
n := NewSharedWithMeNavigator(user, inventory.NewFileClient(client, conf.SQLiteDB, hasher),
inventory.NewAclClient(client, conf.SQLiteDB),
logging.NewConsoleLogger(logging.LevelError), &setting.DBFS{MaxPageSize: 50}, hasher)
return n.(*sharedWithMeNavigator), hasher
}
func aclGrant(t *testing.T, client *ent.Client, fileID int, st aclentry.SubjectType, sid int, perms ...types.AclPermission) {
t.Helper()
bs := &boolset.BooleanSet{}
for _, p := range perms {
boolset.Set(int(p), true, bs)
}
client.AclEntry.Create().
SetFileID(fileID).SetSubjectType(st).SetSubjectID(sid).SetPermissions(bs).
SaveX(context.Background())
}
func sharedWithMeURI(t *testing.T, parts ...string) *fs.URI {
t.Helper()
uri, err := fs.NewUriFromString("cloudreve://" + string(constants.FileSystemSharedWithMe))
require.NoError(t, err)
return uri.Join(parts...)
}
func TestSharedWithMeListsAclGrantedFolder(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
member, _, sharedDir, _ := groupFolderFixture(t, client)
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, member.Edges.Group.ID, types.AclPermRead)
n, _ := groupFolderNavigator(t, client, member)
root, err := n.To(ctx, sharedWithMeURI(t))
require.NoError(t, err)
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
require.NoError(t, err)
require.Len(t, res.Files, 1)
require.Equal(t, "team_dir", res.Files[0].Name())
// The entry carries the ACL-derived capability set and real owner.
require.NotNil(t, res.Files[0].Capabilities())
require.True(t, res.Files[0].Capabilities().Enabled(int(NavigatorCapabilityListChildren)))
require.False(t, res.Files[0].Capabilities().Enabled(int(NavigatorCapabilityUploadFile)))
require.Equal(t, sharedDir.OwnerID, res.Files[0].Owner().ID)
require.Equal(t, sharedWithMeURI(t, hashid.EncodeFileID(n.hasher, sharedDir.ID)).String(), res.Files[0].Uri(false).String())
}
func TestSharedWithMeResolvesGroupFolderSubtree(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
member, owner, sharedDir, nested := groupFolderFixture(t, client)
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, member.Edges.Group.ID,
types.AclPermRead, types.AclPermCreate)
n, hasher := groupFolderNavigator(t, client, member)
root, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
require.NoError(t, err)
require.Equal(t, sharedDir.ID, root.Model.ID)
require.Equal(t, owner.ID, root.Owner().ID)
require.True(t, root.Capabilities().Enabled(int(NavigatorCapabilityUploadFile)))
require.True(t, root.Capabilities().Enabled(int(NavigatorCapabilityListChildren)))
require.False(t, root.Capabilities().Enabled(int(NavigatorCapabilityDeleteFile)))
// Deeper elements walk the owner's subtree.
child, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID), "nested.txt"))
require.NoError(t, err)
require.Equal(t, nested.ID, child.Model.ID)
require.True(t, child.Capabilities().Enabled(int(NavigatorCapabilityDownloadFile)))
// Children of the subtree list like a regular folder.
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
require.NoError(t, err)
require.Len(t, res.Files, 1)
require.Equal(t, "nested.txt", res.Files[0].Name())
require.True(t, res.Files[0].Uri(false).String() == sharedWithMeURI(t,
hashid.EncodeFileID(hasher, sharedDir.ID), "nested.txt").String())
}
func TestSharedWithMeDeniesUngranted(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
member, _, sharedDir, _ := groupFolderFixture(t, client)
n, hasher := groupFolderNavigator(t, client, member)
// No ACL at all.
_, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
require.Error(t, err)
// Grant without the read bit is not a browse grant.
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, member.Edges.Group.ID, types.AclPermCreate)
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
require.Error(t, err)
// Grant to a different group does not leak.
other := client.Group.Create().SetName("other").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, other.ID, types.AclPermRead)
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
require.Error(t, err)
// Everyone-tier grants allow browsing but do not surface in the listing.
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeEveryone, 0, types.AclPermRead)
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
require.NoError(t, err)
root, err := n.To(ctx, sharedWithMeURI(t))
require.NoError(t, err)
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
require.NoError(t, err)
require.Empty(t, res.Files)
}
func TestSharedWithMeMembershipGrant(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
member, _, sharedDir, _ := groupFolderFixture(t, client)
// Grant lands on a group the user holds only through a membership.
teamGroup := client.Group.Create().SetName("team").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
m := client.GroupMembership.Create().SetUserID(member.ID).SetGroupID(teamGroup.ID).SaveX(ctx)
m.Edges.Group = teamGroup
member.Edges.Memberships = []*ent.GroupMembership{m}
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeGroup, teamGroup.ID, types.AclPermRead)
n, hasher := groupFolderNavigator(t, client, member)
root, err := n.To(ctx, sharedWithMeURI(t))
require.NoError(t, err)
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
require.NoError(t, err)
require.Len(t, res.Files, 1)
_, err = n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID)))
require.NoError(t, err)
}
func TestSharedWithMeSymbolicShortcutResolves(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
member, _, _, _ := groupFolderFixture(t, client)
// A saved share shortcut of the member's own.
shortcut := client.File.Create().SetName("saved_share").SetType(int(types.FileTypeFolder)).
SetOwner(member).SetIsSymbolic(true).SaveX(ctx)
n, hasher := groupFolderNavigator(t, client, member)
// No ACL on the owner's dir — resolution rides on ownership of the shortcut.
res, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, shortcut.ID)))
require.NoError(t, err)
require.Equal(t, shortcut.ID, res.Model.ID)
require.Equal(t, member.ID, res.Owner().ID)
}
func TestSharedWithMeUserSubjectGrant(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
member, _, sharedDir, nested := groupFolderFixture(t, client)
aclGrant(t, client, sharedDir.ID, aclentry.SubjectTypeUser, member.ID, types.AclPermRead)
n, hasher := groupFolderNavigator(t, client, member)
root, err := n.To(ctx, sharedWithMeURI(t))
require.NoError(t, err)
res, err := n.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 50}})
require.NoError(t, err)
require.Len(t, res.Files, 1)
file, err := n.To(ctx, sharedWithMeURI(t, hashid.EncodeFileID(hasher, sharedDir.ID), nested.Name))
require.NoError(t, err)
require.Equal(t, nested.ID, file.Model.ID)
}

@ -101,6 +101,9 @@ type (
Page *inventory.PaginationArgs
Search *inventory.SearchFileParameters
SharedWithMe bool
// AclSharedIDs unions other owners' ACL-granted files into the
// SharedWithMe orphan listing; nil outside that filesystem.
AclSharedIDs []int
StreamCallback func([]*File)
}
// ListResult is the result of a list operation.
@ -186,11 +189,30 @@ func init() {
NavigatorCapabilityRestore: true,
NavigatorCapabilityInfo: true,
}, trashNavigatorCapability)
// Static superset admitting every action a group/user ACL grant may
// allow; the effective per-file set narrows to the ACL-derived
// capabilities once a shared subtree is resolved.
boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityCreateFile: true,
NavigatorCapabilityRenameFile: true,
NavigatorCapabilityUploadFile: true,
NavigatorCapabilityDownloadFile: true,
NavigatorCapabilityUpdateMetadata: true,
NavigatorCapabilityListChildren: true,
NavigatorCapabilityGenerateThumb: true,
NavigatorCapabilityDeleteFile: true,
NavigatorCapabilityLockFile: true,
NavigatorCapabilitySoftDelete: true,
NavigatorCapabilityInfo: true,
NavigatorCapabilityVersionControl: true,
NavigatorCapabilityEnterFolder: true,
NavigatorCapabilityModifyProps: true,
}, sharedWithMeNavigatorCapability)
boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityListChildren: true,
NavigatorCapabilityDownloadFile: true,
NavigatorCapabilityEnterFolder: true,
}, sharedWithMeNavigatorCapability)
NavigatorCapabilityInfo: true,
}, sharedWithMeRootCapability)
}
// ==================== Base Navigator ====================
@ -296,6 +318,7 @@ func (b *baseNavigator) children(ctx context.Context, parent *File, args *ListAr
children, err := b.fileClient.GetChildFiles(ctx, &inventory.ListFileParameters{
PaginationArgs: args.Page,
SharedWithMe: args.SharedWithMe,
AclSharedIDs: args.AclSharedIDs,
}, b.user.ID, model)
if err != nil {
return nil, fmt.Errorf("failed to get children: %w", err)
@ -422,6 +445,7 @@ func (b *baseNavigator) search(ctx context.Context, parent *File, args *ListArgs
MixedType: true,
Search: args.Search,
SharedWithMe: args.SharedWithMe,
AclSharedIDs: args.AclSharedIDs,
}, b.user.ID, nil)
if err != nil {
return nil, fmt.Errorf("failed to get children: %w", err)

@ -2,7 +2,6 @@ package dbfs
import (
"context"
"errors"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/constants"
@ -15,19 +14,26 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/samber/lo"
)
var sharedWithMeNavigatorCapability = &boolset.BooleanSet{}
// sharedWithMeRootCapability is stamped on the synthetic flat-listing root;
// the listing itself accepts no writes — only entries beneath it do.
var sharedWithMeRootCapability = &boolset.BooleanSet{}
// NewSharedWithMeNavigator creates a navigator for user's "shared with me" file system.
func NewSharedWithMeNavigator(u *ent.User, fileClient inventory.FileClient, l logging.Logger,
config *setting.DBFS, hasher hashid.Encoder) Navigator {
func NewSharedWithMeNavigator(u *ent.User, fileClient inventory.FileClient, aclClient inventory.AclClient,
l logging.Logger, config *setting.DBFS, hasher hashid.Encoder) Navigator {
n := &sharedWithMeNavigator{
user: u,
l: l,
fileClient: fileClient,
aclClient: aclClient,
config: config,
hasher: hasher,
aclRoots: map[int]*File{},
}
n.baseNavigator = newBaseNavigator(fileClient, defaultFilter, u, hasher, config)
return n
@ -37,15 +43,30 @@ type sharedWithMeNavigator struct {
l logging.Logger
user *ent.User
fileClient inventory.FileClient
aclClient inventory.AclClient
config *setting.DBFS
hasher hashid.Encoder
root *File
// aclRoots caches resolved group/ACL-shared subtrees by root file ID so
// repeated To() calls within a request reuse the built parent chain.
aclRoots map[int]*File
// aclCaps carries the ACL-derived capability set of the most recently
// resolved subtree root; nil at the flat listing root.
aclCaps *boolset.BooleanSet
// sharedFiles caches SharedFileIDs for the request scope.
sharedFiles map[int]*boolset.BooleanSet
sharedResolved bool
*baseNavigator
}
func (t *sharedWithMeNavigator) Recycle() {
for _, root := range t.aclRoots {
root.Recycle()
}
if t.root != nil {
t.root.Recycle()
}
}
func (n *sharedWithMeNavigator) PersistState(kv cache.Driver, key string) {
@ -56,17 +77,38 @@ func (n *sharedWithMeNavigator) RestoreState(s State) error {
}
func (t *sharedWithMeNavigator) To(ctx context.Context, path *fs.URI) (*File, error) {
// Anonymous user does not have a trash folder.
// Anonymous user does not have a shared-with-me folder.
if inventory.IsAnonymousUser(t.user) {
return nil, ErrLoginRequired
}
elements := path.Elements()
if len(elements) > 0 {
// Shared with me folder is a flatten tree, only root can be accessed.
return nil, fs.ErrPathNotExist.WithError(fmt.Errorf("invalid Path %q", path))
if len(elements) == 0 {
t.aclCaps = nil
return t.flatRoot(ctx)
}
// The first path element is the hashid of the shared file itself;
// deeper elements walk into it like a regular folder subtree.
root, err := t.resolveSharedRoot(ctx, elements[0])
if err != nil {
return nil, err
}
current, lastAncestor := root, root
for index := 1; index < len(elements); index++ {
lastAncestor = current
current, err = t.walkNext(ctx, current, elements[index], index == len(elements)-1)
if err != nil {
return lastAncestor, fmt.Errorf("failed to walk into %q: %w", elements[index], err)
}
}
return current, nil
}
// flatRoot builds the synthetic listing root shown at cloudreve://shared_with_me.
func (t *sharedWithMeNavigator) flatRoot(ctx context.Context) (*File, error) {
if t.root == nil {
rootFile, err := t.fileClient.Root(ctx, t.user)
if err != nil {
@ -79,29 +121,159 @@ func (t *sharedWithMeNavigator) To(ctx context.Context, path *fs.URI) (*File, er
t.root.Path[pathIndexRoot], t.root.Path[pathIndexUser] = rootPath, rootPath
t.root.OwnerModel = t.user
t.root.IsUserRoot = true
t.root.CapabilitiesBs = t.Capabilities(false).Capability
t.root.CapabilitiesBs = sharedWithMeRootCapability
}
return t.root, nil
}
func (t *sharedWithMeNavigator) Children(ctx context.Context, parent *File, args *ListArgs) (*ListResult, error) {
args.SharedWithMe = true
res, err := t.baseNavigator.children(ctx, nil, args)
// resolveSharedRoot resolves the hashid path element to a browsable subtree
// root: either the user's own symbolic share shortcut (whose redirect the
// caller translates) or a file explicitly shared to the user via ACL.
func (t *sharedWithMeNavigator) resolveSharedRoot(ctx context.Context, elem string) (*File, error) {
fileID, err := t.hasher.Decode(elem, hashid.FileID)
if err != nil {
return nil, fs.ErrPathNotExist.WithError(err)
}
if cached, ok := t.aclRoots[fileID]; ok {
t.aclCaps = cached.CapabilitiesBs
return cached, nil
}
loadCtx := context.WithValue(ctx, inventory.LoadFileUser{}, true)
model, err := t.fileClient.GetByID(loadCtx, fileID)
if err != nil {
return nil, fs.ErrPathNotExist.WithError(err)
}
res := newFile(nil, model)
res.IsUserRoot = true
res.Path[pathIndexUser] = newSharedWithMeUri(elem)
if model.OwnerID == t.user.ID && model.IsSymbolic {
// The user's own saved share shortcut: resolution exists so that
// walking deeper surfaces ErrSymbolicFolderFound and DBFS can
// translate the address to the share URI.
res.OwnerModel = t.user
res.CapabilitiesBs = myNavigatorCapability
t.aclCaps = res.CapabilitiesBs
t.aclRoots[fileID] = res
return res, nil
}
perms, err := t.aclClient.EffectivePermissions(ctx, fileID, t.user)
if err != nil {
return nil, fmt.Errorf("failed to query ACL permissions: %w", err)
}
if perms == nil || !perms.Enabled(int(types.AclPermRead)) {
return nil, fs.ErrPathNotExist
}
owner := model.Edges.Owner
if owner == nil {
return nil, fs.ErrPathNotExist
}
res.OwnerModel = owner
res.CapabilitiesBs = aclPermsToCapabilities(perms)
// Rebuild the owner-side parent chain so storage operations resolve
// against the owner's filesystem, then confirm the file still lives
// under a real user root (not the trash bin).
ownerRoot, err := t.findRoot(ctx, res)
if err != nil {
return nil, fmt.Errorf("failed to resolve shared file root: %w", err)
}
if ownerRoot.Name() != inventory.RootFolderName {
return nil, fs.ErrPathNotExist
}
ownerRoot.Path[pathIndexRoot] = newMyIDUri(hashid.EncodeUserID(t.hasher, owner.ID))
t.aclCaps = res.CapabilitiesBs
t.aclRoots[fileID] = res
return res, nil
}
// sharedFilePerms returns (and caches) the file→permissions map of ACL
// grants visible to the acting user.
func (t *sharedWithMeNavigator) sharedFilePerms(ctx context.Context) (map[int]*boolset.BooleanSet, error) {
if t.sharedResolved {
return t.sharedFiles, nil
}
perms, err := t.aclClient.SharedFileIDs(ctx, t.user)
if err != nil {
return nil, err
}
t.sharedFiles = perms
t.sharedResolved = true
return t.sharedFiles, nil
}
func (t *sharedWithMeNavigator) Children(ctx context.Context, parent *File, args *ListArgs) (*ListResult, error) {
// Inside an ACL-shared subtree the folder is a real model — list it
// like any other owner tree.
if parent != nil && parent != t.root {
return t.baseNavigator.children(ctx, parent, args)
}
perms, err := t.sharedFilePerms(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query shared ACL entries: %w", err)
}
// Adding user uri for each file.
for i := 0; i < len(res.Files); i++ {
res.Files[i].Path[pathIndexUser] = newSharedWithMeUri(hashid.EncodeFileID(t.hasher, res.Files[i].Model.ID))
// decorate stamps the shared_with_me address, ACL-derived capabilities
// and the real owner on a listed entry; applied to both the returned
// page and every streamed batch.
decorate := func(files []*File) []*File {
res := files[:0]
for _, f := range files {
if p, ok := perms[f.Model.ID]; ok {
// ACL-granted entry: stamp the effective capability set and
// the real owner so writePermitted and Owned resolve
// correctly. Ownerless rows are dropped.
if f.Model.Edges.Owner == nil {
continue
}
f.OwnerModel = f.Model.Edges.Owner
f.CapabilitiesBs = aclPermsToCapabilities(p)
}
f.Path[pathIndexUser] = newSharedWithMeUri(hashid.EncodeFileID(t.hasher, f.Model.ID))
f.IsUserRoot = true
res = append(res, f)
}
return res
}
var streamCallback func([]*File)
if args.StreamCallback != nil {
streamCallback = func(files []*File) {
args.StreamCallback(decorate(files))
}
}
loadCtx := context.WithValue(ctx, inventory.LoadFileUser{}, true)
res, err := t.baseNavigator.children(loadCtx, nil, &ListArgs{
Page: args.Page,
Search: args.Search,
SharedWithMe: true,
StreamCallback: streamCallback,
AclSharedIDs: lo.Keys(perms),
})
if err != nil {
return nil, err
}
res.Files = decorate(res.Files)
return res, nil
}
func (t *sharedWithMeNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
res := baseNavigatorProps(sharedWithMeNavigatorCapability, t.config.MaxPageSize)
if t.aclCaps != nil {
res.Capability = t.aclCaps
}
if isSearching {
res.OrderByOptions = searchLimitedOrderByOption
}
@ -110,19 +282,26 @@ func (t *sharedWithMeNavigator) Capabilities(isSearching bool) *fs.NavigatorProp
}
func (t *sharedWithMeNavigator) Walk(ctx context.Context, levelFiles []*File, limit, depth int, f WalkFunc) error {
return errors.New("not implemented")
return t.baseNavigator.walk(ctx, levelFiles, limit, depth, f)
}
func (n *sharedWithMeNavigator) FollowTx(ctx context.Context) (func(), error) {
oldBase := n.baseNavigator.fileClient
revert, err := followTxClients(ctx, &n.fileClient)
revertFile, err := followTxClients(ctx, &n.fileClient)
if err != nil {
return nil, err
}
revertAcl, err := followTxClients(ctx, &n.aclClient)
if err != nil {
revertFile()
return nil, err
}
n.baseNavigator.fileClient = n.fileClient
return func() {
revert()
revertAcl()
revertFile()
n.baseNavigator.fileClient = oldBase
}, nil
}
@ -132,8 +311,13 @@ func (n *sharedWithMeNavigator) ExecuteHook(ctx context.Context, hookType fs.Hoo
}
func (n *sharedWithMeNavigator) GetView(ctx context.Context, file *File) *types.ExplorerView {
if view, ok := n.user.Settings.FsViewMap[string(constants.FileSystemSharedWithMe)]; ok {
return &view
if file != nil && file != n.root {
return file.View()
}
if n.user.Settings != nil {
if view, ok := n.user.Settings.FsViewMap[string(constants.FileSystemSharedWithMe)]; ok {
return &view
}
}
return getDefaultView()
}

Loading…
Cancel
Save