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 <info@tdvorak.dev>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 67871b8e89
commit 9bf2e14b5f

@ -634,6 +634,8 @@
"uploadOnlyDes": "Visitors can upload files but cannot see or download existing content in this shared folder.", "uploadOnlyDes": "Visitors can upload files but cannot see or download existing content in this shared folder.",
"previewOnly": "Preview only", "previewOnly": "Preview only",
"previewOnlyDes": "Visitors can preview files inline but cannot download them.", "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", "showReadme": "Show README file",
"showReadmeDes": "If selected, the <0>README.md</0> file (case-sensitive) in the directory will be automatically displayed for visitors.", "showReadmeDes": "If selected, the <0>README.md</0> file (case-sensitive) in the directory will be automatically displayed for visitors.",
"viewSetting": "View setting", "viewSetting": "View setting",

@ -634,6 +634,8 @@
"uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。", "uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。",
"previewOnly": "仅预览", "previewOnly": "仅预览",
"previewOnlyDes": "访客可以在线预览文件,但不能下载。", "previewOnlyDes": "访客可以在线预览文件,但不能下载。",
"shareNote": "备注",
"shareNoteDes": "用于识别此分享的私有备注,仅在“我的分享”中对你可见。",
"showReadme": "显示 README 文件", "showReadme": "显示 README 文件",
"showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md</0> (区分大小写) 文件。", "showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md</0> (区分大小写) 文件。",
"viewSetting": "视图设置", "viewSetting": "视图设置",

@ -94,6 +94,7 @@ export interface Share {
allow_edit?: boolean; allow_edit?: boolean;
preview_only?: boolean; preview_only?: boolean;
upload_only?: boolean; upload_only?: boolean;
note?: string;
} }
export enum PolicyType { export enum PolicyType {
@ -319,6 +320,7 @@ export interface ShareCreateService {
allow_edit?: boolean; allow_edit?: boolean;
preview_only?: boolean; preview_only?: boolean;
upload_only?: boolean; upload_only?: boolean;
note?: string;
} }
export interface CreateFileService { export interface CreateFileService {

@ -38,6 +38,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
allow_edit: share.allow_edit, allow_edit: share.allow_edit,
preview_only: share.preview_only, preview_only: share.preview_only,
upload_only: share.upload_only, upload_only: share.upload_only,
note: share.note,
downloads: share.remain_downloads != undefined && share.remain_downloads > 0, downloads: share.remain_downloads != undefined && share.remain_downloads > 0,
expires_val: expireOptions[2], expires_val: expireOptions[2],

@ -28,6 +28,7 @@ import Edit from "../../../Icons/Edit.tsx";
import Eye from "../../../Icons/Eye.tsx"; import Eye from "../../../Icons/Eye.tsx";
import EyeOff from "../../../Icons/EyeOff.tsx"; import EyeOff from "../../../Icons/EyeOff.tsx";
import FolderAdd from "../../../Icons/FolderAdd.tsx"; import FolderAdd from "../../../Icons/FolderAdd.tsx";
import RenameOutlined from "../../../Icons/RenameOutlined.tsx";
import TableSettingsOutlined from "../../../Icons/TableSettings.tsx"; import TableSettingsOutlined from "../../../Icons/TableSettings.tsx";
import Timer from "../../../Icons/Timer.tsx"; import Timer from "../../../Icons/Timer.tsx";
import Upload from "../../../Icons/Upload.tsx"; import Upload from "../../../Icons/Upload.tsx";
@ -87,6 +88,7 @@ export interface ShareSetting {
allow_edit?: boolean; allow_edit?: boolean;
preview_only?: boolean; preview_only?: boolean;
upload_only?: boolean; upload_only?: boolean;
note?: string;
downloads?: boolean; downloads?: boolean;
expires?: boolean; expires?: boolean;
@ -210,6 +212,37 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
)} )}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
<Accordion expanded={expanded === "note"} onChange={handleExpand("note")}>
<AccordionSummary aria-controls="panel-note-content" id="panel-note-header">
<StyledListItemButton>
<ListItemIcon>
<RenameOutlined />
</ListItemIcon>
<ListItemText
primary={t("application:modals.shareNote")}
secondary={setting.note || undefined}
secondaryTypographyProps={{ noWrap: true }}
/>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" sx={{ mb: 1 }}>
{t("application:modals.shareNoteDes")}
</Typography>
<FormControl variant="standard" fullWidth>
<FilledTextField
label={t("application:modals.shareNote")}
slotProps={{
htmlInput: {
maxLength: 255,
},
}}
value={setting.note ?? ""}
onChange={(e) => onSettingChange({ ...setting, note: e.target.value })}
/>
</FormControl>
</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "preview_only"} onChange={handleExpand("preview_only")}> <Accordion expanded={expanded === "preview_only"} onChange={handleExpand("preview_only")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header"> <AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton> <StyledListItemButton>

@ -234,6 +234,13 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading, selecting, selected
<Chip size="small" label={t("application:share.expired")} sx={{ ml: 1, height: 18 }} /> <Chip size="small" label={t("application:share.expired")} sx={{ ml: 1, height: 18 }} />
)} )}
</Box> </Box>
{share?.note && (
<Tooltip title={share.note}>
<NoWrapTypography variant={"body2"} color={"text.secondary"}>
{share.note}
</NoWrapTypography>
</Tooltip>
)}
<Box> <Box>
<Tooltip title={share?.name ?? ""}> <Tooltip title={share?.name ?? ""}>
<NoWrapTypography variant={"body2"} color={"text.secondary"}> <NoWrapTypography variant={"body2"} color={"text.secondary"}>

