diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index 97730e5ca..2b517226a 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -63,6 +63,21 @@ class TorrentResponsePort(Protocol): reason: str +def _torrent_magnet_link(response: Optional[TorrentResponsePort], url: str) -> Optional[str]: + """ + 从最终跳转地址或响应正文提取磁力链接,GET 和首次下载确认共用该判断。 + + :param response: 最终 HTTP 响应;直接跳到磁力链接时为空 + :param url: 最终下载地址或磁力链接 + :return: 磁力链接;响应仍需按种子或错误处理时返回 None + """ + if url.startswith("magnet:"): + return url + if response is not None and response.status_code == 200 and response.content.startswith(b"magnet:"): + return response.text + return None + + def torrent_rate_limit_error(response: Optional[TorrentResponsePort]) -> Optional[str]: """ 在种子解析前提取 JSON 或 HTML 中明确的限流提示,保留站点原文。 @@ -366,9 +381,14 @@ class TorrentHelper: proxies: Optional[dict[str, Any]], retry: bool, site: Mapping[str, Any], + method: str = "GET", + data: Optional[dict[str, Any]] = None, ) -> tuple[Optional[TorrentResponsePort], str, Optional[str]]: """ - 有界处理GET跳转,跨来源后永久移除原站点凭据和Referer。 + 有界处理种子 GET 和首次确认 POST 的跳转,跨来源后永久移除凭据和 Referer。 + + POST 收到 301/302/303 后改用 GET;307/308 仅在同来源保留表单。 + 确认 POST 不自动重试,后续 GET 沿用原下载地址的重试策略。 :param http_port: 已装配HTTP端口 :param url: 当前下载地址 @@ -378,19 +398,27 @@ class TorrentHelper: :param proxies: 本次请求代理 :param retry: 是否对普通GET执行既有瞬时失败重试 :param site: 原始资源所属站点的流控配置,跳转到其他主机后仍共用该额度 + :param method: 起始请求方法,只使用 GET 或 POST + :param data: 首次确认表单;改用 GET 后永久移除 :return: 最终响应、最终地址及允许在该地址使用的Cookie - :raises ValueError: 跳转缺少地址、目标非法、重复或超过上限 + :raises ValueError: 跳转缺少地址、目标非法、重复、超过上限或要求跨来源提交表单 """ seen = set() for _hop in range(9): - if url in seen: + if (method, url) in seen: raise ValueError("种子下载重定向出现环路") - seen.add(url) - response = self._request_torrent_get( - http_port, retry and not torrent_rules.is_expiring_download_url(url), site, - url=url, cookie=cookie, ua=ua, referer=referer, - proxies=proxies, allow_redirects=False, - ) + seen.add((method, url)) + if method == "POST": + response = self._request_torrent( + http_port, site, method=method, url=url, cookie=cookie, ua=ua, + referer=referer, proxies=proxies, data=data, allow_redirects=False, + ) + else: + response = self._request_torrent_get( + http_port, retry and not torrent_rules.is_expiring_download_url(url), site, + url=url, cookie=cookie, ua=ua, referer=referer, + proxies=proxies, allow_redirects=False, + ) if response is None or response.status_code not in (301, 302, 303, 307, 308): return response, url, cookie location = response.headers.get("Location") or response.headers.get("location") @@ -405,7 +433,11 @@ class TorrentHelper: target = urlsplit(destination) if target.scheme not in ("http", "https") or not target.hostname or target.username or target.password: raise ValueError("种子下载重定向目标无效") + if response.status_code in (301, 302, 303): + method, data = "GET", None if not UrlUtils.same_origin(url, destination): + if method == "POST": + raise ValueError("首次下载确认重定向不能向其他来源提交表单") cookie, referer = None, None url = destination raise ValueError("种子下载重定向次数超过上限") @@ -487,15 +519,13 @@ class TorrentHelper: ) except (ValueError, RateLimitExceededException) as err: return cache_path, None, "", [], str(err) - if url.startswith("magnet:"): - return None, url, "", [], "获取到磁力链接" + if magnet := _torrent_magnet_link(req, url): + return cache_path if req is not None else None, magnet, "", [], "获取到磁力链接" if rate_limit_error := torrent_rate_limit_error(req): return cache_path, None, "", [], rate_limit_error if req is not None and req.status_code == 200: if not req.content: return cache_path, None, "", [], "未下载到种子数据" - if req.content.startswith(b"magnet:"): - return cache_path, req.text, "", [], "获取到磁力链接" # 优先按原始二进制解析,避免合法 torrent 被误判为首次下载 HTML。 folder_name, file_list = _cache_valid_torrent_content( self.get_fileinfo_from_torrent_content, cache_backend, cache_path, req.content @@ -511,12 +541,14 @@ class TorrentHelper: return cache_path, None, "", [], "种子数据有误,请确认链接是否正确,如为PT站点则需手工在站点下载一次种子" action, data = confirmation try: - req = self._request_torrent( - http_port, site, method="POST", url=action, cookie=cookie, ua=ua, - referer=url, proxies=config.proxy if proxy else None, data=data, allow_redirects=False, + req, url, cookie = self._request_torrent_redirects( + http_port, site=site, method="POST", url=action, cookie=cookie, ua=ua, + referer=url, proxies=config.proxy if proxy else None, data=data, retry=retry, ) - except RateLimitExceededException as err: + except (ValueError, RateLimitExceededException) as err: return cache_path, None, "", [], str(err) + if magnet := _torrent_magnet_link(req, url): + return cache_path if req is not None else None, magnet, "", [], "获取到磁力链接" if rate_limit_error := torrent_rate_limit_error(req): return cache_path, None, "", [], rate_limit_error if req is None: @@ -526,14 +558,13 @@ class TorrentHelper: if not req.content: return cache_path, None, "", [], "未下载到种子数据" logger.info("已提交站点首次种子下载确认,正在校验种子内容") - # 首次下载提示页面完成后,重新按原始二进制解析 POST 响应。 + # 首次下载提示页面完成后,校验确认请求及其后续跳转取得的内容。 assert req is not None - if req.content: - folder_name, file_list = _cache_valid_torrent_content( - self.get_fileinfo_from_torrent_content, cache_backend, cache_path, req.content - ) - if file_list: - return cache_path, req.content, folder_name, file_list, "" + folder_name, file_list = _cache_valid_torrent_content( + self.get_fileinfo_from_torrent_content, cache_backend, cache_path, req.content + ) + if file_list: + return cache_path, req.content, folder_name, file_list, "" # 解析失败时终止,避免把 HTML 继续下沉到下载器或缓存。 if cache_invalid: self.add_invalid(original_url) diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json index 48b72c86a..7ba81924e 100644 --- a/tests/fixtures/architecture/complexity-baseline.json +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -37,7 +37,7 @@ "app/application/rss.py:RssHelper._parse_impl": 37, "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 45, "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl.__page_handler": 43, - "app/application/torrent/download.py:TorrentHelper.download_torrent": 23, + "app/application/torrent/download.py:TorrentHelper.download_torrent": 22, "app/application/torrent/download.py:TorrentHelper.match_torrent": 16, "app/application/transfer/models.py:TransferPlanCheckpoint.__post_init__": 18, "app/chain/download/existence.py:DownloadExistenceOwner.get_no_exists_info": 23, diff --git a/tests/test_site_request_throttle.py b/tests/test_site_request_throttle.py index fbc1abe57..43f7e6a1a 100644 --- a/tests/test_site_request_throttle.py +++ b/tests/test_site_request_throttle.py @@ -111,7 +111,7 @@ def test_search_ticket_and_content_share_interval_and_window(boundary): boundary.port.request.assert_called_once() -@pytest.mark.parametrize("first", ["redirect", "retry", "confirm"]) +@pytest.mark.parametrize("first", ["redirect", "retry", "confirm", "confirm-redirect"]) def test_each_actual_http_request_consumes_one_slot(boundary, first): """ 跳转、普通重试及首次确认 POST 均需重新申请同一站点请求额度。 @@ -119,9 +119,14 @@ def test_each_actual_http_request_consumes_one_slot(boundary, first): first_response = _response(status=302 if first == "redirect" else 503) if first == "redirect": first_response.headers["Location"] = "https://cdn.example/file.torrent" - if first == "confirm": + if first in ("confirm", "confirm-redirect"): first_response = _response('下载种子文件
'.encode()) - responses = iter([first_response, _response()]) + response_sequence = [first_response, _response()] + if first == "confirm-redirect": + redirect = _response(status=302) + redirect.headers["Location"] = "https://cdn.example/file.torrent" + response_sequence.insert(1, redirect) + responses = iter(response_sequence) starts = [] def request(**_kwargs): @@ -134,9 +139,12 @@ def test_each_actual_http_request_consumes_one_slot(boundary, first): boundary.port.request.side_effect = request assert download.TorrentHelper().download_torrent(URL, site_id=42)[1] == CONTENT - assert starts == [100, 110] + assert starts == ([100, 110, 160] if first == "confirm-redirect" else [100, 110]) methods = [call.kwargs["method"] for call in boundary.port.request.call_args_list] - assert methods == ["GET", "POST" if first == "confirm" else "GET"] + assert methods == { + "redirect": ["GET", "GET"], "retry": ["GET", "GET"], + "confirm": ["GET", "POST"], "confirm-redirect": ["GET", "POST", "GET"], + }[first] def test_remote_cooldown_blocks_other_resources_and_search_until_expiry(boundary): diff --git a/tests/test_torrent_download_retry.py b/tests/test_torrent_download_retry.py index 2fc21267c..ef12f27a8 100644 --- a/tests/test_torrent_download_retry.py +++ b/tests/test_torrent_download_retry.py @@ -174,6 +174,249 @@ def test_first_download_confirmation_empty_response_is_transient(boundary): helper.add_invalid.assert_not_called() +@pytest.mark.parametrize("status", [301, 302, 303]) +@pytest.mark.parametrize("location", ["/file.torrent", "?id=39&confirmed=1", URL]) +def test_confirmation_redirect_switches_to_get_and_caches_original_url(boundary, status, location): + """ + 首次确认后的跳转改用 GET,兼容相对地址及原地址并保留原始缓存键。 + """ + helper, port, cache, sleep = boundary + page = '下载种子文件'.encode() + redirect = _response(status) + redirect.headers["Location"] = location + redirect.close = Mock() + port.request.side_effect = [_response(content=page), redirect, _response()] + + result = helper.download_torrent(URL, cookie="session=test", ua="TestAgent") + + assert result[1:5] == (CONTENT, "", ["Series.mkv"], "") + calls = [call.kwargs for call in port.request.call_args_list] + assert [call["method"] for call in calls] == ["GET", "POST", "GET"] + assert calls[1]["data"] == {"confirm": "yes"} + assert calls[2]["url"] == download.urljoin(URL, location) + assert calls[2].get("data") is None + assert [call["cookie"] for call in calls] == ["session=test"] * 3 + assert calls[2]["referer"] == URL + assert all(call["ua"] == "TestAgent" and call["allow_redirects"] is False for call in calls) + cache.set.assert_called_once_with(result[0].as_posix(), CONTENT, region="torrents") + assert result[0] == Path(download.HashUtils.md5(URL)).with_suffix(".torrent") + redirect.close.assert_called_once() + helper.add_invalid.assert_not_called() + sleep.assert_not_called() + + +@pytest.mark.parametrize("status", [307, 308]) +@pytest.mark.parametrize("final_status", [200, 503]) +def test_confirmation_redirect_preserves_post_without_retry(boundary, status, final_status): + """ + 307/308 在同来源保留确认表单,目标瞬时失败也不能重试 POST。 + """ + helper, port, cache, sleep = boundary + page = '下载种子文件'.encode() + redirect = _response(status) + redirect.headers["Location"] = "/confirmed.php?id=39" + redirect.close = Mock() + port.request.side_effect = [_response(content=page), redirect, _response(final_status)] + + result = helper.download_torrent(URL, cookie="session=test") + + assert result[1] == (CONTENT if final_status == 200 else None) + assert result[4] == ("" if final_status == 200 else "下载种子出错,状态码:503") + calls = [call.kwargs for call in port.request.call_args_list] + assert [call["method"] for call in calls] == ["GET", "POST", "POST"] + assert calls[2]["url"] == "https://tracker.example/confirmed.php?id=39" + assert calls[1]["data"] == calls[2]["data"] == {"token": "secret"} + assert calls[2]["cookie"] == "session=test" + assert calls[2]["allow_redirects"] is False + assert cache.set.call_count == (1 if final_status == 200 else 0) + redirect.close.assert_called_once() + helper.add_invalid.assert_not_called() + sleep.assert_not_called() + + +@pytest.mark.parametrize("status", [307, 308]) +def test_confirmation_redirect_refuses_cross_origin_post(boundary, status): + """ + 需要保留 POST 的跨来源跳转必须终止,不能外发 Cookie 或隐藏表单凭据。 + """ + helper, port, cache, sleep = boundary + page = '下载种子文件'.encode() + redirect = _response(status) + redirect.headers["Location"] = "https://other.example/file.torrent" + redirect.close = Mock() + port.request.side_effect = [_response(content=page), redirect] + + result = helper.download_torrent(URL, cookie="session=test") + + assert result[1] is None + assert result[4] == "首次下载确认重定向不能向其他来源提交表单" + assert port.request.call_count == 2 + redirect.close.assert_called_once() + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + sleep.assert_not_called() + + +def test_confirmation_redirect_drops_credentials_permanently_after_cross_origin(boundary): + """ + 确认后跨站改用 GET,再回原站也不恢复 Cookie、Referer 和表单字段。 + """ + helper, port, _, _ = boundary + page = '下载种子文件'.encode() + redirects = [_response(302), _response(307)] + redirects[0].headers["Location"] = "https://cdn.example/file.torrent" + redirects[1].headers["Location"] = "https://tracker.example/final.torrent" + for response in redirects: + response.close = Mock() + port.request.side_effect = [_response(content=page), *redirects, _response()] + + assert helper.download_torrent(URL, cookie="session=test", referer="https://tracker.example/private")[1] == CONTENT + + calls = [call.kwargs for call in port.request.call_args_list] + assert [call["method"] for call in calls] == ["GET", "POST", "GET", "GET"] + assert [call["cookie"] for call in calls] == ["session=test", "session=test", None, None] + assert [call["referer"] for call in calls] == ["https://tracker.example/private", URL, None, None] + assert all(call.get("data") is None for call in calls[2:]) + for response in redirects: + response.close.assert_called_once() + + +@pytest.mark.parametrize("location, error", [ + (None, "种子下载重定向缺少目标地址"), + ("file:///tmp/private.torrent", "种子下载重定向目标无效"), + ("https://user:secret@other.example/file.torrent", "种子下载重定向目标无效"), +]) +def test_confirmation_redirect_rejects_invalid_target(boundary, location, error): + """ + 确认后缺失或非法跳转目标时返回明确错误,不能发起额外请求或缓存无效地址。 + """ + helper, port, cache, _ = boundary + page = '下载种子文件'.encode() + redirect = _response(302) + if location is not None: + redirect.headers["Location"] = location + redirect.close = Mock() + port.request.side_effect = [_response(content=page), redirect] + + assert helper.download_torrent(URL)[4] == error + assert port.request.call_count == 2 + redirect.close.assert_called_once() + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + + +@pytest.mark.parametrize("status", [302, 307]) +def test_confirmation_redirect_loop_is_bounded(boundary, status): + """ + 按方法与地址识别确认跳转环路,允许 POST 转 GET,但不循环重发确认。 + """ + helper, port, cache, _ = boundary + page = '下载种子文件'.encode() + redirect = _response(status) + redirect.headers["Location"] = URL + redirect.close = Mock() + port.request.side_effect = [_response(content=page), redirect, redirect] + + assert helper.download_torrent(URL)[4] == "种子下载重定向出现环路" + assert port.request.call_count == (3 if status == 302 else 2) + assert [call.kwargs["method"] for call in port.request.call_args_list].count("POST") == 1 + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + + +def test_confirmation_redirect_hop_limit_is_shared_by_post_and_get(boundary): + """ + 确认 POST 和后续 GET 共用有限跳转预算,不能无限追逐不同地址。 + """ + helper, port, cache, _ = boundary + page = '下载种子文件'.encode() + redirects = [_response(302) for _ in range(9)] + for index, response in enumerate(redirects): + response.headers["Location"] = f"/file.torrent?hop={index}" + response.close = Mock() + port.request.side_effect = [_response(content=page), *redirects] + + assert helper.download_torrent(URL)[4] == "种子下载重定向次数超过上限" + assert port.request.call_count == 10 + for response in redirects: + response.close.assert_called_once() + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + + +@pytest.mark.parametrize("in_body", [False, True]) +def test_confirmation_redirect_can_return_magnet(boundary, in_body): + """ + 确认后跳转或落地正文给出磁力链接时直接返回,不缓存为损坏种子。 + """ + helper, port, cache, _ = boundary + page = '下载种子文件'.encode() + magnet = "magnet:?xt=urn:btih:abc" + redirect = _response(302) + redirect.headers["Location"] = "/file.torrent" if in_body else magnet + redirect.close = Mock() + responses = [_response(content=page), redirect] + if in_body: + responses.append(_response(content=magnet.encode())) + port.request.side_effect = responses + + assert helper.download_torrent(URL)[1] == magnet + assert port.request.call_count == (3 if in_body else 2) + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + + +def test_confirmation_redirect_preserves_rate_limit(boundary): + """ + 确认后的落地频控保留站点原因,不触发普通 GET 重试或失效缓存。 + """ + helper, port, cache, sleep = boundary + page = '下载种子文件'.encode() + redirect = _response(302) + redirect.headers["Location"] = "/file.torrent" + redirect.close = Mock() + message = "Request frequency limit, please try in 600 seconds." + port.request.side_effect = [_response(content=page), redirect, _response(403, f"{message}".encode())] + + assert helper.download_torrent(URL)[4] == f"站点限流:{message}" + assert port.request.call_count == 3 + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + sleep.assert_not_called() + + +@pytest.mark.parametrize("link_type, attempts", [("ordinary", 3), ("signed", 1), ("indirect", 1), ("signed-target", 1)]) +def test_confirmation_redirect_get_keeps_original_retry_policy(boundary, link_type, attempts): + """ + 确认后的普通 GET 可退避,签名和换票链路仍只尝试一次且从不重放确认。 + """ + helper, port, cache, sleep = boundary + page = '下载种子文件'.encode() + url = URL + "&t=1&sign=old" if link_type == "signed" else URL + redirect = _response(302) + redirect.headers["Location"] = "/file.torrent?t=1&sign=new" if link_type == "signed-target" else "/file.torrent" + redirect.close = Mock() + port.request.side_effect = [_response(content=page), redirect, *[_response(503) for _ in range(attempts)]] + + assert helper.download_torrent(url, cache_invalid=link_type != "indirect")[4] == "下载种子出错,状态码:503" + assert [call.kwargs["method"] for call in port.request.call_args_list] == ["GET", "POST", *["GET"] * attempts] + assert sleep.call_count == attempts - 1 + cache.set.assert_not_called() + helper.add_invalid.assert_not_called() + + +def test_magnet_body_on_error_does_not_bypass_status_check(boundary): + """ + 非成功响应即使正文看似磁力链接也必须保留 HTTP 错误。 + """ + helper, port, cache, _ = boundary + port.request.return_value = _response(401, b"magnet:?xt=urn:btih:abc") + + assert helper.download_torrent(URL)[4] == "下载种子出错,状态码:401" + port.request.assert_called_once() + cache.set.assert_not_called() + + @pytest.mark.parametrize("form", [ '', "",