From 9bf2e14b5f36fe9675d3dd73641c00c4a8e05c32 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 22:51:50 +0200 Subject: [PATCH] feat(share): owner-private note on shares (#3570) Share owners can now attach a free-text note/alias to a share for identifying it in My Shares. The note lives in the existing ShareProps JSON column (no schema migration), is settable on create and edit via the share dialog, renders as a secondary line on share cards, and is only serialized for the share owner - BuildShare omits it for visitors and anonymous viewers, covered by a new redaction test. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../public/locales/en-US/application.json | 2 + .../public/locales/zh-CN/application.json | 2 + frontend/src/api/explorer.ts | 2 + .../FileManager/Dialogs/Share/ShareDialog.tsx | 1 + .../Dialogs/Share/ShareSetting.tsx | 33 +++++++++ .../src/component/Pages/Shares/ShareCard.tsx | 7 ++ frontend/src/redux/thunks/share.ts | 1 + inventory/types/types.go | 3 + pkg/filemanager/manager/manager.go | 1 + pkg/filemanager/manager/operation.go | 1 + service/explorer/response.go | 3 + service/explorer/response_share_test.go | 68 +++++++++++++++++++ service/share/manage.go | 3 + 13 files changed, 127 insertions(+) create mode 100644 service/explorer/response_share_test.go 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