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 <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 8feb55fb82
commit 5af5a190cb

@ -1012,7 +1012,13 @@
"relocateFileTo": "Relocate storage policy to {{policy}} for <0></0>{{more}}", "relocateFileTo": "Relocate storage policy to {{policy}} for <0></0>{{more}}",
"extractFileTo": "Extract <0></0>{{more}} to <1></1>", "extractFileTo": "Extract <0></0>{{more}} to <1></1>",
"createArchiveTo": "Create archive file to <1></1> for <0></0>{{more}}", "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": { "vas": {
"points": "Points", "points": "Points",

@ -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.", "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", "ssoCallbackUrl": "Callback URL",
"ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}</0>", "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": { "giftCodes": {
"giftCodesSettings": "Gift Codes", "giftCodesSettings": "Gift Codes",

@ -1012,7 +1012,13 @@
"relocateFileTo": "将 <0></0>{{more}} 的存储策略转移至 {{policy}}", "relocateFileTo": "将 <0></0>{{more}} 的存储策略转移至 {{policy}}",
"extractFileTo": "将 <0></0>{{more}} 解压缩至 <1></1>", "extractFileTo": "将 <0></0>{{more}} 解压缩至 <1></1>",
"createArchiveTo": "将 <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": { "vas": {
"points": "积分", "points": "积分",

@ -842,7 +842,14 @@
"ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。",
"ssoCallbackUrl": "回调地址", "ssoCallbackUrl": "回调地址",
"ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}</0>", "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}</0>",
"themeVisible": "可见" "themeVisible": "可见",
"shareDefaultPrivate": "默认私密分享",
"shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。",
"defaultShareLinksInProfile": "默认个人主页分享可见性",
"defaultShareLinksInProfileDes": "当用户未自行选择时,控制其公开个人主页上显示哪些分享。",
"profileSharePublicOnly": "仅无密码分享",
"profileShareAll": "所有分享",
"profileShareHide": "隐藏所有分享"
}, },
"giftCodes": { "giftCodes": {
"giftCodesSettings": "礼品码", "giftCodesSettings": "礼品码",

@ -54,6 +54,8 @@ export interface SiteConfig {
thumb_exts?: string[]; thumb_exts?: string[];
show_encryption_status?: boolean; show_encryption_status?: boolean;
full_text_search?: boolean; full_text_search?: boolean;
share_default_private?: boolean;
default_share_links_in_profile?: string;
} }
export interface CaptchaResponse { export interface CaptchaResponse {

@ -24,6 +24,7 @@ export interface User {
language?: string; language?: string;
disable_view_sync?: boolean; disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel; share_links_in_profile?: ShareLinksInProfileLevel;
share_default_private?: boolean;
} }
export interface Group { export interface Group {
id: string; id: string;
@ -105,6 +106,7 @@ export interface UserSettings {
passkeys?: Passkey[]; passkeys?: Passkey[];
disable_view_sync: boolean; disable_view_sync: boolean;
share_links_in_profile: ShareLinksInProfileLevel; share_links_in_profile: ShareLinksInProfileLevel;
share_default_private?: boolean;
oauth_grants?: OAuthGrant[]; oauth_grants?: OAuthGrant[];
} }
@ -129,6 +131,8 @@ export interface PatchUserSetting {
two_fa_code?: string; two_fa_code?: string;
disable_view_sync?: boolean; disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel; share_links_in_profile?: ShareLinksInProfileLevel;
// Tri-state: "true" / "false" / "" (inherit the site default).
share_default_private?: string;
} }
export interface PasskeyCredentialOption { export interface PasskeyCredentialOption {
@ -206,7 +210,8 @@ export interface ResetPasswordService {
} }
export enum ShareLinksInProfileLevel { export enum ShareLinksInProfileLevel {
public_share_only = "", site_default = "",
public_share_only = "public_share",
all_share = "all_share", all_share = "all_share",
hide_share = "hide_share", hide_share = "hide_share",
} }

@ -139,6 +139,49 @@ const UserSession = () => {
</NoMarginHelperText> </NoMarginHelperText>
</FormControl> </FormControl>
</SettingForm> </SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.share_default_private)}
onChange={(e) =>
setSettings({
share_default_private: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.shareDefaultPrivate")}
/>
<NoMarginHelperText>{t("settings.shareDefaultPrivateDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.defaultShareLinksInProfile")} lgWidth={5}>
<FormControl>
<DenseSelect
value={values.default_share_links_in_profile ?? ""}
onChange={(e) =>
setSettings({
default_share_links_in_profile: e.target.value as string,
})
}
>
{["profileSharePublicOnly", "profileShareAll", "profileShareHide"].map((v, i) => (
<SquareMenuItem key={v} value={["", "all_share", "hide_share"][i]}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`settings.${v}`)}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("settings.defaultShareLinksInProfileDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("vas.filterEmailProvider")} lgWidth={5}> <SettingForm title={t("vas.filterEmailProvider")} lgWidth={5}>
<FormControl> <FormControl>
<DenseSelect <DenseSelect

@ -8,6 +8,7 @@ import { Share as ShareModel } from "../../../../api/explorer.ts";
import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts"; import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { createOrUpdateShareLink } from "../../../../redux/thunks/share.ts"; import { createOrUpdateShareLink } from "../../../../redux/thunks/share.ts";
import SessionManager from "../../../../session";
import { copyToClipboard, sendLink } from "../../../../util"; import { copyToClipboard, sendLink } from "../../../../util";
import AutoHeight from "../../../Common/AutoHeight.tsx"; import AutoHeight from "../../../Common/AutoHeight.tsx";
import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx"; import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx";
@ -17,10 +18,11 @@ import Share from "../../../Icons/Share.tsx";
import { FileManagerIndex } from "../../FileManager.tsx"; import { FileManagerIndex } from "../../FileManager.tsx";
import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx"; import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx";
const initialSetting: ShareSetting = { const initialSetting = (privateByDefault: boolean): ShareSetting => ({
is_private: privateByDefault || undefined,
expires_val: expireOptions[2], expires_val: expireOptions[2],
downloads_val: downloadOptions[0], downloads_val: downloadOptions[0],
}; });
interface ShareLinkPassword { interface ShareLinkPassword {
shareLink: string; shareLink: string;
@ -75,8 +77,12 @@ const ShareDialog = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const theme = useTheme(); 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 [loading, setLoading] = useState(false);
const [setting, setSetting] = useState<ShareSetting>(initialSetting); const [setting, setSetting] = useState<ShareSetting>(() => initialSetting(privateByDefault));
const [shareLink, setShareLink] = useState<string>(""); const [shareLink, setShareLink] = useState<string>("");
const [includePassword, setIncludePassword] = useState(true); const [includePassword, setIncludePassword] = useState(true);
const shareLinkPassword = useMemo(() => { const shareLinkPassword = useMemo(() => {
@ -98,7 +104,7 @@ const ShareDialog = () => {
if (editTarget) { if (editTarget) {
setSetting(shareToSetting(editTarget, t)); setSetting(shareToSetting(editTarget, t));
} else { } else {
setSetting(initialSetting); setSetting(initialSetting(privateByDefault));
} }
setShareLink(""); setShareLink("");
setIncludePassword(true); setIncludePassword(true);

@ -1,5 +1,5 @@
import { LoadingButton } from "@mui/lab"; 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 { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -7,7 +7,8 @@ import { sendUpdateUserSetting } from "../../../api/api.ts";
import { UserSettings, ShareLinksInProfileLevel } from "../../../api/user.ts"; import { UserSettings, ShareLinksInProfileLevel } from "../../../api/user.ts";
import { useAppDispatch } from "../../../redux/hooks.ts"; import { useAppDispatch } from "../../../redux/hooks.ts";
import SessionManager from "../../../session"; 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 TimeBadge from "../../Common/TimeBadge.tsx";
import CaretDown from "../../Icons/CaretDown.tsx"; import CaretDown from "../../Icons/CaretDown.tsx";
import AvatarSetting from "./AvatarSetting.tsx"; import AvatarSetting from "./AvatarSetting.tsx";
@ -154,6 +155,39 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
{...bindPopover(profileSettingPopup)} {...bindPopover(profileSettingPopup)}
/> />
</SettingForm> </SettingForm>
<SettingForm title={t("setting.shareDefaultPrivate")} noContainer lgWidth={6}>
<DenseSelect
fullWidth
value={
setting.share_default_private === undefined || setting.share_default_private === null
? ""
: setting.share_default_private
? "true"
: "false"
}
onChange={(e) => {
const v = e.target.value as string;
dispatch(sendUpdateUserSetting({ share_default_private: v })).then(() => {
setSetting({
...setting,
share_default_private: v === "" ? undefined : v === "true",
});
});
}}
>
<SquareMenuItem value="">
<ListItemText primary={t("setting.shareSiteDefault")} />
</SquareMenuItem>
<SquareMenuItem value="true">
<ListItemText primary={t("setting.sharePrivateOn")} />
</SquareMenuItem>
<SquareMenuItem value="false">
<ListItemText primary={t("setting.sharePrivateOff")} />
</SquareMenuItem>
</DenseSelect>
<FormHelperText>{t("setting.shareDefaultPrivateDes")}</FormHelperText>
</SettingForm>
</Grid2> </Grid2>
</Stack> </Stack>
</Grid2> </Grid2>

@ -28,6 +28,12 @@ const profileSettingOptions: {
description: string; description: string;
icon?: ((props: SvgIconProps) => JSX.Element) | typeof Eye; 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, value: ShareLinksInProfileLevel.public_share_only,
label: "application:setting.publicShareOnly", label: "application:setting.publicShareOnly",
@ -53,7 +59,7 @@ export const useProfileSettingSummary = (value: ShareLinksInProfileLevel) => {
const summary = useMemo(() => { const summary = useMemo(() => {
const option = profileSettingOptions.find((opt) => opt.value === (value ?? "")); 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]); }, [value, t]);
return summary; return summary;

@ -17,6 +17,9 @@ type (
DisableViewSync bool `json:"disable_view_sync,omitempty"` DisableViewSync bool `json:"disable_view_sync,omitempty"`
FsViewMap map[string]ExplorerView `json:"fs_view_map,omitempty"` FsViewMap map[string]ExplorerView `json:"fs_view_map,omitempty"`
ShareLinksInProfile ShareLinksInProfileLevel `json:"share_links_in_profile,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 ShareLinksInProfileLevel string
@ -409,6 +412,11 @@ const (
ProfilePublicShareOnly = ShareLinksInProfileLevel("") ProfilePublicShareOnly = ShareLinksInProfileLevel("")
ProfileAllShare = ShareLinksInProfileLevel("all_share") ProfileAllShare = ShareLinksInProfileLevel("all_share")
ProfileHideShare = ShareLinksInProfileLevel("hide_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 ( const (

@ -246,6 +246,9 @@ type (
SSO(ctx context.Context) *SSO SSO(ctx context.Context) *SSO
// EmailFilter returns the sign-up email restriction settings. // EmailFilter returns the sign-up email restriction settings.
EmailFilter(ctx context.Context) *EmailFilter 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{} UseFirstSiteUrlCtxKey = struct{}
) )
@ -936,6 +939,28 @@ func (s *settingProvider) ExposeUserEmail(ctx context.Context) bool {
return s.getBoolean(ctx, "expose_user_email", true) 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 { func (s *settingProvider) SiteBasic(ctx context.Context) *SiteBasic {
return &SiteBasic{ return &SiteBasic{
Name: s.getString(ctx, "siteName", ""), Name: s.getString(ctx, "siteName", ""),

@ -27,6 +27,10 @@ type SiteConfig struct {
CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"` CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"`
CustomHTML *setting.CustomHTML `json:"custom_html,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 // Login Section
LoginCaptcha bool `json:"login_captcha,omitempty"` LoginCaptcha bool `json:"login_captcha,omitempty"`
RegCaptcha bool `json:"reg_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) appSetting := settings.AppSetting(c)
customNavItems := settings.CustomNavItems(c) customNavItems := settings.CustomNavItems(c)
customHTML := settings.CustomHTML(c) customHTML := settings.CustomHTML(c)
shareDefaults := settings.ShareDefaults(c)
return &SiteConfig{ return &SiteConfig{
InstanceID: siteBasic.ID, InstanceID: siteBasic.ID,
SiteName: siteBasic.Name, SiteName: siteBasic.Name,
Themes: themes.Themes, Themes: themes.Themes,
DefaultTheme: themes.DefaultTheme, DefaultTheme: themes.DefaultTheme,
User: &userRes, User: &userRes,
Logo: logo.Normal, Logo: logo.Normal,
LogoLight: logo.Light, LogoLight: logo.Light,
CaptchaType: settings.CaptchaType(c), CaptchaType: settings.CaptchaType(c),
TurnstileSiteID: settings.TurnstileCaptcha(c).Key, TurnstileSiteID: settings.TurnstileCaptcha(c).Key,
ReCaptchaKey: reCaptcha.Key, ReCaptchaKey: reCaptcha.Key,
CapInstanceURL: capCaptcha.InstanceURL, CapInstanceURL: capCaptcha.InstanceURL,
CapSiteKey: capCaptcha.SiteKey, CapSiteKey: capCaptcha.SiteKey,
CapAssetServer: capCaptcha.AssetServer, CapAssetServer: capCaptcha.AssetServer,
AppPromotion: appSetting.Promotion, AppPromotion: appSetting.Promotion,
CustomNavItems: customNavItems, CustomNavItems: customNavItems,
CustomHTML: customHTML, CustomHTML: customHTML,
ShareDefaultPrivate: shareDefaults.PrivateByDefault,
DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile),
}, nil }, nil
} }

@ -14,13 +14,17 @@ import (
) )
// stubSettingProvider satisfies the SettingProvider dependency; only // stubSettingProvider satisfies the SettingProvider dependency; only
// ExposeUserEmail is exercised by BuildUserRedacted. // ExposeUserEmail and ShareDefaults are exercised by BuildUserRedacted.
type stubSettingProvider struct { type stubSettingProvider struct {
setting.Provider setting.Provider
} }
func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false } 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 // TestBuildShareNoteVisibility ensures the owner-defined share note is only
// exposed to the share owner, never to visitors (upstream #3570). // exposed to the share owner, never to visitors (upstream #3570).
func TestBuildShareNoteVisibility(t *testing.T) { func TestBuildShareNoteVisibility(t *testing.T) {

@ -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) 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) 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{ args := &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{ PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true, UseCursorPagination: true,

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

@ -32,6 +32,7 @@ type UserSettings struct {
Passkeys []Passkey `json:"passkeys,omitempty"` Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"` DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"` ShareLinksInProfile string `json:"share_links_in_profile"`
ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
OAuthGrants []OauthGrant `json:"oauth_grants,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, DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile), ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
ShareDefaultPrivate: u.Settings.ShareDefaultPrivate,
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant { OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item) return BuildOauthGrant(item)
}), }),
@ -118,6 +120,10 @@ type User struct {
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
DisableViewSync bool `json:"disable_view_sync,omitempty"` DisableViewSync bool `json:"disable_view_sync,omitempty"`
ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,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 { type Group struct {
@ -175,6 +181,7 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
Language: user.Settings.Language, Language: user.Settings.Language,
DisableViewSync: user.Settings.DisableViewSync, DisableViewSync: user.Settings.DisableViewSync,
ShareLinksInProfile: user.Settings.ShareLinksInProfile, 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) userRaw := BuildUser(u, idEncoder)
user := User{ user := User{
ID: userRaw.ID, ID: userRaw.ID,
Nickname: userRaw.Nickname, Nickname: userRaw.Nickname,
Avatar: userRaw.Avatar, Avatar: userRaw.Avatar,
CreatedAt: userRaw.CreatedAt, CreatedAt: userRaw.CreatedAt,
// Profile visitors see the effective visibility: an unset user
// preference resolves to the site-wide default (#3390).
ShareLinksInProfile: userRaw.ShareLinksInProfile, ShareLinksInProfile: userRaw.ShareLinksInProfile,
} }
if user.ShareLinksInProfile == types.ProfilePublicShareOnly {
user.ShareLinksInProfile = dependency.FromContext(ctx).SettingProvider().ShareDefaults(ctx).LinksInProfile
}
if userRaw.Group != nil { if userRaw.Group != nil {
user.Group = RedactedGroup(userRaw.Group) user.Group = RedactedGroup(userRaw.Group)

@ -230,6 +230,9 @@ type (
TwoFACode *string `json:"two_fa_code" binding:"omitempty"` TwoFACode *string `json:"two_fa_code" binding:"omitempty"`
DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"` DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"`
ShareLinksInProfile *string `json:"share_links_in_profile" 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{} PatchUserSettingParamsCtx struct{}
) )
@ -277,7 +280,25 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
} }
if s.ShareLinksInProfile != nil { 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 saveSetting = true
} }

@ -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")}))
}
Loading…
Cancel
Save