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.",
"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</0> file (case-sensitive) in the directory will be automatically displayed for visitors.",
"viewSetting": "View setting",

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

@ -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 {

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

@ -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
)}
</AccordionDetails>
</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")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<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 }} />
)}
</Box>
{share?.note && (
<Tooltip title={share.note}>
<NoWrapTypography variant={"body2"} color={"text.secondary"}>
{share.note}
</NoWrapTypography>
</Tooltip>
)}
<Box>
<Tooltip title={share?.name ?? ""}>
<NoWrapTypography variant={"body2"} color={"text.secondary"}>

@ -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,
};

@ -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 {

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

@ -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{

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

@ -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"`
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

Loading…
Cancel
Save