@ -34,6 +34,7 @@ export function createOrUpdateShareLink(
allow_edit: setting.allow_edit, allow_edit: setting.allow_edit,
preview_only: setting.preview_only, preview_only: setting.preview_only,
upload_only: setting.upload_only, upload_only: setting.upload_only,
note: setting.note?.trim() || undefined,
downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : 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, expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined,
}; };

@ -241,6 +241,9 @@ type (
PreviewOnly bool `json:"preview_only,omitempty"` PreviewOnly bool `json:"preview_only,omitempty"`
// Whether share visitors can upload but cannot list or download (drop box) // Whether share visitors can upload but cannot list or download (drop box)
UploadOnly bool `json:"upload_only,omitempty"` 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 { OAuthClientProps struct {

@ -127,6 +127,7 @@ type (
AllowEdit bool AllowEdit bool
PreviewOnly bool PreviewOnly bool
UploadOnly bool UploadOnly bool
Note string
} }
FullTextSearchResults struct { FullTextSearchResults struct {

@ -355,6 +355,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
AllowEdit: args.AllowEdit, AllowEdit: args.AllowEdit,
PreviewOnly: args.PreviewOnly, PreviewOnly: args.PreviewOnly,
UploadOnly: args.UploadOnly, UploadOnly: args.UploadOnly,
Note: args.Note,
} }
share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{ share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{

@ -342,6 +342,7 @@ type Share struct {
AllowEdit bool `json:"allow_edit,omitempty"` AllowEdit bool `json:"allow_edit,omitempty"`
PreviewOnly bool `json:"preview_only,omitempty"` PreviewOnly bool `json:"preview_only,omitempty"`
UploadOnly bool `json:"upload_only,omitempty"` UploadOnly bool `json:"upload_only,omitempty"`
Note string `json:"note,omitempty"`
// Only viewable if explicitly unlocked by owner // Only viewable if explicitly unlocked by owner
SourceUri string `json:"source_uri,omitempty"` 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.AllowEdit = s.Props.AllowEdit
res.PreviewOnly = s.Props.PreviewOnly res.PreviewOnly = s.Props.PreviewOnly
res.UploadOnly = s.Props.UploadOnly res.UploadOnly = s.Props.UploadOnly
// Owner-private note; never sent to share visitors (#3570).
res.Note = s.Props.Note
} }
} }

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

@ -31,6 +31,8 @@ type (
AllowEdit bool `json:"allow_edit"` AllowEdit bool `json:"allow_edit"`
PreviewOnly bool `json:"preview_only"` PreviewOnly bool `json:"preview_only"`
UploadOnly bool `json:"upload_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{} ShareCreateParamCtx struct{}
@ -98,6 +100,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
AllowEdit: service.AllowEdit, AllowEdit: service.AllowEdit,
PreviewOnly: service.PreviewOnly, PreviewOnly: service.PreviewOnly,
UploadOnly: service.UploadOnly, UploadOnly: service.UploadOnly,
Note: service.Note,
}) })
if err != nil { if err != nil {
return "", err return "", err

Loading…
Cancel
Save