From 361ec0736a4d2ce4675acb4ac313846ca3a2f082 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 17:19:44 +0200 Subject: [PATCH] feat(fs): group folders via ACL discovery in shared_with_me (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. - 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> --- ROADMAP.md | 1 + .../public/locales/en-US/application.json | 2 +- .../public/locales/zh-CN/application.json | 2 +- inventory/acl.go | 52 ++++ inventory/acl_test.go | 53 ++++ inventory/file.go | 15 +- inventory/file_utils.go | 29 ++- pkg/filemanager/fs/dbfs/dbfs.go | 10 +- pkg/filemanager/fs/dbfs/group_folder_test.go | 230 ++++++++++++++++++ pkg/filemanager/fs/dbfs/navigator.go | 28 ++- .../fs/dbfs/sharewithme_navigator.go | 224 +++++++++++++++-- 11 files changed, 611 insertions(+), 35 deletions(-) create mode 100644 pkg/filemanager/fs/dbfs/group_folder_test.go diff --git a/ROADMAP.md b/ROADMAP.md index 8db2bfba..1beced0a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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/` — 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 diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 9371a421..58539f2e 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -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", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 905604d1..84a19479 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -207,7 +207,7 @@ "auto": "自动", "default": "默认", "shareWithMeEmpty": "没有找到别人的分享", - "shareWithMeEmptyDes": "如需要在此看到别人的分享,请在访问别人的分享链接时,在右上角将快捷方式保存到你的文件中的任意位置。", + "shareWithMeEmptyDes": "分享给你或你所在用户组的文件夹会自动出现在这里。你也可以在访问别人的分享链接时,在右上角将快捷方式保存到你的文件中。", "selectAll": "全选", "selectNone": "取消选择", "invertSelection": "反选", diff --git a/inventory/acl.go b/inventory/acl.go index 704edcfd..de20830f 100644 --- a/inventory/acl.go +++ b/inventory/acl.go @@ -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 +} diff --git a/inventory/acl_test.go b/inventory/acl_test.go index 51f15331..000d4afa 100644 --- a/inventory/acl_test.go +++ b/inventory/acl_test.go @@ -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()) }) diff --git a/inventory/file.go b/inventory/file.go index 81da392d..e07be3d5 100644 --- a/inventory/file.go +++ b/inventory/file.go @@ -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 ( diff --git a/inventory/file_utils.go b/inventory/file_utils.go index 364dd716..29c6d472 100644 --- a/inventory/file_utils.go +++ b/inventory/file_utils.go @@ -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())) } diff --git a/pkg/filemanager/fs/dbfs/dbfs.go b/pkg/filemanager/fs/dbfs/dbfs.go index 657a6d16..6a303a00 100644 --- a/pkg/filemanager/fs/dbfs/dbfs.go +++ b/pkg/filemanager/fs/dbfs/dbfs.go @@ -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) } diff --git a/pkg/filemanager/fs/dbfs/group_folder_test.go b/pkg/filemanager/fs/dbfs/group_folder_test.go new file mode 100644 index 00000000..faecea6e --- /dev/null +++ b/pkg/filemanager/fs/dbfs/group_folder_test.go @@ -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) +} diff --git a/pkg/filemanager/fs/dbfs/navigator.go b/pkg/filemanager/fs/dbfs/navigator.go index e7149da5..b5ee69aa 100644 --- a/pkg/filemanager/fs/dbfs/navigator.go +++ b/pkg/filemanager/fs/dbfs/navigator.go @@ -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) diff --git a/pkg/filemanager/fs/dbfs/sharewithme_navigator.go b/pkg/filemanager/fs/dbfs/sharewithme_navigator.go index 2e2bd64e..f7e06f49 100644 --- a/pkg/filemanager/fs/dbfs/sharewithme_navigator.go +++ b/pkg/filemanager/fs/dbfs/sharewithme_navigator.go @@ -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() }