Add selectable CDN download routes

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>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 6b05d8d9e5
commit d264d3c734

@ -432,6 +432,9 @@
"serverBatchDownload": "Server-side archiving", "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.", "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", "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...", "batchDownloadStarted": "Batch download has started, please do not close this tab...",
"downloadProgress": "{{completed}} / {{total}} files downloaded ({{percent}}%)", "downloadProgress": "{{completed}} / {{total}} files downloaded ({{percent}}%)",
"downloadComplete": "Download complete!", "downloadComplete": "Download complete!",

@ -840,6 +840,8 @@
"ssoIssuerDes": "Base URL of the OIDC issuer, e.g. <0>https://keycloak.example.com/realms/master</0>. The provider metadata is fetched from <1>.well-known/openid-configuration</1> under this URL.", "ssoIssuerDes": "Base URL of the OIDC issuer, e.g. <0>https://keycloak.example.com/realms/master</0>. The provider metadata is fetched from <1>.well-known/openid-configuration</1> under this URL.",
"ssoRegisterEnabled": "Allow automatic registration", "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.", "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", "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",

@ -432,6 +432,9 @@
"serverBatchDownload": "服务端中转打包", "serverBatchDownload": "服务端中转打包",
"serverBatchDownloadDescription": "由服务端中转打包为 Zip 文件并实时发送到客户端下载,不支持分享快捷方式。", "serverBatchDownloadDescription": "由服务端中转打包为 Zip 文件并实时发送到客户端下载,不支持分享快捷方式。",
"selectArchiveMethod": "选择批量下载方式", "selectArchiveMethod": "选择批量下载方式",
"selectDownloadRoute": "选择下载线路",
"downloadRouteDirect": "直连",
"downloadRouteDirectDescription": "直接从本站下载",
"batchDownloadStarted": "打包下载已开始,请不要关闭此页面...", "batchDownloadStarted": "打包下载已开始,请不要关闭此页面...",
"downloadProgress": "已下载 {{completed}} / {{total}} 个文件 ({{percent}}%)", "downloadProgress": "已下载 {{completed}} / {{total}} 个文件 ({{percent}}%)",
"downloadComplete": "下载完成!", "downloadComplete": "下载完成!",

@ -840,6 +840,8 @@
"ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master</0>。将从该地址下的 <1>.well-known/openid-configuration</1> 获取提供方元数据。", "ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master</0>。将从该地址下的 <1>.well-known/openid-configuration</1> 获取提供方元数据。",
"ssoRegisterEnabled": "允许自动注册", "ssoRegisterEnabled": "允许自动注册",
"ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。",
"downloadCdnRoutes": "下载 CDN 线路",
"downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。",
"ssoAutoRedirect": "自动跳转至 SSO", "ssoAutoRedirect": "自动跳转至 SSO",
"ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1</0> 可进入密码登录表单(例如管理员账户恢复)。", "ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1</0> 可进入密码登录表单(例如管理员账户恢复)。",
"ssoCallbackUrl": "回调地址", "ssoCallbackUrl": "回调地址",

