diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 6ad33e84..a1435408 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -634,6 +634,8 @@ "uploadOnlyDes": "Visitors can upload files but cannot see or download existing content in this shared folder.", "previewOnly": "Preview only", "previewOnlyDes": "Visitors can preview files inline but cannot download them.", + "shareNote": "Note", + "shareNoteDes": "A private note to identify this share. Only visible to you in My Shares.", "showReadme": "Show README file", "showReadmeDes": "If selected, the <0>README.md file (case-sensitive) in the directory will be automatically displayed for visitors.", "viewSetting": "View setting", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 25e5fedd..1b58829e 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -634,6 +634,8 @@ "uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。", "previewOnly": "仅预览", "previewOnlyDes": "访客可以在线预览文件,但不能下载。", + "shareNote": "备注", + "shareNoteDes": "用于识别此分享的私有备注,仅在“我的分享”中对你可见。", "showReadme": "显示 README 文件", "showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md (区分大小写) 文件。", "viewSetting": "视图设置", diff --git a/frontend/src/api/explorer.ts b/frontend/src/api/explorer.ts index 86b6cd0e..bf75c67f 100644 --- a/frontend/src/api/explorer.ts +++ b/frontend/src/api/explorer.ts @@ -94,6 +94,7 @@ export interface Share { allow_edit?: boolean; preview_only?: boolean; upload_only?: boolean; + note?: string; } export enum PolicyType { @@ -319,6 +320,7 @@ export interface ShareCreateService { allow_edit?: boolean; preview_only?: boolean; upload_only?: boolean; + note?: string; } export interface CreateFileService { diff --git a/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx b/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx index 43676403..753baea0 100644 --- a/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx +++ b/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx @@ -38,6 +38,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => { allow_edit: share.allow_edit, preview_only: share.preview_only, upload_only: share.upload_only, + note: share.note, downloads: share.remain_downloads != undefined && share.remain_downloads > 0, expires_val: expireOptions[2], diff --git a/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx b/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx index bd5837e7..1c99e2d7 100644 --- a/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx +++ b/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx @@ -28,6 +28,7 @@ import Edit from "../../../Icons/Edit.tsx"; import Eye from "../../../Icons/Eye.tsx"; import EyeOff from "../../../Icons/EyeOff.tsx"; import FolderAdd from "../../../Icons/FolderAdd.tsx"; +import RenameOutlined from "../../../Icons/RenameOutlined.tsx"; import TableSettingsOutlined from "../../../Icons/TableSettings.tsx"; import Timer from "../../../Icons/Timer.tsx"; import Upload from "../../../Icons/Upload.tsx"; @@ -87,6 +88,7 @@ export interface ShareSetting { allow_edit?: boolean; preview_only?: boolean; upload_only?: boolean; + note?: string; downloads?: boolean; expires?: boolean; @@ -210,6 +212,37 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS )} + + + + + + + + + + + + {t("application:modals.shareNoteDes")} + + + onSettingChange({ ...setting, note: e.target.value })} + /> + + + diff --git a/frontend/src/component/Pages/Shares/ShareCard.tsx b/frontend/src/component/Pages/Shares/ShareCard.tsx index 4bd69955..233f38f0 100644 --- a/frontend/src/component/Pages/Shares/ShareCard.tsx +++ b/frontend/src/component/Pages/Shares/ShareCard.tsx @@ -234,6 +234,13 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading, selecting, selected )} + {share?.note && ( + + + {share.note} + + + )} diff --git a/frontend/src/redux/thunks/share.ts b/frontend/src/redux/thunks/share.ts index fcdbe4b5..05c526fe 100644 --- a/frontend/src/redux/thunks/share.ts +++ b/frontend/src/redux/thunks/share.ts @@ -34,6 +34,7 @@ export function createOrUpdateShareLink( allow_edit: setting.allow_edit, preview_only: setting.preview_only, upload_only: setting.upload_only, + note: setting.note?.trim() || undefined, downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined, expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined, }; diff --git a/inventory/types/types.go b/inventory/types/types.go index f95d789d..43faad56 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -241,6 +241,9 @@ type ( PreviewOnly bool `json:"preview_only,omitempty"` // Whether share visitors can upload but cannot list or download (drop box) UploadOnly bool `json:"upload_only,omitempty"` + // Owner-defined note/alias for identifying the share in My Shares; + // never exposed to share visitors (#3570). + Note string `json:"note,omitempty"` } OAuthClientProps struct { diff --git a/pkg/filemanager/manager/manager.go b/pkg/filemanager/manager/manager.go index 2f022f7a..1352bea5 100644 --- a/pkg/filemanager/manager/manager.go +++ b/pkg/filemanager/manager/manager.go @@ -127,6 +127,7 @@ type ( AllowEdit bool PreviewOnly bool UploadOnly bool + Note string } FullTextSearchResults struct { diff --git a/pkg/filemanager/manager/operation.go b/pkg/filemanager/manager/operation.go index 5097f5b5..7e2ce99f 100644 --- a/pkg/filemanager/manager/operation.go +++ b/pkg/filemanager/manager/operation.go @@ -355,6 +355,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C AllowEdit: args.AllowEdit, PreviewOnly: args.PreviewOnly, UploadOnly: args.UploadOnly, + Note: args.Note, } share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{ diff --git a/service/explorer/response.go b/service/explorer/response.go index 7b4688d8..fd94a473 100644 --- a/service/explorer/response.go +++ b/service/explorer/response.go @@ -342,6 +342,7 @@ type Share struct { AllowEdit bool `json:"allow_edit,omitempty"` PreviewOnly bool `json:"preview_only,omitempty"` UploadOnly bool `json:"upload_only,omitempty"` + Note string `json:"note,omitempty"` // Only viewable if explicitly unlocked by owner SourceUri string `json:"source_uri,omitempty"` @@ -393,6 +394,8 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid. res.AllowEdit = s.Props.AllowEdit res.PreviewOnly = s.Props.PreviewOnly res.UploadOnly = s.Props.UploadOnly + // Owner-private note; never sent to share visitors (#3570). + res.Note = s.Props.Note } } diff --git a/service/explorer/response_share_test.go b/service/explorer/response_share_test.go new file mode 100644 index 00000000..d0cb1e94 --- /dev/null +++ b/service/explorer/response_share_test.go @@ -0,0 +1,68 @@ +package explorer + +import ( + "context" + "net/url" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/require" +) + +// stubSettingProvider satisfies the SettingProvider dependency; only +// ExposeUserEmail is exercised by BuildUserRedacted. +type stubSettingProvider struct { + setting.Provider +} + +func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false } + +// TestBuildShareNoteVisibility ensures the owner-defined share note is only +// exposed to the share owner, never to visitors (upstream #3570). +func TestBuildShareNoteVisibility(t *testing.T) { + dep := dependency.NewDependency(dependency.WithSettingProvider(stubSettingProvider{})) + ctx := context.WithValue(context.Background(), dependency.DepCtx{}, dep) + + hasher, err := hashid.New("test-salt") + require.NoError(t, err) + base := &url.URL{Scheme: "https", Host: "example.com"} + + owner := &ent.User{ID: 1, Email: "owner@example.com", Settings: &types.UserSetting{}} + visitor := &ent.User{ID: 2, Email: "visitor@example.com", Settings: &types.UserSetting{}} + anonymous := &ent.User{ID: 0, Settings: &types.UserSetting{}} + + newShare := func() *ent.Share { + return &ent.Share{ + ID: 1, + Props: &types.ShareProps{Note: "tax receipts Q3"}, + } + } + + t.Run("owner sees note", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, owner, owner, + "receipts.zip", types.FileTypeFile, true, false) + require.Equal(t, "tax receipts Q3", res.Note) + }) + + t.Run("unlocked visitor does not see note", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, visitor, owner, + "receipts.zip", types.FileTypeFile, true, false) + require.Empty(t, res.Note) + }) + + t.Run("anonymous visitor does not see note", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, anonymous, owner, + "receipts.zip", types.FileTypeFile, true, false) + require.Empty(t, res.Note) + }) + + t.Run("locked share does not leak note to visitor", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, visitor, owner, + "receipts.zip", types.FileTypeFile, false, false) + require.Empty(t, res.Note) + }) +} diff --git a/service/share/manage.go b/service/share/manage.go index 45ef01b1..570a01d1 100644 --- a/service/share/manage.go +++ b/service/share/manage.go @@ -31,6 +31,8 @@ type ( AllowEdit bool `json:"allow_edit"` PreviewOnly bool `json:"preview_only"` UploadOnly bool `json:"upload_only"` + // Optional owner-defined note shown on My Shares (#3570). + Note string `json:"note" binding:"omitempty,max=255"` } ShareCreateParamCtx struct{} @@ -98,6 +100,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string, AllowEdit: service.AllowEdit, PreviewOnly: service.PreviewOnly, UploadOnly: service.UploadOnly, + Note: service.Note, }) if err != nil { return "", err