From 791764a66f7a473bb17c5edee3b209e85af72cee Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sat, 19 Sep 2026 00:48:01 +0200 Subject: [PATCH] feat(workflow): custom request headers for remote downloads (#3491) - DownloadWorkflowService accepts headers[] ("Name: value" lines, CRLF rejected, max 32 entries) - aria2 maps them to the header option; qBittorrent maps a Cookie: line onto its cookie field (the only credential its add API accepts) - Persisted in task private state for resumability; only applied to plain HTTP(S) sources Authored By: TDvorak 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 | 2 + .../public/locales/zh-CN/application.json | 2 + frontend/src/api/workflow.ts | 1 + .../Dialogs/CreateRemoteDownload.tsx | 20 +++++++++- pkg/filemanager/workflows/remote_download.go | 39 +++++++++++++------ .../workflows/remote_download_test.go | 22 +++++++++++ service/explorer/workflows.go | 14 ++++++- 7 files changed, 87 insertions(+), 13 deletions(-) diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index a169aa46..db5e1b1b 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -565,6 +565,8 @@ "remoteDownloadFileNameDescription": "Custom output file name", "remoteDownloadHttpUser": "HTTP username (optional)", "remoteDownloadHttpPassword": "HTTP password (optional)", + "remoteDownloadHeaders": "Request headers (optional)", + "remoteDownloadHeadersDescription": "One header per line, e.g. Cookie: sid=...", "processNode": "Target node", "remoteDownloadNodeAuto": "Auto dispatch", "createTask": "Create task", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 5dec9720..2622dccf 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -565,6 +565,8 @@ "remoteDownloadFileNameDescription": "自定义保存文件名", "remoteDownloadHttpUser": "HTTP 用户名(可选)", "remoteDownloadHttpPassword": "HTTP 密码(可选)", + "remoteDownloadHeaders": "请求头(可选)", + "remoteDownloadHeadersDescription": "每行一个请求头,例如 Cookie: sid=...", "processNode": "处理节点", "remoteDownloadNodeAuto": "自动分配", "createTask": "创建任务", diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index d77730e3..521c4c1c 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -104,6 +104,7 @@ export interface DownloadWorkflowService { file_name?: string; username?: string; password?: string; + headers?: string[]; } export interface ImportWorkflowService { diff --git a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx index 62c140a8..dcda903f 100644 --- a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx +++ b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx @@ -32,6 +32,7 @@ const CreateRemoteDownload = () => { const [fileName, setFileName] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [headers, setHeaders] = useState(""); const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen); const target = useAppSelector((state) => state.globalState.remoteDownloadDialogFile); @@ -46,6 +47,7 @@ const CreateRemoteDownload = () => { setFileName(""); setUsername(""); setPassword(""); + setHeaders(""); } }, [open]); @@ -67,6 +69,7 @@ const CreateRemoteDownload = () => { file_name: fileName || undefined, username: username || undefined, password: password || undefined, + headers: headers ? headers.split("\n").filter((h) => h.trim()) : undefined, }), ) .then(() => { @@ -80,7 +83,7 @@ const CreateRemoteDownload = () => { .finally(() => { setLoading(false); }); - }, [target, url, path, fileName, username, password]); + }, [target, url, path, fileName, username, password, headers]); return ( { /> )} + {!target && ( + + } + variant="outlined" + value={headers} + multiline + minRows={2} + onChange={(e) => setHeaders(e.target.value)} + placeholder={t("modals.remoteDownloadHeadersDescription")} + label={t("application:modals.remoteDownloadHeaders")} + fullWidth + /> + + )} diff --git a/pkg/filemanager/workflows/remote_download.go b/pkg/filemanager/workflows/remote_download.go index bd1ea938..29c5071d 100644 --- a/pkg/filemanager/workflows/remote_download.go +++ b/pkg/filemanager/workflows/remote_download.go @@ -50,6 +50,7 @@ type ( FileName string `json:"file_name,omitempty"` HTTPUsername string `json:"http_username,omitempty"` HTTPPassword string `json:"http_password,omitempty"` + HTTPHeaders []string `json:"http_headers,omitempty"` Handle *downloader.TaskHandle `json:"handle,omitempty"` Status *downloader.TaskStatus `json:"status,omitempty"` NodeState `json:",inline"` @@ -93,6 +94,7 @@ type RemoteDownloadTaskOption struct { FileName string HTTPUsername string HTTPPassword string + HTTPHeaders []string } // NewRemoteDownloadTask creates a new RemoteDownloadTask @@ -107,6 +109,7 @@ func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string, state.FileName = sanitizeFileName(opts.FileName) state.HTTPUsername = opts.HTTPUsername state.HTTPPassword = opts.HTTPPassword + state.HTTPHeaders = opts.HTTPHeaders } stateBytes, err := json.Marshal(state) if err != nil { @@ -251,7 +254,7 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende // credentials only apply to plain HTTP(S) source URLs on aria2; qBittorrent // accepts a torrent rename and carries HTTP auth in the URL userinfo. func (m *RemoteDownloadTask) buildDownloadOptions(ctx context.Context, base map[string]interface{}, srcUrl string) (map[string]interface{}, string) { - if m.state.FileName == "" && m.state.HTTPUsername == "" { + if m.state.FileName == "" && m.state.HTTPUsername == "" && len(m.state.HTTPHeaders) == 0 { return base, srcUrl } @@ -266,19 +269,33 @@ func (m *RemoteDownloadTask) buildDownloadOptions(ctx context.Context, base map[ if m.state.FileName != "" { options["rename"] = m.state.FileName } - if m.state.HTTPUsername != "" && isHttpSrc { - if u, err := url.Parse(srcUrl); err == nil { - u.User = url.UserPassword(m.state.HTTPUsername, m.state.HTTPPassword) - srcUrl = u.String() + if isHttpSrc { + if m.state.HTTPUsername != "" { + if u, err := url.Parse(srcUrl); err == nil { + u.User = url.UserPassword(m.state.HTTPUsername, m.state.HTTPPassword) + srcUrl = u.String() + } + } + // qBittorrent's add API only accepts a cookie field, not arbitrary + // headers — pass through any Cookie: line the user supplied. + for _, h := range m.state.HTTPHeaders { + if k, v, ok := strings.Cut(h, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "cookie") { + options["cookie"] = strings.TrimSpace(v) + } } } default: - if m.state.FileName != "" && isHttpSrc { - options["out"] = m.state.FileName - } - if m.state.HTTPUsername != "" && isHttpSrc { - options["http-user"] = m.state.HTTPUsername - options["http-passwd"] = m.state.HTTPPassword + if isHttpSrc { + if m.state.FileName != "" { + options["out"] = m.state.FileName + } + if m.state.HTTPUsername != "" { + options["http-user"] = m.state.HTTPUsername + options["http-passwd"] = m.state.HTTPPassword + } + if len(m.state.HTTPHeaders) > 0 { + options["header"] = m.state.HTTPHeaders + } } } return options, srcUrl diff --git a/pkg/filemanager/workflows/remote_download_test.go b/pkg/filemanager/workflows/remote_download_test.go index f2b0fe4d..30fa4744 100644 --- a/pkg/filemanager/workflows/remote_download_test.go +++ b/pkg/filemanager/workflows/remote_download_test.go @@ -107,6 +107,28 @@ func TestBuildDownloadOptionsNoExtras(t *testing.T) { a.Equal("https://example.com/file.zip", taskUrl) } +func TestBuildDownloadOptionsHeaders(t *testing.T) { + a := assert.New(t) + + // aria2: arbitrary headers pass through as a list. + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/f.zip", + HTTPHeaders: []string{"Cookie: sid=abc", "Referer: https://example.com/"}, + }, types.DownloaderProviderAria2) + opts, _ := m.buildDownloadOptions(context.Background(), nil, "https://example.com/f.zip") + a.Equal([]string{"Cookie: sid=abc", "Referer: https://example.com/"}, opts["header"]) + + // qBittorrent: only the Cookie line maps onto the cookie field. + m = newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/f.torrent", + HTTPHeaders: []string{"Referer: https://x/", "Cookie: sid=abc"}, + }, types.DownloaderProviderQBittorrent) + opts, _ = m.buildDownloadOptions(context.Background(), nil, "https://example.com/f.torrent") + a.Equal("sid=abc", opts["cookie"]) + _, hasHeader := opts["header"] + a.False(hasHeader) +} + func TestNewRemoteDownloadTaskSanitizesFileName(t *testing.T) { a := assert.New(t) tsk, err := NewRemoteDownloadTask(context.Background(), "https://example.com/f", "", "cloudreve://my/dst", &RemoteDownloadTaskOption{ diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index c1b75659..4b385a6d 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -5,6 +5,7 @@ import ( "encoding/gob" "fmt" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "strings" "time" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -79,6 +80,7 @@ type ( FileName string `json:"file_name" binding:"omitempty,max=255"` Username string `json:"username" binding:"omitempty,max=255"` Password string `json:"password" binding:"omitempty,max=255"` + Headers []string `json:"headers" binding:"omitempty,max=32,dive,max=2048"` } CreateDownloadParamCtx struct{} ) @@ -134,11 +136,20 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } } + // Validate custom request headers: "Name: value" lines, no CRLF injection. + for _, h := range service.Headers { + name, _, ok := strings.Cut(h, ":") + if !ok || strings.TrimSpace(name) == "" || strings.ContainsAny(h, "\r\n") { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid header", nil) + } + } + // Custom file name only applies to single-source tasks; HTTP credentials - // only apply to plain HTTP(S) source URLs. + // and headers only apply to plain HTTP(S) source URLs. taskOpts := &workflows.RemoteDownloadTaskOption{ HTTPUsername: service.Username, HTTPPassword: service.Password, + HTTPHeaders: service.Headers, } if len(service.Src) <= 1 { taskOpts.FileName = service.FileName @@ -146,6 +157,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T if service.SrcFile != "" { taskOpts.HTTPUsername = "" taskOpts.HTTPPassword = "" + taskOpts.HTTPHeaders = nil } // batch creating tasks