diff --git a/ROADMAP.md b/ROADMAP.md index 3055ff9d..4094cff3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,7 +172,7 @@ Order = user-visible value first; each ships with backend + UI + tests. 1. **Share collaboration** — write/upload/delete via share link, anonymous upload, share ACL (users/groups), preview-only mode (fixes #3555, #3390, #3340, #3517, #3578; uses `NavigatorCapability` placeholder slots + `ShareProps` extension + `share` entity fields). See §1.3a for the extracted UI spec. - [x] PR #140 — `allow_upload`/`allow_edit`/`preview_only`/`upload_only` props, props-derived capability sets enforced server-side (`writePermitted`), same-share move/copy, anonymous upload, drop-box listing suppression, download denial via `IsDownloadCtxKey` hooks (fixes #3555 preview-only, #3340 drop-box) - [ ] File/dir ACL entity — `(subject_type ∈ user|group, subject_id)` → R/C/U/D bitmask + anonymous/everyone tiers; Permissions dialog under More actions; enforced in navigator capability checks (#3517) - - [ ] Default shares — `setting.default_shares` chip-input of share IDs; materialize as share-shortcut entries on user create (#3390) + - [x] Default shares — `setting.default_symbolics` + group `default_pinned` chip-input of share IDs; materialize as share-shortcut entries on fs init (#180) - [ ] Paid shares — `share.price_points` + gate page + purchase/resume-ticket flow; needs B.4 credits first 2. **Storage policy advanced** — multiple policies per group, per-directory binding, load-balancer policy, file migration (fixes #3518, #2961, #2262). See §1.3a. - [x] PR #175 — resumable admin relocation task (entities or whole-policy scope), encryption-aware re-wrap, admin UI + per-policy migrate action (#9, #125, #136) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index c7ca98ce..02c73547 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -64,6 +64,7 @@ export interface GroupSetting { trash_retention?: number; redirected_source?: boolean; login_ip_whitelist?: string[]; + default_pinned?: number[]; } export interface AdminListGroupResponse { diff --git a/frontend/src/component/Admin/Common/SharesInput.tsx b/frontend/src/component/Admin/Common/SharesInput.tsx index b0b7f5e5..b980a846 100644 --- a/frontend/src/component/Admin/Common/SharesInput.tsx +++ b/frontend/src/component/Admin/Common/SharesInput.tsx @@ -1,30 +1,65 @@ -import { Box, debounce, useTheme } from "@mui/material"; -import { useEffect, useMemo, useState } from "react"; +import { useTheme } from "@mui/material"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getShareList } from "../../../api/api.ts"; +import { getShareDetail, getShareList } from "../../../api/api.ts"; import { Share } from "../../../api/dashboard.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; -import { DenseAutocomplete, DenseFilledTextField, NoWrapBox, SquareChip } from "../../Common/StyledComponents.tsx"; -import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx"; -import LinkDismiss from "../../Icons/LinkDismiss.tsx"; +import { DenseAutocomplete, DenseFilledTextField } from "../../Common/StyledComponents.tsx"; -export interface SharesInputProps {} +export interface SharesInputProps { + value: number[]; + onChange: (value: number[]) => void; +} -const SharesInput = (props: SharesInputProps) => { +const optionLabel = (s: Share) => (s.edges?.file?.name ? `${s.edges.file.name} (#${s.id})` : `#${s.id}`); + +const SharesInput = ({ value, onChange }: SharesInputProps) => { const theme = useTheme(); const { t } = useTranslation(); - const [options, setOptions] = useState([]); + const dispatch = useAppDispatch(); + const [options, setOptions] = useState([]); + const [selected, setSelected] = useState([]); + + // Resolve currently selected share ids to share objects for display. + useEffect(() => { + let mounted = true; + Promise.all(value.map((id) => dispatch(getShareDetail(id)).catch(() => null))) + .then((res) => { + if (mounted) { + setSelected(res.filter((s): s is Share => s != null)); + } + }); + return () => { + mounted = false; + }; + }, [value]); + + // Load the most recent shares as the option pool; filtering is client-side. + useEffect(() => { + dispatch( + getShareList({ + page: 1, + page_size: 50, + order_by: "id", + order_direction: "desc", + }), + ) + .then((res) => setOptions(res.shares)) + .catch(() => {}); + }, []); return ( (o as Share).id === (v as Share).id} + getOptionLabel={(o) => optionLabel(o as Share)} + onChange={(_, v) => onChange((v as Share[]).map((s) => s.id))} renderInput={(params) => ( { const { t } = useTranslation("dashboard"); - const { values } = useContext(GroupSettingContext); + const { values, setGroup } = useContext(GroupSettingContext); if (values?.id == AnonymousGroupID) { return null; @@ -24,7 +24,15 @@ const DefaultPinnedSection = () => { - + + setGroup((p) => ({ + ...p, + settings: { ...p.settings, default_pinned: ids.length > 0 ? ids : undefined }, + })) + } + /> { - + + setSettings({ + default_symbolics: JSON.stringify(ids), + }) + } + /> 0 { + shareIDs = lo.Union(shareIDs, group.Settings.DefaultPinned) + } + } + } + + for _, sid := range shareIDs { + s, err := f.shareClient.GetByID(shareCtx, sid) + if err != nil || s == nil || s.Edges.File == nil { + f.l.Warning("[DBFS] Skip invalid default share %d: %v", sid, err) + continue + } + if err := inventory.IsValidShare(s); err != nil { + f.l.Warning("[DBFS] Skip default share %d: %v", sid, err) + continue + } + + metadata := map[string]string{ + MetadataSharedRedirect: fs.NewShareUri(hashid.EncodeShareID(f.hasher, s.ID), ""), + } + if s.Edges.User != nil { + metadata[MetadataSharedOwner] = hashid.EncodeUserID(f.hasher, s.Edges.User.ID) + } + + if s.Edges.File.Type == int(types.FileTypeFolder) { + _, err = f.fileClient.CreateFolder(ctx, root, &inventory.CreateFolderParameters{ + Owner: uid, + Name: s.Edges.File.Name, + IsSymbolic: true, + Metadata: metadata, + }) + } else { + _, _, _, err = f.fileClient.CreateFile(ctx, root, &inventory.CreateFileParameters{ + FileType: types.FileTypeFile, + Name: s.Edges.File.Name, + IsSymbolic: true, + StoragePolicyID: policyID, + Metadata: metadata, + }) + } + if err != nil { + f.l.Warning("[DBFS] Failed to seed default share %d: %v", sid, err) + } + } +} + func (f *DBFS) getNavigator(ctx context.Context, path *fs.URI, requiredCapabilities ...NavigatorCapability) (Navigator, error) { pathFs := path.FileSystem() config := f.settingClient.DBFS(ctx) diff --git a/pkg/filemanager/fs/dbfs/default_shares_test.go b/pkg/filemanager/fs/dbfs/default_shares_test.go new file mode 100644 index 00000000..e7ce71f7 --- /dev/null +++ b/pkg/filemanager/fs/dbfs/default_shares_test.go @@ -0,0 +1,165 @@ +package dbfs + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + entfile "github.com/cloudreve/Cloudreve/v4/ent/file" + entmetadata "github.com/cloudreve/Cloudreve/v4/ent/metadata" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" + "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" +) + +type defaultShareSettingProvider struct { + setting.Provider + ids []int +} + +func (p defaultShareSettingProvider) DefaultShares(context.Context) []int { + return p.ids +} + +// defaultShareFixture creates a share owner with shared file/dir, and a +// separate new user with an empty root. Returns (newUser, newUserRoot, +// fileShare, dirShare, owner). +func defaultShareFixture(t *testing.T, client *ent.Client) (*ent.User, *ent.File, *ent.Share, *ent.Share, *ent.User) { + t.Helper() + ctx := context.Background() + policy := client.StoragePolicy.Create().SetName("local").SetType("local"). + SetStatus(storagepolicy.StatusActive).SaveX(ctx) + permissions := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionShare, true, permissions) + group := client.Group.Create().SetName("seed").SetPermissions(permissions). + SetStoragePolicies(policy).SaveX(ctx) + + owner := client.User.Create().SetEmail("owner@example.com").SetNick("o").SetGroup(group).SaveX(ctx) + ownerRoot := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx) + sharedFile := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(ownerRoot).SaveX(ctx) + sharedDir := client.File.Create().SetName("shared_dir").SetType(int(types.FileTypeFolder)).SetOwner(owner).SetParent(ownerRoot).SaveX(ctx) + fileShare := client.Share.Create().SetUser(owner).SetFile(sharedFile).SaveX(ctx) + dirShare := client.Share.Create().SetUser(owner).SetFile(sharedDir).SaveX(ctx) + + newUser := client.User.Create().SetEmail("new@example.com").SetNick("n").SetGroup(group).SaveX(ctx) + newRoot := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(newUser).SaveX(ctx) + return newUser, newRoot, fileShare, dirShare, owner +} + +func seedTestDBFS(t *testing.T, client *ent.Client, ids []int) *DBFS { + t.Helper() + hasher, err := hashid.New("seed-test-salt") + require.NoError(t, err) + return &DBFS{ + fileClient: inventory.NewFileClient(client, conf.SQLiteDB, hasher), + shareClient: inventory.NewShareClient(client, conf.SQLiteDB, hasher), + userClient: inventory.NewUserClient(client), + storagePolicyClient: inventory.NewStoragePolicyClient(client, nil), + settingClient: defaultShareSettingProvider{ids: ids}, + hasher: hasher, + l: logging.NewConsoleLogger(logging.LevelError), + } +} + +func metadataOf(t *testing.T, client *ent.Client, f *ent.File) map[string]string { + t.Helper() + rows := client.Metadata.Query().Where(entmetadata.HasFileWith(entfile.ID(f.ID))).AllX(context.Background()) + res := make(map[string]string, len(rows)) + for _, r := range rows { + res[r.Name] = r.Value + } + return res +} + +func TestSeedDefaultSharesCreatesSymbolicShortcuts(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, root, fileShare, dirShare, owner := defaultShareFixture(t, client) + f := seedTestDBFS(t, client, []int{fileShare.ID, dirShare.ID}) + f.seedDefaultShares(ctx, user.ID, root) + + shortcuts := client.File.Query(). + Where(entfile.FileChildren(root.ID), entfile.IsSymbolic(true)). + AllX(ctx) + require.Len(t, shortcuts, 2) + + byName := map[string]*ent.File{} + for _, c := range shortcuts { + byName[c.Name] = c + } + + fileShortcut := byName["shared.txt"] + require.NotNil(t, fileShortcut) + require.Equal(t, int(types.FileTypeFile), fileShortcut.Type) + require.Equal(t, user.ID, fileShortcut.OwnerID) + + dirShortcut := byName["shared_dir"] + require.NotNil(t, dirShortcut) + require.Equal(t, int(types.FileTypeFolder), dirShortcut.Type) + + fm := metadataOf(t, client, fileShortcut) + require.Equal(t, fs.NewShareUri(hashid.EncodeShareID(f.hasher, fileShare.ID), ""), fm[MetadataSharedRedirect]) + require.Equal(t, hashid.EncodeUserID(f.hasher, owner.ID), fm[MetadataSharedOwner]) + + dm := metadataOf(t, client, dirShortcut) + require.Equal(t, fs.NewShareUri(hashid.EncodeShareID(f.hasher, dirShare.ID), ""), dm[MetadataSharedRedirect]) +} + +func TestSeedDefaultSharesSkipsInvalidIDs(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, root, fileShare, _, _ := defaultShareFixture(t, client) + f := seedTestDBFS(t, client, []int{0, -1, 99999, fileShare.ID}) + f.seedDefaultShares(ctx, user.ID, root) + + shortcuts := client.File.Query(). + Where(entfile.FileChildren(root.ID), entfile.IsSymbolic(true)). + AllX(ctx) + require.Len(t, shortcuts, 1) + require.Equal(t, "shared.txt", shortcuts[0].Name) +} + +func TestSeedDefaultSharesNoConfigIsNoop(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, root, _, _, _ := defaultShareFixture(t, client) + f := seedTestDBFS(t, client, nil) + f.seedDefaultShares(ctx, user.ID, root) + + shortcuts := client.File.Query(). + Where(entfile.FileChildren(root.ID), entfile.IsSymbolic(true)). + AllX(ctx) + require.Empty(t, shortcuts) +} + +func TestSeedDefaultSharesMergesGroupPinned(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() + + // Site config seeds the file share; the new user's group pins the dir share. + user, root, fileShare, dirShare, _ := defaultShareFixture(t, client) + group := user.QueryGroup().OnlyX(ctx) + client.Group.UpdateOne(group).SetSettings(&types.GroupSetting{DefaultPinned: []int{dirShare.ID}}).SaveX(ctx) + f := seedTestDBFS(t, client, []int{fileShare.ID}) + f.seedDefaultShares(ctx, user.ID, root) + + shortcuts := client.File.Query(). + Where(entfile.FileChildren(root.ID), entfile.IsSymbolic(true)). + AllX(ctx) + require.Len(t, shortcuts, 2) +} diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 6629c0de..3fbbc6aa 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -67,6 +67,9 @@ type ( HashIDSalt(ctx context.Context) string // DBFS returns the DBFS related settings. DBFS(ctx context.Context) *DBFS + // DefaultShares returns the share entity IDs seeded as share-shortcut + // entries in every newly initialized file system. + DefaultShares(ctx context.Context) []int // MaxBatchedFile returns the maximum number of files in a batch operation. MaxBatchedFile(ctx context.Context) int // UploadSessionTTL returns the TTL of upload session. @@ -711,6 +714,19 @@ func (s *settingProvider) MaxBatchedFile(ctx context.Context) int { return s.getInt(ctx, "max_batched_file", 3000) } +func (s *settingProvider) DefaultShares(ctx context.Context) []int { + raw := s.getString(ctx, "default_symbolics", "") + if raw == "" { + return nil + } + + var ids []int + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return nil + } + return ids +} + func (s *settingProvider) DBFS(ctx context.Context) *DBFS { return &DBFS{ UseCursorPagination: s.getBoolean(ctx, "use_cursor_pagination", true),