feat: shuffle download URLs across site URL and CDN routes

Fork #173: admins running multiple download endpoints want generated
download links spread across them so external download managers hit
different hosts per link, instead of every URL pointing at the site URL.

- setting.Provider.DownloadURLBase: uniform random pick from the site
  URL plus all valid download_cdn_routes when download_cdn_shuffle is
  on; falls back to SiteURL when off/unconfigured and honors
  UseFirstSiteUrl pins.
- entitysource: internal-proxy entity URLs use the shuffled base only
  for explicit downloads — preview/thumb URLs keep the resolved site
  URL to avoid cross-origin viewer breakage.
- Archive download sessions pick from the same pool; redirect-type
  direct links shuffle at resolve time via the entity URL path.
- Site config exposes download_cdn_shuffle; the web client skips the
  manual route picker when the server already distributes.
- Admin Site Information gets the toggle; en-US + zh-CN strings.

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/3587/head
Tomas Dvorak 2 weeks ago
parent 831f40a0dc
commit 0a768b0f92

@ -207,6 +207,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before. - #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before.
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety) - [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)
- [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics - [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics
- [x] Download URL shuffling (#173) — `download_cdn_shuffle` distributes generated download URLs randomly across the site URL + `download_cdn_routes` endpoints (`setting.DownloadURLBase`, honors `UseFirstSiteUrl`); covers entity downloads, archive sessions, and redirect-type direct links; manual route picker hidden client-side while active
## 6. Phase D — desktop, all platforms ## 6. Phase D — desktop, all platforms

@ -854,6 +854,8 @@
"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.",
"downloadCdnRoutes": "Download CDN routes", "downloadCdnRoutes": "Download CDN routes",
"downloadCdnRoutesDes": "Alternative download endpoints offered to users, one per line in name=url format (e.g. Line 1=https://cdn1.example.com). Routes must proxy the full request path and query back to this site so signed URLs stay valid; CORS headers are required for in-browser downloads.", "downloadCdnRoutesDes": "Alternative download endpoints offered to users, one per line in name=url format (e.g. Line 1=https://cdn1.example.com). Routes must proxy the full request path and query back to this site so signed URLs stay valid; CORS headers are required for in-browser downloads.",
"downloadCdnShuffle": "Shuffle download routes",
"downloadCdnShuffleDes": "Randomly distribute each generated download link across the site URL and all configured CDN routes, so batch exports to download managers hit different endpoints. The manual route picker is hidden while enabled.",
"ssoAutoRedirect": "Auto redirect to SSO", "ssoAutoRedirect": "Auto redirect to SSO",
"ssoAutoRedirectDes": "Skip the login form and send visitors straight to the identity provider. Append <0>?nosso=1</0> to the login URL to reach the password form (e.g. for admin recovery).", "ssoAutoRedirectDes": "Skip the login form and send visitors straight to the identity provider. Append <0>?nosso=1</0> to the login URL to reach the password form (e.g. for admin recovery).",
"ssoCallbackUrl": "Callback URL", "ssoCallbackUrl": "Callback URL",

@ -854,6 +854,8 @@
"ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。",
"downloadCdnRoutes": "下载 CDN 线路", "downloadCdnRoutes": "下载 CDN 线路",
"downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。", "downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。",
"downloadCdnShuffle": "随机分发下载线路",
"downloadCdnShuffleDes": "启用后,每条生成的下载链接将随机分发到本站地址或任一已配置的 CDN 线路,批量导出到下载器时不同链接会命中不同端点。启用期间将隐藏手动选择线路。",
"ssoAutoRedirect": "自动跳转至 SSO", "ssoAutoRedirect": "自动跳转至 SSO",
"ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1</0> 可进入密码登录表单(例如管理员账户恢复)。", "ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1</0> 可进入密码登录表单(例如管理员账户恢复)。",
"ssoCallbackUrl": "回调地址", "ssoCallbackUrl": "回调地址",

@ -34,6 +34,7 @@ export interface SiteConfig {
sso_auto_redirect?: boolean; sso_auto_redirect?: boolean;
qq_connect_enabled?: boolean; qq_connect_enabled?: boolean;
download_cdn_routes?: { name: string; url: string }[]; download_cdn_routes?: { name: string; url: string }[];
download_cdn_shuffle?: boolean;
abuse_captcha?: boolean; abuse_captcha?: boolean;
upload_dedup?: boolean; upload_dedup?: boolean;
allow_select_node?: boolean; allow_select_node?: boolean;

@ -165,6 +165,7 @@ const Settings = () => {
"siteDes", "siteDes",
"siteURL", "siteURL",
"download_cdn_routes", "download_cdn_routes",
"download_cdn_shuffle",
"siteScript", "siteScript",
"pwa_small_icon", "pwa_small_icon",
"pwa_medium_icon", "pwa_medium_icon",

@ -65,6 +65,24 @@ const SiteInformation = () => {
<NoMarginHelperText>{t("settings.downloadCdnRoutesDes")}</NoMarginHelperText> <NoMarginHelperText>{t("settings.downloadCdnRoutesDes")}</NoMarginHelperText>
</FormControl> </FormControl>
</SettingForm> </SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.download_cdn_shuffle)}
onChange={(e) =>
setSettings({
download_cdn_shuffle: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.downloadCdnShuffle")}
/>
<NoMarginHelperText>{t("settings.downloadCdnShuffleDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.customFooterHTML")} lgWidth={5}> <SettingForm title={t("settings.customFooterHTML")} lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<DenseFilledTextField <DenseFilledTextField

@ -137,7 +137,14 @@ export function backendBatchDownload(files: FileResponse[]): AppThunk {
// signature stays valid through the CDN. Cancel falls back to direct. // signature stays valid through the CDN. Cancel falls back to direct.
export function pickDownloadRoute(url: string): AppThunk<Promise<string>> { export function pickDownloadRoute(url: string): AppThunk<Promise<string>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const routes = getState().siteConfig.basic.config.download_cdn_routes; const config = getState().siteConfig.basic.config;
// With server-side shuffling the URL already points at a random
// endpoint; the manual picker would only confuse (#173).
if (config.download_cdn_shuffle) {
return url;
}
const routes = config.download_cdn_routes;
if (!routes || routes.length === 0) { if (!routes || routes.length === 0) {
return url; return url;
} }

@ -572,6 +572,7 @@ var DefaultSettings = map[string]string{
"qq_connect_register_enabled": "1", "qq_connect_register_enabled": "1",
"upload_dedup_scope": "owner", "upload_dedup_scope": "owner",
"download_cdn_routes": "", "download_cdn_routes": "",
"download_cdn_shuffle": "0",
"email_filter_mode": "0", "email_filter_mode": "0",
"email_filter_list": "", "email_filter_list": "",
"email_disable_subaddress": "0", "email_disable_subaddress": "0",

@ -612,7 +612,13 @@ func (f *entitySource) Url(ctx context.Context, opts ...EntitySourceOption) (*En
// 4. The entity is encrypted and internal proxy not disabled by option // 4. The entity is encrypted and internal proxy not disabled by option
handlerCapability := f.handler.Capabilities() handlerCapability := f.handler.Capabilities()
if f.ShouldInternalProxy() { if f.ShouldInternalProxy() {
// Download URLs may be distributed across the site URL and
// configured CDN routes (#173); preview/thumb URLs stay on the
// resolved site URL to avoid cross-origin viewer breakage.
siteUrl := f.settings.SiteURL(ctx) siteUrl := f.settings.SiteURL(ctx)
if f.o.IsDownload {
siteUrl = f.settings.DownloadURLBase(ctx)
}
base := routes.MasterFileContentUrl( base := routes.MasterFileContentUrl(
siteUrl, siteUrl,
hashid.EncodeEntityID(f.hasher, f.e.ID()), hashid.EncodeEntityID(f.hasher, f.e.ID()),

@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand/v2"
"net/url" "net/url"
"sort" "sort"
"strconv" "strconv"
@ -260,6 +261,15 @@ type (
// DownloadCDNRoutes returns the configured alternative download // DownloadCDNRoutes returns the configured alternative download
// endpoints users can pick from (e.g. CDN mirrors of the site). // endpoints users can pick from (e.g. CDN mirrors of the site).
DownloadCDNRoutes(ctx context.Context) []CDNRoute DownloadCDNRoutes(ctx context.Context) []CDNRoute
// DownloadCDNShuffle returns true if generated download URLs should be
// distributed randomly across the site URL and all CDN routes.
DownloadCDNShuffle(ctx context.Context) bool
// DownloadURLBase returns the base URL used when generating file
// download URLs. With `download_cdn_shuffle` enabled and CDN routes
// configured, an endpoint is picked uniformly at random from the
// primary site URL plus all routes; otherwise it falls back to
// SiteURL. A context pinned by UseFirstSiteUrl always returns SiteURL.
DownloadURLBase(ctx context.Context) *url.URL
// AuditLogEnabled returns true if the given audit event type is // AuditLogEnabled returns true if the given audit event type is
// recorded. An empty/unset list records everything. // recorded. An empty/unset list records everything.
AuditLogEnabled(ctx context.Context, eventType int) bool AuditLogEnabled(ctx context.Context, eventType int) bool
@ -1064,6 +1074,32 @@ func (s *settingProvider) DownloadCDNRoutes(ctx context.Context) []CDNRoute {
return routes return routes
} }
func (s *settingProvider) DownloadCDNShuffle(ctx context.Context) bool {
return s.getBoolean(ctx, "download_cdn_shuffle", false)
}
func (s *settingProvider) DownloadURLBase(ctx context.Context) *url.URL {
if _, pinned := ctx.Value(UseFirstSiteUrlCtxKey{}).(bool); pinned {
return s.SiteURL(ctx)
}
if !s.DownloadCDNShuffle(ctx) {
return s.SiteURL(ctx)
}
routes := s.DownloadCDNRoutes(ctx)
if len(routes) == 0 {
return s.SiteURL(ctx)
}
pool := make([]*url.URL, 0, len(routes)+1)
pool = append(pool, s.SiteURL(ctx))
for _, r := range routes {
if u, err := url.Parse(r.URL); err == nil && u.Scheme != "" && u.Host != "" {
pool = append(pool, u)
}
}
return pool[rand.IntN(len(pool))]
}
func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults { func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults {
level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", "")) level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", ""))
switch level { switch level {

@ -35,3 +35,52 @@ func TestDownloadCDNRoutes(t *testing.T) {
{Name: "cdn2", URL: "https://cdn2.example.com/base"}, {Name: "cdn2", URL: "https://cdn2.example.com/base"},
}, routes) }, routes)
} }
func TestDownloadURLBase(t *testing.T) {
ctx := context.Background()
// Shuffle disabled: always the resolved site URL.
p := NewProvider(stubAdapter{
"siteURL": "https://a.example.com,https://b.example.com",
"download_cdn_routes": "cdn1=https://cdn1.example.com",
})
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
// Shuffle enabled without routes: still the site URL.
p = NewProvider(stubAdapter{
"siteURL": "https://a.example.com",
"download_cdn_shuffle": "1",
})
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
// Shuffle enabled: every draw lands on site URL or a configured route,
// and all endpoints are reached over enough draws.
p = NewProvider(stubAdapter{
"siteURL": "https://a.example.com",
"download_cdn_shuffle": "1",
"download_cdn_routes": "cdn1=https://cdn1.example.com\ncdn2=https://cdn2.example.com",
})
seen := map[string]bool{}
for i := 0; i < 300; i++ {
seen[p.DownloadURLBase(ctx).String()] = true
}
require.Equal(t, map[string]bool{
"https://a.example.com": true,
"https://cdn1.example.com": true,
"https://cdn2.example.com": true,
}, seen)
// UseFirstSiteUrl pins the primary site URL even with shuffle on.
pinned := context.WithValue(ctx, UseFirstSiteUrlCtxKey{}, true)
require.Equal(t, "https://a.example.com", p.DownloadURLBase(pinned).String())
// Invalid route entries never leak into the pool.
p = NewProvider(stubAdapter{
"siteURL": "https://a.example.com",
"download_cdn_shuffle": "1",
"download_cdn_routes": "bad=ftp://x.example.com",
})
for i := 0; i < 50; i++ {
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
}
}

@ -58,6 +58,11 @@ type SiteConfig struct {
// can offer a download-route picker (#2987). // can offer a download-route picker (#2987).
DownloadCDNRoutes []setting.CDNRoute `json:"download_cdn_routes,omitempty"` DownloadCDNRoutes []setting.CDNRoute `json:"download_cdn_routes,omitempty"`
// DownloadCDNShuffle tells clients generated download URLs are already
// spread across the site URL and all CDN routes server-side (#173), so
// the manual route picker can be skipped.
DownloadCDNShuffle bool `json:"download_cdn_shuffle,omitempty"`
// AbuseCaptcha controls whether the report-abuse dialog shows captcha. // AbuseCaptcha controls whether the report-abuse dialog shows captcha.
AbuseCaptcha bool `json:"abuse_captcha,omitempty"` AbuseCaptcha bool `json:"abuse_captcha,omitempty"`
@ -260,6 +265,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
ShareDefaultPrivate: shareDefaults.PrivateByDefault, ShareDefaultPrivate: shareDefaults.PrivateByDefault,
DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile), DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile),
DownloadCDNRoutes: settings.DownloadCDNRoutes(c), DownloadCDNRoutes: settings.DownloadCDNRoutes(c),
DownloadCDNShuffle: settings.DownloadCDNShuffle(c),
AbuseCaptcha: settings.AbuseCaptchaEnabled(c), AbuseCaptcha: settings.AbuseCaptchaEnabled(c),
UploadDedup: settings.DBFS(c).DedupScope != "off", UploadDedup: settings.DBFS(c).DedupScope != "off",
TaskNodes: taskNodes, TaskNodes: taskNodes,

@ -463,7 +463,7 @@ func (s *FileURLService) GetArchiveDownloadSession(c *gin.Context) (*FileURLResp
return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to create archive download session", err) return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to create archive download session", err)
} }
base := settings.SiteURL(c) base := settings.DownloadURLBase(c)
downloadUrl := routes.MasterArchiveDownloadUrl(base, sessionId) downloadUrl := routes.MasterArchiveDownloadUrl(base, sessionId)
finalUrl, err := auth.SignURI(c, dep.GeneralAuth(), downloadUrl.String(), &expire) finalUrl, err := auth.SignURI(c, dep.GeneralAuth(), downloadUrl.String(), &expire)
if err != nil { if err != nil {

Loading…
Cancel
Save