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>0>{{more}}",
"extractFileTo": "Extract <0>0>{{more}} to <1>1>",
"createArchiveTo": "Create archive file to <1>1> for <0>0>{{more}}",
- "importFileTo": "Import files from {{policy}} to <0>0>"
+ "importFileTo": "Import files from {{policy}} to <0>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}}0>",
- "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>0>{{more}} 的存储策略转移至 {{policy}}",
"extractFileTo": "将 <0>0>{{more}} 解压缩至 <1>1>",
"createArchiveTo": "将 <0>0>{{more}} 打包至 <1>1>",
- "importFileTo": "将 {{policy}} 中的文件导入至 <0>0>"
+ "importFileTo": "将 {{policy}} 中的文件导入至 <0>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}}0>",
- "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")}))
+}