feat(filemanager): default share shortcuts for new users (site + group pinned)

Materializes the Pro "default shares" surface: shares configured in
`default_symbolics` (site-wide) and group `settings.default_pinned` are
seeded as symbolic share-shortcut entries in the root of every newly
initialized file system, reusing the existing shared_redirect/shared_owner
metadata machinery.

- SettingProvider.DefaultShares parses the JSON id list
- DBFS.initFs seeds shortcuts after root creation; invalid/expired
  shares are skipped without failing signup
- Group-level pinned list is merged with the site list (deduped)
- SharesInput fleshed out from its dead scaffold into a working
  autocomplete bound to share ids, used by both the site settings and
  the previously-unwired group DefaultPinnedSection

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent 47aeba0ecb
commit d0cf0995aa

@ -64,6 +64,7 @@ export interface GroupSetting {
trash_retention?: number;
redirected_source?: boolean;
login_ip_whitelist?: string[];
default_pinned?: number[];
}
export interface AdminListGroupResponse {

@ -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<number[]>([]);
const dispatch = useAppDispatch();
const [options, setOptions] = useState<Share[]>([]);
const [selected, setSelected] = useState<Share[]>([]);
// 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 (
<DenseAutocomplete
multiple
options={options}
blurOnSelect
value={selected}
isOptionEqualToValue={(o, v) => (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) => (
<DenseFilledTextField
{...params}
sx={{
"& .MuiInputBase-root": {},
"& .MuiInputBase-root.MuiOutlinedInput-root": {
paddingTop: theme.spacing(0.6),
paddingBottom: theme.spacing(0.6),

@ -10,7 +10,7 @@ import { GroupSettingContext } from "./GroupSettingWrapper";
const DefaultPinnedSection = () => {
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 = () => {
<SettingSectionContent>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<SharesInput />
<SharesInput
value={values?.settings?.default_pinned ?? []}
onChange={(ids) =>
setGroup((p) => ({
...p,
settings: { ...p.settings, default_pinned: ids.length > 0 ? ids : undefined },
}))
}
/>
<NoMarginHelperText>
<Trans
i18nKey="group.defaultPinnedDes"

@ -149,7 +149,14 @@ const UserSession = () => {
</SettingForm>
<SettingForm title={t("settings.defaultSymbolics")} lgWidth={5}>
<FormControl>
<SharesInput />
<SharesInput
value={defaultSymbolics}
onChange={(ids) =>
setSettings({
default_symbolics: JSON.stringify(ids),
})
}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.defaultSymbolicsDes"

@ -54,6 +54,9 @@ type (
// LoginIPWhitelist restricts sign-in to the given IPs/CIDR ranges.
// Empty means no restriction.
LoginIPWhitelist []string `json:"login_ip_whitelist,omitempty"`
// DefaultPinned is a list of share entity IDs seeded as share
// shortcuts for members of this group on file-system init.
DefaultPinned []int `json:"default_pinned,omitempty"`
}
// PolicySetting 非公有的存储策略属性

@ -768,7 +768,7 @@ func (f *DBFS) getFileByPath(ctx context.Context, navigator Navigator, path *fs.
// initFs initializes the file system for the user.
func (f *DBFS) initFs(ctx context.Context, uid int) error {
f.l.Info("Initialize database file system for user %q", f.user.Email)
_, err := f.fileClient.CreateFolder(ctx, nil,
root, err := f.fileClient.CreateFolder(ctx, nil,
&inventory.CreateFolderParameters{
Owner: uid,
Name: inventory.RootFolderName,
@ -777,9 +777,78 @@ func (f *DBFS) initFs(ctx context.Context, uid int) error {
return fmt.Errorf("failed to create root folder: %w", err)
}
f.seedDefaultShares(ctx, uid, root)
return nil
}
// seedDefaultShares materializes share shortcuts configured in the
// `default_shares` setting into the root of a newly created file system.
// Invalid or expired shares are skipped without failing initialization.
func (f *DBFS) seedDefaultShares(ctx context.Context, uid int, root *ent.File) {
shareIDs := f.settingClient.DefaultShares(ctx)
if len(shareIDs) == 0 {
return
}
shareCtx := context.WithValue(ctx, inventory.LoadShareFile{}, true)
shareCtx = context.WithValue(shareCtx, inventory.LoadShareUser{}, true)
// Symbolic entries carry no entities, but the storage_policy_files
// column is non-nullable; bind to the new user's group policy.
policyID := 0
userCtx := context.WithValue(ctx, inventory.LoadUserGroup{}, true)
if owner, err := f.userClient.GetByID(userCtx, uid); err == nil {
if group, err := owner.Edges.GroupOrErr(); err == nil {
if policy, err := f.storagePolicyClient.GetByGroup(ctx, group); err == nil && policy != nil {
policyID = policy.ID
}
if group.Settings != nil && len(group.Settings.DefaultPinned) > 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)

@ -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)
}

@ -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),

Loading…
Cancel
Save