From 5af5a190cba9db91b2047cd027769b5040380b84 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 23:47:43 +0200 Subject: [PATCH] feat(share): site-wide share defaults with user overrides (#3390) Admin-configurable defaults: profile share visibility (public-only / all / hidden) and private-by-default for new shares. User settings take precedence when set; empty values inherit the site default. - setting provider: ShareDefaults getter over default_share_links_in_profile and share_default_private keys - user settings: tri-state share_default_private (nil = inherit) and explicit public_share level for profile visibility - ListInUserProfile and BuildUserRedacted resolve effective visibility - share dialog initializes is_private from user override ?? site default - admin UI controls under User & Session settings - tests: visibility resolution matrix + patch validation 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 | 8 +- frontend/public/locales/en-US/dashboard.json | 9 +- .../public/locales/zh-CN/application.json | 8 +- frontend/public/locales/zh-CN/dashboard.json | 9 +- frontend/src/api/site.ts | 2 + frontend/src/api/user.ts | 7 +- .../Settings/UserSession/UserSession.tsx | 43 ++++++ .../FileManager/Dialogs/Share/ShareDialog.tsx | 14 +- .../Pages/Setting/ProfileSetting.tsx | 38 +++++- .../Pages/Setting/ProfileSettingPopover.tsx | 8 +- inventory/types/types.go | 8 ++ pkg/setting/provider.go | 25 ++++ service/basic/site.go | 39 +++--- service/explorer/response_share_test.go | 6 +- service/share/visit.go | 13 +- service/share/visit_test.go | 124 ++++++++++++++++++ service/user/response.go | 20 ++- service/user/setting.go | 23 +++- service/user/setting_test.go | 83 ++++++++++++ 19 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 service/share/visit_test.go create mode 100644 service/user/setting_test.go diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index d46d432a..150fa3ae 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -1012,7 +1012,13 @@ "relocateFileTo": "Relocate storage policy to {{policy}} for <0>{{more}}", "extractFileTo": "Extract <0>{{more}} to <1>", "createArchiveTo": "Create archive file to <1> for <0>{{more}}", - "importFileTo": "Import files from {{policy}} to <0>" + "importFileTo": "Import files from {{policy}} to <0>", + "shareSiteDefault": "Site default", + "shareSiteDefaultDes": "Follow the site-wide default configured by the administrator.", + "shareDefaultPrivate": "Default share privacy", + "shareDefaultPrivateDes": "New shares default to private (password protected).", + "sharePrivateOn": "Private", + "sharePrivateOff": "Public" }, "vas": { "points": "Points", diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 2b1c8340..58565e56 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -842,7 +842,14 @@ "ssoRegisterEnabledDes": "Automatically create a local account when a user signs in via SSO for the first time. The sign-up email filter below also applies.", "ssoCallbackUrl": "Callback URL", "ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}", - "themeVisible": "Visible" + "themeVisible": "Visible", + "shareDefaultPrivate": "Private share by default", + "shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.", + "defaultShareLinksInProfile": "Default profile share visibility", + "defaultShareLinksInProfileDes": "Controls which shares appear on a user's public profile page when the user has not chosen one themselves.", + "profileSharePublicOnly": "Shares without password only", + "profileShareAll": "All shares", + "profileShareHide": "Hide all shares" }, "giftCodes": { "giftCodesSettings": "Gift Codes", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 3b2db71f..aa23bb53 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -1012,7 +1012,13 @@ "relocateFileTo": "将 <0>{{more}} 的存储策略转移至 {{policy}}", "extractFileTo": "将 <0>{{more}} 解压缩至 <1>", "createArchiveTo": "将 <0>{{more}} 打包至 <1>", - "importFileTo": "将 {{policy}} 中的文件导入至 <0>" + "importFileTo": "将 {{policy}} 中的文件导入至 <0>", + "shareSiteDefault": "跟随站点默认", + "shareSiteDefaultDes": "使用管理员配置的站点级默认值。", + "shareDefaultPrivate": "默认分享隐私", + "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。", + "sharePrivateOn": "私密", + "sharePrivateOff": "公开" }, "vas": { "points": "积分", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 6830b649..2deb3d25 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -842,7 +842,14 @@ "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", "ssoCallbackUrl": "回调地址", "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}", - "themeVisible": "可见" + "themeVisible": "可见", + "shareDefaultPrivate": "默认私密分享", + "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。", + "defaultShareLinksInProfile": "默认个人主页分享可见性", + "defaultShareLinksInProfileDes": "当用户未自行选择时,控制其公开个人主页上显示哪些分享。", + "profileSharePublicOnly": "仅无密码分享", + "profileShareAll": "所有分享", + "profileShareHide": "隐藏所有分享" }, "giftCodes": { "giftCodesSettings": "礼品码", diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index b185bc5e..2103a7cd 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -54,6 +54,8 @@ export interface SiteConfig { thumb_exts?: string[]; show_encryption_status?: boolean; full_text_search?: boolean; + share_default_private?: boolean; + default_share_links_in_profile?: string; } export interface CaptchaResponse { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 5237f364..021211eb 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -24,6 +24,7 @@ export interface User { language?: string; disable_view_sync?: boolean; share_links_in_profile?: ShareLinksInProfileLevel; + share_default_private?: boolean; } export interface Group { id: string; @@ -105,6 +106,7 @@ export interface UserSettings { passkeys?: Passkey[]; disable_view_sync: boolean; share_links_in_profile: ShareLinksInProfileLevel; + share_default_private?: boolean; oauth_grants?: OAuthGrant[]; } @@ -129,6 +131,8 @@ export interface PatchUserSetting { two_fa_code?: string; disable_view_sync?: boolean; share_links_in_profile?: ShareLinksInProfileLevel; + // Tri-state: "true" / "false" / "" (inherit the site default). + share_default_private?: string; } export interface PasskeyCredentialOption { @@ -206,7 +210,8 @@ export interface ResetPasswordService { } export enum ShareLinksInProfileLevel { - public_share_only = "", + site_default = "", + public_share_only = "public_share", all_share = "all_share", hide_share = "hide_share", } diff --git a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx index fb177d9a..3b656973 100644 --- a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx +++ b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx @@ -139,6 +139,49 @@ const UserSession = () => { + + + + setSettings({ + share_default_private: e.target.checked ? "1" : "0", + }) + } + /> + } + label={t("settings.shareDefaultPrivate")} + /> + {t("settings.shareDefaultPrivateDes")} + + + + + + setSettings({ + default_share_links_in_profile: e.target.value as string, + }) + } + > + {["profileSharePublicOnly", "profileShareAll", "profileShareHide"].map((v, i) => ( + + + {t(`settings.${v}`)} + + + ))} + + {t("settings.defaultShareLinksInProfileDes")} + + ({ + is_private: privateByDefault || undefined, expires_val: expireOptions[2], downloads_val: downloadOptions[0], -}; +}); interface ShareLinkPassword { shareLink: string; @@ -75,8 +77,12 @@ const ShareDialog = () => { const dispatch = useAppDispatch(); const theme = useTheme(); + const sitePrivateDefault = useAppSelector((state) => state.siteConfig.basic.config.share_default_private); + const userPrivateDefault = SessionManager.currentLoginOrNull()?.user.share_default_private; + const privateByDefault = userPrivateDefault ?? !!sitePrivateDefault; + const [loading, setLoading] = useState(false); - const [setting, setSetting] = useState(initialSetting); + const [setting, setSetting] = useState(() => initialSetting(privateByDefault)); const [shareLink, setShareLink] = useState(""); const [includePassword, setIncludePassword] = useState(true); const shareLinkPassword = useMemo(() => { @@ -98,7 +104,7 @@ const ShareDialog = () => { if (editTarget) { setSetting(shareToSetting(editTarget, t)); } else { - setSetting(initialSetting); + setSetting(initialSetting(privateByDefault)); } setShareLink(""); setIncludePassword(true); diff --git a/frontend/src/component/Pages/Setting/ProfileSetting.tsx b/frontend/src/component/Pages/Setting/ProfileSetting.tsx index 62d646f2..95873bea 100644 --- a/frontend/src/component/Pages/Setting/ProfileSetting.tsx +++ b/frontend/src/component/Pages/Setting/ProfileSetting.tsx @@ -1,5 +1,5 @@ import { LoadingButton } from "@mui/lab"; -import { Collapse, Grid2, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material"; +import { Collapse, FormHelperText, Grid2, ListItemText, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material"; import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,7 +7,8 @@ import { sendUpdateUserSetting } from "../../../api/api.ts"; import { UserSettings, ShareLinksInProfileLevel } from "../../../api/user.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import SessionManager from "../../../session"; -import { DefaultButton, DenseFilledTextField } from "../../Common/StyledComponents.tsx"; +import { DefaultButton, DenseFilledTextField, DenseSelect } from "../../Common/StyledComponents.tsx"; +import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; import TimeBadge from "../../Common/TimeBadge.tsx"; import CaretDown from "../../Icons/CaretDown.tsx"; import AvatarSetting from "./AvatarSetting.tsx"; @@ -154,6 +155,39 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => { {...bindPopover(profileSettingPopup)} /> + + + { + const v = e.target.value as string; + dispatch(sendUpdateUserSetting({ share_default_private: v })).then(() => { + setSetting({ + ...setting, + share_default_private: v === "" ? undefined : v === "true", + }); + }); + }} + > + + + + + + + + + + + {t("setting.shareDefaultPrivateDes")} + diff --git a/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx b/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx index 7f31542a..9f5a3e91 100644 --- a/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx +++ b/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx @@ -28,6 +28,12 @@ const profileSettingOptions: { description: string; icon?: ((props: SvgIconProps) => JSX.Element) | typeof Eye; }[] = [ + { + value: ShareLinksInProfileLevel.site_default, + label: "application:setting.shareSiteDefault", + description: "application:setting.shareSiteDefaultDes", + icon: Globe, + }, { value: ShareLinksInProfileLevel.public_share_only, label: "application:setting.publicShareOnly", @@ -53,7 +59,7 @@ export const useProfileSettingSummary = (value: ShareLinksInProfileLevel) => { const summary = useMemo(() => { const option = profileSettingOptions.find((opt) => opt.value === (value ?? "")); - return option ? t(option.label) : t("application:setting.publicShareOnly"); + return option ? t(option.label) : t("application:setting.shareSiteDefault"); }, [value, t]); return summary; diff --git a/inventory/types/types.go b/inventory/types/types.go index 43faad56..b71de0b4 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -17,6 +17,9 @@ type ( DisableViewSync bool `json:"disable_view_sync,omitempty"` FsViewMap map[string]ExplorerView `json:"fs_view_map,omitempty"` ShareLinksInProfile ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"` + // ShareDefaultPrivate overrides the site-wide private-share default + // for this user. nil means inherit the site default. + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` } ShareLinksInProfileLevel string @@ -409,6 +412,11 @@ const ( ProfilePublicShareOnly = ShareLinksInProfileLevel("") ProfileAllShare = ShareLinksInProfileLevel("all_share") ProfileHideShare = ShareLinksInProfileLevel("hide_share") + // ProfileSharePublic is the explicit "password-free shares only" choice. + // The empty value (ProfilePublicShareOnly) doubles as "inherit the + // site-wide default"; ProfileSharePublic lets a user force public-only + // visibility even when the site default differs (#3390). + ProfileSharePublic = ShareLinksInProfileLevel("public_share") ) const ( diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index d0878a94..06a8524e 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -246,6 +246,9 @@ type ( SSO(ctx context.Context) *SSO // EmailFilter returns the sign-up email restriction settings. EmailFilter(ctx context.Context) *EmailFilter + // ShareDefaults returns the site-wide share defaults applied when a + // user has not overridden them in their personal settings. + ShareDefaults(ctx context.Context) *ShareDefaults } UseFirstSiteUrlCtxKey = struct{} ) @@ -936,6 +939,28 @@ func (s *settingProvider) ExposeUserEmail(ctx context.Context) bool { return s.getBoolean(ctx, "expose_user_email", true) } +// ShareDefaults holds the site-wide share defaults (#3390). +type ShareDefaults struct { + // LinksInProfile is the default profile share visibility applied when a + // user's own share_links_in_profile is unset. + LinksInProfile types.ShareLinksInProfileLevel + // PrivateByDefault makes new shares default to private (random password). + PrivateByDefault bool +} + +func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults { + level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", "")) + switch level { + case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare: + default: + level = types.ProfilePublicShareOnly + } + return &ShareDefaults{ + LinksInProfile: level, + PrivateByDefault: s.getBoolean(ctx, "share_default_private", false), + } +} + func (s *settingProvider) SiteBasic(ctx context.Context) *SiteBasic { return &SiteBasic{ Name: s.getString(ctx, "siteName", ""), diff --git a/service/basic/site.go b/service/basic/site.go index 87e9cd5e..3e1f5cb8 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -27,6 +27,10 @@ type SiteConfig struct { CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"` CustomHTML *setting.CustomHTML `json:"custom_html,omitempty"` + // Share section + ShareDefaultPrivate bool `json:"share_default_private,omitempty"` + DefaultShareLinksInProfile string `json:"default_share_links_in_profile,omitempty"` + // Login Section LoginCaptcha bool `json:"login_captcha,omitempty"` RegCaptcha bool `json:"reg_captcha,omitempty"` @@ -196,23 +200,26 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { appSetting := settings.AppSetting(c) customNavItems := settings.CustomNavItems(c) customHTML := settings.CustomHTML(c) + shareDefaults := settings.ShareDefaults(c) return &SiteConfig{ - InstanceID: siteBasic.ID, - SiteName: siteBasic.Name, - Themes: themes.Themes, - DefaultTheme: themes.DefaultTheme, - User: &userRes, - Logo: logo.Normal, - LogoLight: logo.Light, - CaptchaType: settings.CaptchaType(c), - TurnstileSiteID: settings.TurnstileCaptcha(c).Key, - ReCaptchaKey: reCaptcha.Key, - CapInstanceURL: capCaptcha.InstanceURL, - CapSiteKey: capCaptcha.SiteKey, - CapAssetServer: capCaptcha.AssetServer, - AppPromotion: appSetting.Promotion, - CustomNavItems: customNavItems, - CustomHTML: customHTML, + InstanceID: siteBasic.ID, + SiteName: siteBasic.Name, + Themes: themes.Themes, + DefaultTheme: themes.DefaultTheme, + User: &userRes, + Logo: logo.Normal, + LogoLight: logo.Light, + CaptchaType: settings.CaptchaType(c), + TurnstileSiteID: settings.TurnstileCaptcha(c).Key, + ReCaptchaKey: reCaptcha.Key, + CapInstanceURL: capCaptcha.InstanceURL, + CapSiteKey: capCaptcha.SiteKey, + CapAssetServer: capCaptcha.AssetServer, + AppPromotion: appSetting.Promotion, + CustomNavItems: customNavItems, + CustomHTML: customHTML, + ShareDefaultPrivate: shareDefaults.PrivateByDefault, + DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile), }, nil } diff --git a/service/explorer/response_share_test.go b/service/explorer/response_share_test.go index d0cb1e94..67fadda4 100644 --- a/service/explorer/response_share_test.go +++ b/service/explorer/response_share_test.go @@ -14,13 +14,17 @@ import ( ) // stubSettingProvider satisfies the SettingProvider dependency; only -// ExposeUserEmail is exercised by BuildUserRedacted. +// ExposeUserEmail and ShareDefaults are exercised by BuildUserRedacted. type stubSettingProvider struct { setting.Provider } func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false } +func (stubSettingProvider) ShareDefaults(context.Context) *setting.ShareDefaults { + return &setting.ShareDefaults{} +} + // TestBuildShareNoteVisibility ensures the owner-defined share note is only // exposed to the share owner, never to visitors (upstream #3570). func TestBuildShareNoteVisibility(t *testing.T) { diff --git a/service/share/visit.go b/service/share/visit.go index 51168a93..22f96fc8 100644 --- a/service/share/visit.go +++ b/service/share/visit.go @@ -162,11 +162,20 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err) } - if targetUser.Settings != nil && targetUser.Settings.ShareLinksInProfile == types.ProfileHideShare { + // An unset user preference ("") inherits the site-wide default; explicit + // values (public_share/all_share/hide_share) always win. + level := types.ProfilePublicShareOnly + if targetUser.Settings != nil { + level = targetUser.Settings.ShareLinksInProfile + } + if level == types.ProfilePublicShareOnly { + level = dep.SettingProvider().ShareDefaults(c).LinksInProfile + } + if level == types.ProfileHideShare { return nil, serializer.NewError(serializer.CodeParamErr, "User has disabled share links in profile", nil) } - publicOnly := targetUser.Settings == nil || targetUser.Settings.ShareLinksInProfile == types.ProfilePublicShareOnly + publicOnly := level != types.ProfileAllShare args := &inventory.ListShareArgs{ PaginationArgs: &inventory.PaginationArgs{ UseCursorPagination: true, diff --git a/service/share/visit_test.go b/service/share/visit_test.go new file mode 100644 index 00000000..edd25920 --- /dev/null +++ b/service/share/visit_test.go @@ -0,0 +1,124 @@ +package share + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "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/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// stubSettingProvider satisfies the SettingProvider dependency; only +// ShareDefaults, SiteURL and ExposeUserEmail are exercised by +// ListInUserProfile and the share response builder. +type stubSettingProvider struct { + setting.Provider + defaults *setting.ShareDefaults +} + +func (s stubSettingProvider) ShareDefaults(context.Context) *setting.ShareDefaults { + return s.defaults +} + +func (stubSettingProvider) SiteURL(context.Context) *url.URL { + return &url.URL{Scheme: "https", Host: "example.com"} +} + +func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false } + +// TestListInUserProfileVisibilityResolution ensures a user's empty +// share_links_in_profile inherits the site-wide default while explicit +// values always win (upstream #3390). +func TestListInUserProfileVisibilityResolution(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + hasher, err := hashid.New("test-salt") + require.NoError(t, err) + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + + newOwner := func(email string, level types.ShareLinksInProfileLevel) int { + owner := client.User.Create(). + SetEmail(email). + SetNick("owner"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{ShareLinksInProfile: level}). + SaveX(ctx) + file := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SaveX(ctx) + client.Share.Create().SetUser(owner).SetFile(file).SaveX(ctx) // public share + client.Share.Create().SetUser(owner).SetFile(file).SetPassword("pw").SaveX(ctx) // private share + return owner.ID + } + + newCtx := func(dep dependency.Dep, requesterID int) *gin.Context { + w := httptest.NewRecorder() + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(w, engine) + c.Request = httptest.NewRequest("GET", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dep) + util.WithValue(c, inventory.UserCtx{}, client.User.GetX(context.Background(), requesterID)) + return c + } + + newDep := func(defaults *setting.ShareDefaults) dependency.Dep { + return dependency.NewDependency( + dependency.WithKV(cache.NewMemoStore("", nil)), + dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)), + dependency.WithUserClient(inventory.NewUserClient(client)), + dependency.WithShareClient(inventory.NewShareClient(client, conf.SQLiteDB, hasher)), + dependency.WithHashIDEncoder(hasher), + dependency.WithSettingProvider(stubSettingProvider{defaults: defaults}), + ) + } + + svc := &ListShareService{PageSize: 20} + + tests := []struct { + name string + userLevel types.ShareLinksInProfileLevel + siteDefault types.ShareLinksInProfileLevel + wantCount int + wantErr bool + }{ + {name: "unset inherits site default all_share", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfileAllShare, wantCount: 2}, + {name: "unset inherits site default public only", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfilePublicShareOnly, wantCount: 1}, + {name: "unset inherits site default hide", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfileHideShare, wantErr: true}, + {name: "explicit all_share overrides site public", userLevel: types.ProfileAllShare, siteDefault: types.ProfilePublicShareOnly, wantCount: 2}, + {name: "explicit public_share overrides site all", userLevel: types.ProfileSharePublic, siteDefault: types.ProfileAllShare, wantCount: 1}, + {name: "explicit hide overrides site all", userLevel: types.ProfileHideShare, siteDefault: types.ProfileAllShare, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + uid := newOwner(tt.name+"@example.com", tt.userLevel) + dep := newDep(&setting.ShareDefaults{LinksInProfile: tt.siteDefault}) + c := newCtx(dep, uid) + + res, err := svc.ListInUserProfile(c, uid) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, res.Shares, tt.wantCount) + }) + } +} diff --git a/service/user/response.go b/service/user/response.go index ac063a0d..5f215215 100644 --- a/service/user/response.go +++ b/service/user/response.go @@ -32,6 +32,7 @@ type UserSettings struct { Passkeys []Passkey `json:"passkeys,omitempty"` DisableViewSync bool `json:"disable_view_sync"` ShareLinksInProfile string `json:"share_links_in_profile"` + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` } @@ -47,6 +48,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa }), DisableViewSync: u.Settings.DisableViewSync, ShareLinksInProfile: string(u.Settings.ShareLinksInProfile), + ShareDefaultPrivate: u.Settings.ShareDefaultPrivate, OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant { return BuildOauthGrant(item) }), @@ -118,6 +120,10 @@ type User struct { Language string `json:"language,omitempty"` DisableViewSync bool `json:"disable_view_sync,omitempty"` ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"` + // ShareDefaultPrivate is the user's own private-share override; nil means + // the site default applies. Never resolved here — the dialog computes the + // effective default from user ?? site. + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` } type Group struct { @@ -175,6 +181,7 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User { Language: user.Settings.Language, DisableViewSync: user.Settings.DisableViewSync, ShareLinksInProfile: user.Settings.ShareLinksInProfile, + ShareDefaultPrivate: user.Settings.ShareDefaultPrivate, } } @@ -230,12 +237,17 @@ func BuildUserRedacted(ctx context.Context, u *ent.User, level int, idEncoder ha userRaw := BuildUser(u, idEncoder) user := User{ - ID: userRaw.ID, - Nickname: userRaw.Nickname, - Avatar: userRaw.Avatar, - CreatedAt: userRaw.CreatedAt, + ID: userRaw.ID, + Nickname: userRaw.Nickname, + Avatar: userRaw.Avatar, + CreatedAt: userRaw.CreatedAt, + // Profile visitors see the effective visibility: an unset user + // preference resolves to the site-wide default (#3390). ShareLinksInProfile: userRaw.ShareLinksInProfile, } + if user.ShareLinksInProfile == types.ProfilePublicShareOnly { + user.ShareLinksInProfile = dependency.FromContext(ctx).SettingProvider().ShareDefaults(ctx).LinksInProfile + } if userRaw.Group != nil { user.Group = RedactedGroup(userRaw.Group) diff --git a/service/user/setting.go b/service/user/setting.go index 58d821c9..b2bbf7ac 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -230,6 +230,9 @@ type ( TwoFACode *string `json:"two_fa_code" binding:"omitempty"` DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"` ShareLinksInProfile *string `json:"share_links_in_profile" binding:"omitempty"` + // ShareDefaultPrivate accepts "true", "false" or "" (clear the + // override and inherit the site default). + ShareDefaultPrivate *string `json:"share_default_private" binding:"omitempty"` } PatchUserSettingParamsCtx struct{} ) @@ -277,7 +280,25 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error { } if s.ShareLinksInProfile != nil { - u.Settings.ShareLinksInProfile = types.ShareLinksInProfileLevel(*s.ShareLinksInProfile) + level := types.ShareLinksInProfileLevel(*s.ShareLinksInProfile) + switch level { + case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare, types.ProfileSharePublic: + default: + return serializer.NewError(serializer.CodeParamErr, "Invalid share links visibility", nil) + } + u.Settings.ShareLinksInProfile = level + saveSetting = true + } + + if s.ShareDefaultPrivate != nil { + if *s.ShareDefaultPrivate != "" && *s.ShareDefaultPrivate != "true" && *s.ShareDefaultPrivate != "false" { + return serializer.NewError(serializer.CodeParamErr, "Invalid share privacy default", nil) + } + u.Settings.ShareDefaultPrivate = nil + if *s.ShareDefaultPrivate != "" { + v := *s.ShareDefaultPrivate == "true" + u.Settings.ShareDefaultPrivate = &v + } saveSetting = true } diff --git a/service/user/setting_test.go b/service/user/setting_test.go new file mode 100644 index 00000000..dd422af5 --- /dev/null +++ b/service/user/setting_test.go @@ -0,0 +1,83 @@ +package user + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "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/util" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestPatchUserSettingShareDefaults verifies the tri-state private-share +// override and profile-visibility validation (upstream #3390). +func TestPatchUserSettingShareDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + u := client.User.Create(). + SetEmail("patch@example.com"). + SetNick("patch"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{}). + SaveX(ctx) + + newCtx := func() *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("PATCH", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dependency.NewDependency( + dependency.WithUserClient(inventory.NewUserClient(client)), + )) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + strPtr := func(s string) *string { return &s } + + patch := func(s *PatchUserSetting) error { + return s.Patch(newCtx()) + } + + // Set override on. + require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("true")})) + require.NotNil(t, u.Settings.ShareDefaultPrivate) + require.True(t, *u.Settings.ShareDefaultPrivate) + + // Set override off. + require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("false")})) + require.NotNil(t, u.Settings.ShareDefaultPrivate) + require.False(t, *u.Settings.ShareDefaultPrivate) + + // Persisted to DB. + persisted := client.User.GetX(ctx, u.ID) + require.NotNil(t, persisted.Settings.ShareDefaultPrivate) + require.False(t, *persisted.Settings.ShareDefaultPrivate) + + // Empty clears the override back to inheritance. + require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("")})) + require.Nil(t, u.Settings.ShareDefaultPrivate) + + // Malformed value rejected. + require.Error(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("yes")})) + + // Valid profile visibility values accepted. + for _, v := range []string{"", "public_share", "all_share", "hide_share"} { + require.NoError(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr(v)})) + require.Equal(t, types.ShareLinksInProfileLevel(v), u.Settings.ShareLinksInProfile) + } + + // Invalid profile visibility rejected. + require.Error(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr("friends_only")})) +}