Merge pull request #7260 from jxxghp/codex/issue-7258-confirm-redirect

fix(torrent): 修复首次下载确认后的重定向
This commit is contained in:
jxxghp
2026-10-31 22:37:29 +08:00
committed by GitHub
4 changed files with 312 additions and 30 deletions

View File

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

View File

@@ -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,

View File

@@ -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('下载种子文件<form method="post"><input name="confirm" value="yes"></form>'.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):

View File

@@ -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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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 = '下载种子文件<form action="?"><input name="token" value="secret"></form>'.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 = '下载种子文件<form action="?"><input name="token" value="secret"></form>'.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 = '下载种子文件<form action="?"><input name="token" value="secret"></form>'.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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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"<html>{message}</html>".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 = '下载种子文件<form action="?"><input name="confirm" value="yes"></form>'.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", [
'<form action="?"><input value="yes" name="confirm"></form>',
"<form method='POST' action='?'><input value='a&amp;b' name='token'></form>",