From 1b9a6850ef4928faa6e09c328d16790be0399ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:31:33 +0200 Subject: [PATCH] feat(download): source typing + torrent file-count guard (#227) Distinguishes plain URLs from BitTorrent sources at task creation: src_file must name a .torrent, magnet links auto-pick a BT-capable provider (qBittorrent preferred, aria2 fallback), and ytdlp is rejected for torrent sources. validateFiles now caps selected files at 10k to block crafted-torrent entity floods. Generated with Devin --- ROADMAP.md | 1 + pkg/filemanager/workflows/remote_download.go | 11 +++++++ service/explorer/workflows.go | 31 ++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 715e9692..7dad41a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -225,6 +225,7 @@ Order = user-visible value first; each ships with backend + UI + tests. - [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales - [x] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `_i18n` JSON map by language tag (exact → bare primary subtag → wildcard `*` → base value); `SiteBasicLocalized` covers site name/title/description; consumed by site config, announcement endpoint, share-preview OG tags, index.html placeholders, WOPI breadcrumb, and email templates (recipient language); SKU gains `name_i18n`/`des_i18n` columns resolved per buyer language in the shop; admin gets a reusable `LocalizedFields` accordion (per-language inputs) wired into site name/description/announcement and SKU name/description; en+zh locales - [x] Weighted policy selection (upstream #2178 item 2) — `GroupSetting.WeightedPolicies` spreads uploads across the group's allowed policies by free capacity: `pickByFreeCapacity` picks the member with the most remaining `MaxTotalSize` headroom that fits the file (uncapped/suspended members not weighed); explicit directory/user preferences still win; size-aware `getPreferredPolicyForSize` wired into both upload paths; admin group editor gains a switch, en+zh locales +- [x] Download source typing + torrent bomb guard (upstream #2178 离线下载) — `CreateDownloadTask` distinguishes plain URLs from BitTorrent sources: `src_file` must name a `.torrent`, `magnet:` links auto-pick a BT-capable provider (qBittorrent preferred, aria2 fallback) and fail fast when only non-BT nodes exist; explicit `provider=ytdlp` with a torrent source is rejected; `validateFiles` caps selected files per task at `maxDownloadFiles` (10k) with `queue.CriticalErr` so crafted torrents cannot flood the entity table ## 6. Phase D — desktop, all platforms diff --git a/pkg/filemanager/workflows/remote_download.go b/pkg/filemanager/workflows/remote_download.go index d2245105..b53115cd 100644 --- a/pkg/filemanager/workflows/remote_download.go +++ b/pkg/filemanager/workflows/remote_download.go @@ -78,6 +78,11 @@ const ( GetTaskStatusMaxTries = 5 + // maxDownloadFiles bounds the number of files a single download task + // may import, guarding against crafted torrents that declare absurd + // file counts. + maxDownloadFiles = 10_000 + SummaryKeyDownloadStatus = "download" SummaryKeySrcStr = "src_str" @@ -761,6 +766,12 @@ func (m *RemoteDownloadTask) validateFiles(ctx context.Context, dep dependency.D return fmt.Errorf("no selected file found in download task") } + // Bound the entity count a single task can create — a crafted torrent + // declaring hundreds of thousands of files is a database bomb. + if len(selectedFiles) > maxDownloadFiles { + return fmt.Errorf("download task selects %d files, exceeds the %d limit", len(selectedFiles), maxDownloadFiles) + } + validateArgs := lo.Map(selectedFiles, func(f downloader.TaskFile, _ int) fs.PreValidateFile { return fs.PreValidateFile{ Name: sanitizeFileName(f.Name), diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index f1f1f545..88b011c2 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -200,6 +200,37 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } } + // Distinguish BitTorrent sources (magnet links, .torrent files) from + // plain URLs: they require a BT-capable provider, and src_file must + // actually name a .torrent. + if service.SrcFile != "" && !strings.HasSuffix(strings.ToLower(service.SrcFile), ".torrent") { + return nil, serializer.NewError(serializer.CodeParamErr, "Source file must be a .torrent", nil) + } + torrentSrc := service.SrcFile != "" + for _, s := range service.Src { + if strings.HasPrefix(strings.ToLower(s), "magnet:") { + torrentSrc = true + break + } + } + if torrentSrc { + if service.Provider == string(types.DownloaderProviderYtDlp) { + return nil, serializer.NewError(serializer.CodeParamErr, "Downloader does not support torrent sources", nil) + } + if service.Provider == "" { + // Auto-pick a BitTorrent-capable provider, preferring qBittorrent. + for _, p := range []types.DownloaderProvider{types.DownloaderProviderQBittorrent, types.DownloaderProviderAria2} { + if providerNodeAvailable(c, dep, string(p)) != 0 { + service.Provider = string(p) + break + } + } + if service.Provider == "" { + return nil, serializer.NewError(serializer.CodeParamErr, "No BitTorrent-capable downloader node available", nil) + } + } + } + // Validate a requested downloader provider against what the node pool // actually offers. if service.Provider != "" && providerNodeAvailable(c, dep, service.Provider) == 0 {