From d264d3c734e9eff54cfc84096acc801604b20ee2 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sat, 19 Sep 2026 01:49:17 +0200 Subject: [PATCH] Add selectable CDN download routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now list alternative download endpoints under Settings > Site information (name=url per line). When configured, single-file and server-side batch downloads offer a route picker; the chosen base URL replaces the signed URL's origin while path and query — and therefore the signature — stay intact for the CDN to proxy back. 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 | 3 ++ frontend/public/locales/en-US/dashboard.json | 2 + .../public/locales/zh-CN/application.json | 3 ++ frontend/public/locales/zh-CN/dashboard.json | 2 + frontend/src/api/site.ts | 1 + .../src/component/Admin/Settings/Settings.tsx | 1 + .../SiteInformation/SiteInformation.tsx | 13 ++++++ frontend/src/redux/thunks/download.ts | 45 +++++++++++++++++-- inventory/setting.go | 1 + pkg/setting/provider.go | 33 ++++++++++++++ pkg/setting/provider_test.go | 37 +++++++++++++++ service/basic/site.go | 5 +++ 12 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 pkg/setting/provider_test.go diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index db5e1b1b..f8fabe2d 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -432,6 +432,9 @@ "serverBatchDownload": "Server-side archiving", "serverBatchDownloadDescription": "Archive by the server to a Zip file and sent to the client for download on-the-fly, share link shortcut is not supported.", "selectArchiveMethod": "Select archive method", + "selectDownloadRoute": "Select download route", + "downloadRouteDirect": "Direct", + "downloadRouteDirectDescription": "Download directly from this site", "batchDownloadStarted": "Batch download has started, please do not close this tab...", "downloadProgress": "{{completed}} / {{total}} files downloaded ({{percent}}%)", "downloadComplete": "Download complete!", diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index ef5473ab..7fbdf0cd 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -840,6 +840,8 @@ "ssoIssuerDes": "Base URL of the OIDC issuer, e.g. <0>https://keycloak.example.com/realms/master. The provider metadata is fetched from <1>.well-known/openid-configuration under this URL.", "ssoRegisterEnabled": "Allow automatic registration", "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", + "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.", "ssoAutoRedirect": "Auto redirect to SSO", "ssoAutoRedirectDes": "Skip the login form and send visitors straight to the identity provider. Append <0>?nosso=1 to the login URL to reach the password form (e.g. for admin recovery).", "ssoCallbackUrl": "Callback URL", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 2622dccf..0ae05648 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -432,6 +432,9 @@ "serverBatchDownload": "服务端中转打包", "serverBatchDownloadDescription": "由服务端中转打包为 Zip 文件并实时发送到客户端下载,不支持分享快捷方式。", "selectArchiveMethod": "选择批量下载方式", + "selectDownloadRoute": "选择下载线路", + "downloadRouteDirect": "直连", + "downloadRouteDirectDescription": "直接从本站下载", "batchDownloadStarted": "打包下载已开始,请不要关闭此页面...", "downloadProgress": "已下载 {{completed}} / {{total}} 个文件 ({{percent}}%)", "downloadComplete": "下载完成!", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 755dfbcd..52cf6563 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -840,6 +840,8 @@ "ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master。将从该地址下的 <1>.well-known/openid-configuration 获取提供方元数据。", "ssoRegisterEnabled": "允许自动注册", "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", + "downloadCdnRoutes": "下载 CDN 线路", + "downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。", "ssoAutoRedirect": "自动跳转至 SSO", "ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1 可进入密码登录表单(例如管理员账户恢复)。", "ssoCallbackUrl": "回调地址", diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index 67ca5773..47c140b3 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -31,6 +31,7 @@ export interface SiteConfig { sso_enabled?: boolean; sso_display_name?: string; sso_auto_redirect?: boolean; + download_cdn_routes?: { name: string; url: string }[]; logo?: string; logo_light?: string; tos_url?: string; diff --git a/frontend/src/component/Admin/Settings/Settings.tsx b/frontend/src/component/Admin/Settings/Settings.tsx index bee58c83..d26f7cdf 100644 --- a/frontend/src/component/Admin/Settings/Settings.tsx +++ b/frontend/src/component/Admin/Settings/Settings.tsx @@ -164,6 +164,7 @@ const Settings = () => { "siteName", "siteDes", "siteURL", + "download_cdn_routes", "siteScript", "pwa_small_icon", "pwa_medium_icon", diff --git a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx index d28eefa0..90b248af 100644 --- a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx +++ b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx @@ -52,6 +52,19 @@ const SiteInformation = () => { setSettings({ siteURL: v })} /> + + + setSettings({ download_cdn_routes: e.target.value })} + value={values.download_cdn_routes} + multiline + rows={3} + placeholder={"Line 1=https://cdn1.example.com\nLine 2=https://cdn2.example.com"} + /> + {t("settings.downloadCdnRoutesDes")} + + > { + return async (dispatch, getState) => { + const routes = getState().siteConfig.basic.config.download_cdn_routes; + if (!routes || routes.length === 0) { + return url; + } + + const options: DialogSelectOption[] = [ + { + value: -1, + name: i18next.t("fileManager.downloadRouteDirect"), + description: i18next.t("fileManager.downloadRouteDirectDescription"), + }, + ...routes.map((r, i): DialogSelectOption => ({ value: i, name: r.name, description: r.url })), + ]; + + let picked: number; + try { + picked = (await dispatch(selectOption(options, "fileManager.selectDownloadRoute"))) as number; + } catch { + return url; + } + if (picked < 0 || picked >= routes.length) { + return url; + } + + try { + const u = new URL(url); + return routes[picked].url.replace(/\/+$/, "") + u.pathname + u.search + u.hash; + } catch { + return url; + } }; } @@ -553,11 +591,12 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string) "application:fileManager.preparingDownload", ); + const downloadUrl = await dispatch(pickDownloadRoute(urlRes.urls[0].url)); const streamSaverName = urlRes.urls[0].stream_saver_display_name; if (streamSaverName) { // remove streamSaverParam from query const fileStream = streamSaver.createWriteStream(streamSaverName); - const res = await fetch(urlRes.urls[0].url); + const res = await fetch(downloadUrl); const readableStream = res.body; if (!readableStream) { return; @@ -575,7 +614,7 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string) return readableStream.pipeTo(fileStream).finally(() => closeSnackbar(downloadingSnackbar)); } } else { - window.location.assign(urlRes.urls[0].url); + window.location.assign(downloadUrl); } }; } diff --git a/inventory/setting.go b/inventory/setting.go index fdc5e678..ed4aa989 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -559,6 +559,7 @@ var DefaultSettings = map[string]string{ "sso_scopes": "", "sso_register_enabled": "1", "sso_auto_redirect": "0", + "download_cdn_routes": "", "email_filter_mode": "0", "email_filter_list": "", "email_disable_subaddress": "0", diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 3a169ce0..c547f9a8 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -249,6 +249,9 @@ type ( // ShareDefaults returns the site-wide share defaults applied when a // user has not overridden them in their personal settings. ShareDefaults(ctx context.Context) *ShareDefaults + // DownloadCDNRoutes returns the configured alternative download + // endpoints users can pick from (e.g. CDN mirrors of the site). + DownloadCDNRoutes(ctx context.Context) []CDNRoute } UseFirstSiteUrlCtxKey = struct{} ) @@ -950,6 +953,36 @@ type ShareDefaults struct { PrivateByDefault bool } +// CDNRoute is an alternative download endpoint offered to users when +// downloading files, e.g. a CDN mirror fronting the site. +type CDNRoute struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func (s *settingProvider) DownloadCDNRoutes(ctx context.Context) []CDNRoute { + raw := s.getString(ctx, "download_cdn_routes", "") + routes := make([]CDNRoute, 0) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + name, u, found := strings.Cut(line, "=") + if !found { + continue + } + u = strings.TrimRight(strings.TrimSpace(u), "/") + parsed, err := url.Parse(u) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + continue + } + routes = append(routes, CDNRoute{Name: strings.TrimSpace(name), URL: u}) + } + return routes +} + func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults { level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", "")) switch level { diff --git a/pkg/setting/provider_test.go b/pkg/setting/provider_test.go new file mode 100644 index 00000000..9645b4bf --- /dev/null +++ b/pkg/setting/provider_test.go @@ -0,0 +1,37 @@ +package setting + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type stubAdapter map[string]any + +func (s stubAdapter) Get(_ context.Context, name string, defaultVal any) any { + if v, ok := s[name]; ok { + return v + } + return defaultVal +} + +func TestDownloadCDNRoutes(t *testing.T) { + ctx := context.Background() + + p := NewProvider(stubAdapter{}) + require.Empty(t, p.DownloadCDNRoutes(ctx)) + + p = NewProvider(stubAdapter{ + "download_cdn_routes": "Line 1=https://cdn1.example.com/\n\n" + + "not-a-route\n" + + "cdn2 = https://cdn2.example.com/base/\n" + + "bad=ftp://example.com\n" + + "also-bad=notaurl", + }) + routes := p.DownloadCDNRoutes(ctx) + require.Equal(t, []CDNRoute{ + {Name: "Line 1", URL: "https://cdn1.example.com"}, + {Name: "cdn2", URL: "https://cdn2.example.com/base"}, + }, routes) +} diff --git a/service/basic/site.go b/service/basic/site.go index 7e12babb..5f126700 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -49,6 +49,10 @@ type SiteConfig struct { SSODisplayName string `json:"sso_display_name,omitempty"` SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"` + // DownloadCDNRoutes exposes configured CDN mirror endpoints so clients + // can offer a download-route picker (#2987). + DownloadCDNRoutes []setting.CDNRoute `json:"download_cdn_routes,omitempty"` + // Explorer section Icons string `json:"icons,omitempty"` EmojiPreset string `json:"emoji_preset,omitempty"` @@ -222,6 +226,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { CustomHTML: customHTML, ShareDefaultPrivate: shareDefaults.PrivateByDefault, DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile), + DownloadCDNRoutes: settings.DownloadCDNRoutes(c), }, nil }