@ -31,6 +31,7 @@ export interface SiteConfig {
sso_enabled?: boolean; sso_enabled?: boolean;
sso_display_name?: string; sso_display_name?: string;
sso_auto_redirect?: boolean; sso_auto_redirect?: boolean;
download_cdn_routes?: { name: string; url: string }[];
logo?: string; logo?: string;
logo_light?: string; logo_light?: string;
tos_url?: string; tos_url?: string;

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

@ -52,6 +52,19 @@ const SiteInformation = () => {
<SiteURLInput urls={values.siteURL} onChange={(v) => setSettings({ siteURL: v })} /> <SiteURLInput urls={values.siteURL} onChange={(v) => setSettings({ siteURL: v })} />
</FormControl> </FormControl>
</SettingForm> </SettingForm>
<SettingForm title={t("settings.downloadCdnRoutes")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => 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"}
/>
<NoMarginHelperText>{t("settings.downloadCdnRoutesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.customFooterHTML")} lgWidth={5}> <SettingForm title={t("settings.customFooterHTML")} lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<DenseFilledTextField <DenseFilledTextField

@ -128,7 +128,45 @@ export function backendBatchDownload(files: FileResponse[]): AppThunk {
"application:fileManager.preparingBathDownload", "application:fileManager.preparingBathDownload",
); );
window.location.assign(downloadUrl.urls[0].url); window.location.assign(await dispatch(pickDownloadRoute(downloadUrl.urls[0].url)));
};
}
// pickDownloadRoute lets the user choose one of the admin-configured CDN
// mirror endpoints (#2987); the signed URL keeps its path+query so the
// signature stays valid through the CDN. Cancel falls back to direct.
export function pickDownloadRoute(url: string): AppThunk<Promise<string>> {
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", "application:fileManager.preparingDownload",
); );
const downloadUrl = await dispatch(pickDownloadRoute(urlRes.urls[0].url));
const streamSaverName = urlRes.urls[0].stream_saver_display_name; const streamSaverName = urlRes.urls[0].stream_saver_display_name;
if (streamSaverName) { if (streamSaverName) {
// remove streamSaverParam from query // remove streamSaverParam from query
const fileStream = streamSaver.createWriteStream(streamSaverName); const fileStream = streamSaver.createWriteStream(streamSaverName);
const res = await fetch(urlRes.urls[0].url); const res = await fetch(downloadUrl);
const readableStream = res.body; const readableStream = res.body;
if (!readableStream) { if (!readableStream) {
return; return;
@ -575,7 +614,7 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string)
return readableStream.pipeTo(fileStream).finally(() => closeSnackbar(downloadingSnackbar)); return readableStream.pipeTo(fileStream).finally(() => closeSnackbar(downloadingSnackbar));
} }
} else { } else {
window.location.assign(urlRes.urls[0].url); window.location.assign(downloadUrl);
} }
}; };
} }

@ -559,6 +559,7 @@ var DefaultSettings = map[string]string{
"sso_scopes": "", "sso_scopes": "",
"sso_register_enabled": "1", "sso_register_enabled": "1",
"sso_auto_redirect": "0", "sso_auto_redirect": "0",
"download_cdn_routes": "",
"email_filter_mode": "0", "email_filter_mode": "0",
"email_filter_list": "", "email_filter_list": "",
"email_disable_subaddress": "0", "email_disable_subaddress": "0",

@ -249,6 +249,9 @@ type (
// ShareDefaults returns the site-wide share defaults applied when a // ShareDefaults returns the site-wide share defaults applied when a
// user has not overridden them in their personal settings. // user has not overridden them in their personal settings.
ShareDefaults(ctx context.Context) *ShareDefaults 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{} UseFirstSiteUrlCtxKey = struct{}
) )
@ -950,6 +953,36 @@ type ShareDefaults struct {
PrivateByDefault bool 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 { 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 {

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

@ -49,6 +49,10 @@ type SiteConfig struct {
SSODisplayName string `json:"sso_display_name,omitempty"` SSODisplayName string `json:"sso_display_name,omitempty"`
SSOAutoRedirect bool `json:"sso_auto_redirect,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 // Explorer section
Icons string `json:"icons,omitempty"` Icons string `json:"icons,omitempty"`
EmojiPreset string `json:"emoji_preset,omitempty"` EmojiPreset string `json:"emoji_preset,omitempty"`
@ -222,6 +226,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
CustomHTML: customHTML, CustomHTML: customHTML,
ShareDefaultPrivate: shareDefaults.PrivateByDefault, ShareDefaultPrivate: shareDefaults.PrivateByDefault,
DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile), DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile),
DownloadCDNRoutes: settings.DownloadCDNRoutes(c),
}, nil }, nil
} }

Loading…
Cancel
Save