diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 2f8f2d09..a169aa46 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -561,6 +561,10 @@ "remoteDownloadURL": "Download target URL", "remoteDownloadURLDescription": "Paste the download URL, one URL per line", "remoteDownloadDst": "Download to", + "remoteDownloadFileName": "File name (optional)", + "remoteDownloadFileNameDescription": "Custom output file name", + "remoteDownloadHttpUser": "HTTP username (optional)", + "remoteDownloadHttpPassword": "HTTP password (optional)", "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 0649856d..5dec9720 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -561,6 +561,10 @@ "remoteDownloadURL": "下载链接", "remoteDownloadURLDescription": "输入文件下载地址,一行一个", "remoteDownloadDst": "下载至", + "remoteDownloadFileName": "文件名(可选)", + "remoteDownloadFileNameDescription": "自定义保存文件名", + "remoteDownloadHttpUser": "HTTP 用户名(可选)", + "remoteDownloadHttpPassword": "HTTP 密码(可选)", "processNode": "处理节点", "remoteDownloadNodeAuto": "自动分配", "createTask": "创建任务", diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index 351cb925..d77730e3 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -101,6 +101,9 @@ export interface DownloadWorkflowService { src?: string[]; src_file?: string; dst: string; + file_name?: string; + username?: string; + password?: string; } export interface ImportWorkflowService { diff --git a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx index a8a9c10b..62c140a8 100644 --- a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx +++ b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx @@ -13,7 +13,10 @@ import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx"; import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx"; import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; +import Edit from "../../Icons/Edit.tsx"; import Link from "../../Icons/Link.tsx"; +import LockClosedKey from "../../Icons/LockClosedKey.tsx"; +import PersonOutlined from "../../Icons/PersonOutlined.tsx"; import { FileManagerIndex } from "../FileManager.tsx"; const CreateRemoteDownload = () => { @@ -26,6 +29,9 @@ const CreateRemoteDownload = () => { const [loading, setLoading] = useState(false); const [path, setPath] = useState(""); const [url, setUrl] = useState(""); + const [fileName, setFileName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen); const target = useAppSelector((state) => state.globalState.remoteDownloadDialogFile); @@ -37,6 +43,9 @@ const CreateRemoteDownload = () => { const fs = initialPath.fs(); setPath(fs == Filesystem.shared_with_me || fs == Filesystem.trash ? defaultPath : initialPath.toString()); setUrl(""); + setFileName(""); + setUsername(""); + setPassword(""); } }, [open]); @@ -55,6 +64,9 @@ const CreateRemoteDownload = () => { src_file: target ? getFileLinkedUri(target) : undefined, dst: path, src: url ? url.split("\n") : undefined, + file_name: fileName || undefined, + username: username || undefined, + password: password || undefined, }), ) .then(() => { @@ -68,7 +80,7 @@ const CreateRemoteDownload = () => { .finally(() => { setLoading(false); }); - }, [target, url, path]); + }, [target, url, path, fileName, username, password]); return ( { label={t("modals.remoteDownloadDst")} /> + + } + variant="outlined" + value={fileName} + onChange={(e) => setFileName(e.target.value)} + label={t("application:modals.remoteDownloadFileName")} + placeholder={t("modals.remoteDownloadFileNameDescription")} + fullWidth + /> + + {!target && ( + + } + variant="outlined" + value={username} + onChange={(e) => setUsername(e.target.value)} + label={t("application:modals.remoteDownloadHttpUser")} + fullWidth + /> + } + variant="outlined" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + label={t("application:modals.remoteDownloadHttpPassword")} + fullWidth + /> + + )} diff --git a/pkg/filemanager/workflows/remote_download.go b/pkg/filemanager/workflows/remote_download.go index 4b78afc8..bd1ea938 100644 --- a/pkg/filemanager/workflows/remote_download.go +++ b/pkg/filemanager/workflows/remote_download.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "net/url" "os" "path" "path/filepath" @@ -45,6 +47,9 @@ type ( SrcFileUri string `json:"src_file_uri,omitempty"` SrcUri string `json:"src_uri,omitempty"` Dst string `json:"dst,omitempty"` + FileName string `json:"file_name,omitempty"` + HTTPUsername string `json:"http_username,omitempty"` + HTTPPassword string `json:"http_password,omitempty"` Handle *downloader.TaskHandle `json:"handle,omitempty"` Status *downloader.TaskStatus `json:"status,omitempty"` NodeState `json:",inline"` @@ -81,14 +86,28 @@ func init() { queue.RegisterResumableTaskFactory(queue.RemoteDownloadTaskType, NewRemoteDownloadTaskFromModel) } +// RemoteDownloadTaskOption carries optional per-task parameters supplied by +// the user: a custom output file name and HTTP basic-auth credentials for +// plain HTTP(S) sources. +type RemoteDownloadTaskOption struct { + FileName string + HTTPUsername string + HTTPPassword string +} + // NewRemoteDownloadTask creates a new RemoteDownloadTask -func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string) (queue.Task, error) { +func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string, opts *RemoteDownloadTaskOption) (queue.Task, error) { state := &RemoteDownloadTaskState{ SrcUri: src, SrcFileUri: srcFile, Dst: dst, NodeState: NodeState{}, } + if opts != nil { + state.FileName = sanitizeFileName(opts.FileName) + state.HTTPUsername = opts.HTTPUsername + state.HTTPPassword = opts.HTTPPassword + } stateBytes, err := json.Marshal(state) if err != nil { return nil, fmt.Errorf("failed to marshal state: %w", err) @@ -214,8 +233,10 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende torrentUrl = torrentUrls[0].Url } + options, taskUrl := m.buildDownloadOptions(ctx, user.Edges.Group.Settings.RemoteDownloadOptions, torrentUrl) + // Create download task - handle, err := m.d.CreateTask(ctx, torrentUrl, user.Edges.Group.Settings.RemoteDownloadOptions) + handle, err := m.d.CreateTask(ctx, taskUrl, options) if err != nil { return task.StatusError, fmt.Errorf("failed to create download task: %w", err) } @@ -225,6 +246,44 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende return task.StatusSuspending, nil } +// buildDownloadOptions overlays per-task options (custom file name, HTTP +// credentials) onto the group's remote-download options. Custom name and +// 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 == "" { + return base, srcUrl + } + + options := maps.Clone(base) + if options == nil { + options = map[string]interface{}{} + } + + isHttpSrc := m.state.SrcFileUri == "" && (strings.HasPrefix(m.state.SrcUri, "http://") || strings.HasPrefix(m.state.SrcUri, "https://")) + switch m.node.Settings(ctx).Provider { + case types.DownloaderProviderQBittorrent: + 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() + } + } + 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 + } + } + return options, srcUrl +} + // buildSSRFOptions composes the SSRF policy for a download: the assigned // node's URLValidation settings, plus the operator-configured site URL hosts // (always allowlisted so users can fetch files served by Cloudreve itself). @@ -679,6 +738,6 @@ func (m *RemoteDownloadTask) Progress(ctx context.Context) queue.Progresses { } func sanitizeFileName(name string) string { - r := strings.NewReplacer("\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") + r := strings.NewReplacer("\\", "_", "/", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") return r.Replace(name) } diff --git a/pkg/filemanager/workflows/remote_download_test.go b/pkg/filemanager/workflows/remote_download_test.go new file mode 100644 index 00000000..f2b0fe4d --- /dev/null +++ b/pkg/filemanager/workflows/remote_download_test.go @@ -0,0 +1,120 @@ +package workflows + +import ( + "context" + "encoding/json" + "testing" + + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster" + "github.com/stretchr/testify/assert" +) + +type stubNode struct { + cluster.Node + settings *types.NodeSetting +} + +func (s *stubNode) Settings(ctx context.Context) *types.NodeSetting { + return s.settings +} + +func newRemoteDownloadTaskForOptions(state *RemoteDownloadTaskState, provider types.DownloaderProvider) *RemoteDownloadTask { + return &RemoteDownloadTask{ + state: state, + node: &stubNode{settings: &types.NodeSetting{Provider: provider}}, + } +} + +func TestBuildDownloadOptionsAria2(t *testing.T) { + a := assert.New(t) + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/file.zip", + FileName: "renamed.zip", + HTTPUsername: "user", + HTTPPassword: "pass", + }, types.DownloaderProviderAria2) + + base := map[string]interface{}{"max-connection-per-server": "4"} + opts, taskUrl := m.buildDownloadOptions(context.Background(), base, "https://example.com/file.zip") + + a.Equal("renamed.zip", opts["out"]) + a.Equal("user", opts["http-user"]) + a.Equal("pass", opts["http-passwd"]) + a.Equal("4", opts["max-connection-per-server"]) + a.Equal("https://example.com/file.zip", taskUrl) + _, exists := base["out"] + a.False(exists, "base options must not be mutated") +} + +func TestBuildDownloadOptionsQBittorrent(t *testing.T) { + a := assert.New(t) + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/file.torrent", + FileName: "renamed", + HTTPUsername: "user", + HTTPPassword: "p@ss:word", + }, types.DownloaderProviderQBittorrent) + + opts, taskUrl := m.buildDownloadOptions(context.Background(), nil, "https://example.com/file.torrent") + + a.Equal("renamed", opts["rename"]) + _, exists := opts["out"] + a.False(exists) + a.Equal("https://user:p%40ss%3Aword@example.com/file.torrent", taskUrl) +} + +func TestBuildDownloadOptionsNonHttpSrc(t *testing.T) { + a := assert.New(t) + + // Torrent source file: aria2 "out" and HTTP credentials must not apply. + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcFileUri: "cloudreve://my/file.torrent", + FileName: "renamed", + HTTPUsername: "user", + HTTPPassword: "pass", + }, types.DownloaderProviderAria2) + + opts, taskUrl := m.buildDownloadOptions(context.Background(), nil, "https://slave.example.com/entity/1") + _, hasOut := opts["out"] + a.False(hasOut) + _, hasUser := opts["http-user"] + a.False(hasUser) + a.Equal("https://slave.example.com/entity/1", taskUrl) + + // Magnet link: same for aria2. + m = newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "magnet:?xt=urn:btih:abc", + FileName: "renamed", + HTTPUsername: "user", + }, types.DownloaderProviderAria2) + opts, _ = m.buildDownloadOptions(context.Background(), nil, "magnet:?xt=urn:btih:abc") + _, hasOut = opts["out"] + a.False(hasOut) + _, hasUser = opts["http-user"] + a.False(hasUser) +} + +func TestBuildDownloadOptionsNoExtras(t *testing.T) { + a := assert.New(t) + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/file.zip", + }, types.DownloaderProviderAria2) + + base := map[string]interface{}{"k": "v"} + opts, taskUrl := m.buildDownloadOptions(context.Background(), base, "https://example.com/file.zip") + a.Equal("v", opts["k"]) + a.Equal("https://example.com/file.zip", taskUrl) +} + +func TestNewRemoteDownloadTaskSanitizesFileName(t *testing.T) { + a := assert.New(t) + tsk, err := NewRemoteDownloadTask(context.Background(), "https://example.com/f", "", "cloudreve://my/dst", &RemoteDownloadTaskOption{ + FileName: "../../etc/passwd", + }) + a.NoError(err) + + state := &RemoteDownloadTaskState{} + a.NoError(json.Unmarshal([]byte(tsk.(*RemoteDownloadTask).Task.PrivateState), state)) + a.Equal(".._.._etc_passwd", state.FileName) +} diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index 09c5f7e3..c1b75659 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -73,9 +73,12 @@ func init() { type ( DownloadWorkflowService struct { - Src []string `json:"src"` - SrcFile string `json:"src_file"` - Dst string `json:"dst" binding:"required"` + Src []string `json:"src"` + SrcFile string `json:"src_file"` + Dst string `json:"dst" binding:"required"` + 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"` } CreateDownloadParamCtx struct{} ) @@ -131,6 +134,20 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } } + // Custom file name only applies to single-source tasks; HTTP credentials + // only apply to plain HTTP(S) source URLs. + taskOpts := &workflows.RemoteDownloadTaskOption{ + HTTPUsername: service.Username, + HTTPPassword: service.Password, + } + if len(service.Src) <= 1 { + taskOpts.FileName = service.FileName + } + if service.SrcFile != "" { + taskOpts.HTTPUsername = "" + taskOpts.HTTPPassword = "" + } + // batch creating tasks ae := serializer.NewAggregateError() tasks := make([]queue.Task, 0, len(service.Src)) @@ -139,7 +156,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T continue } - t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst) + t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst, taskOpts) if err != nil { ae.Add(src, err) continue @@ -153,7 +170,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } if service.SrcFile != "" { - t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst) + t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst, taskOpts) if err != nil { ae.Add(service.SrcFile, err) }