From 252b702875cba0fa2d4428376ef2829cc4c997b9 Mon Sep 17 00:00:00 2001
From: Cat <86354789+MiCat-S@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:43:19 +0800
Subject: [PATCH] fix(site): keep redirect failures from aborting site requests
(#7227)
---
app/chain/site.py | 4 +-
app/foundation/url.py | 18 +++
app/startup/composition/site.py | 58 ++++++--
docs/architecture-overview.md | 2 +-
.../architecture/dependency-baseline.json | 6 +-
tests/test_site_chain_ports.py | 127 ++++++++++++++++++
tests/test_url_utils.py | 16 ++-
7 files changed, 213 insertions(+), 18 deletions(-)
diff --git a/app/chain/site.py b/app/chain/site.py
index 0fa10ba6c..39df46073 100644
--- a/app/chain/site.py
+++ b/app/chain/site.py
@@ -745,7 +745,7 @@ class SiteChain(InteractionChainMixin, ChainBase):
if res:
html_text = res.text
else:
- logger.error(f"获取站点页面失败:{url}")
+ logger.error(f"获取站点页面失败:{url_tools.url_origin_for_log(url)}")
return favicon_url, None
html = etree.HTML(html_text)
try:
@@ -760,7 +760,7 @@ class SiteChain(InteractionChainMixin, ChainBase):
) as res:
if res:
return favicon_url, base64.b64encode(res.content).decode()
- logger.error(f"获取站点图标失败:{favicon_url}")
+ logger.error(f"获取站点图标失败:{url_tools.url_origin_for_log(favicon_url)}")
finally:
if html is not None:
del html
diff --git a/app/foundation/url.py b/app/foundation/url.py
index 53b64886b..c279de32e 100644
--- a/app/foundation/url.py
+++ b/app/foundation/url.py
@@ -283,6 +283,24 @@ def mask_url_credentials(url: str) -> str:
)
+def url_origin_for_log(url: str) -> str:
+ """
+ 只保留协议、主机与端口,用于记录来自站点页面或重定向的地址。
+
+ 路径和查询参数可能携带签名、passkey 或会话令牌,且参数名无法穷举,因此一律省略;
+ userinfo 同样不保留。
+
+ :param url: 需要记录的地址
+ :return: 来源地址;地址无法解析时返回固定占位文本
+ """
+ try:
+ parts = urlparse(url)
+ port = f":{parts.port}" if parts.port else ""
+ except ValueError:
+ return "(地址无法解析)"
+ return f"{parts.scheme}://{parts.hostname or ''}{port}"
+
+
def parse_address(
address: str,
include_scheme: bool = True,
diff --git a/app/startup/composition/site.py b/app/startup/composition/site.py
index 87847edbd..ce2d9e2be 100644
--- a/app/startup/composition/site.py
+++ b/app/startup/composition/site.py
@@ -2,7 +2,7 @@
站点访问技术端口的宿主组合根。
"""
-from contextlib import contextmanager
+from contextlib import ExitStack, contextmanager
from typing import Any, Callable, Iterator, Mapping, Optional, cast
from urllib.parse import urljoin, urlsplit
@@ -43,7 +43,29 @@ from app.chain.site import (
configure_site_ports,
reset_site_ports,
)
-from app.foundation.url import UrlUtils
+from app.foundation.url import UrlUtils, url_origin_for_log
+from app.runtime.log import logger
+
+
+def _redirect_destination(url: str, response: Any) -> tuple[Optional[str], Optional[str]]:
+ """
+ 解析一跳重定向的目标地址,只接受不带 URL 凭据的 HTTP(S) 目标。
+
+ :param url: 当前跳的请求地址
+ :param response: 当前跳的重定向响应
+ :return: 目标地址与失败原因,二者恰有一个非空
+ """
+ location = response.headers.get("Location")
+ if not location:
+ return None, "站点请求重定向缺少目标地址"
+ try:
+ destination = urljoin(url, location)
+ target = urlsplit(destination)
+ except ValueError:
+ return None, "站点请求重定向目标无效"
+ if target.scheme not in ("http", "https") or not target.hostname or target.username or target.password:
+ return None, "站点请求重定向目标无效"
+ return destination, None
class _RssHttpAdapter:
@@ -214,6 +236,10 @@ class _SiteHttpAdapter:
"""
打开响应并在所有返回或异常分支后关闭底层连接。
+ GET 请求逐跳跟随重定向,跨域跳转清除 Cookie 与认证头。重定向缺少目标、目标无效、出现环路
+ 或超过跳数上限时只记录来源地址的告警并提供 None,与请求失败的语义一致,
+ 由调用方按无法打开站点处理,不能以异常中断 CookieCloud 同步等批量流程。
+
:param method: HTTP 请求方法
:param url: 完整请求地址
:param headers: 额外请求头
@@ -226,9 +252,11 @@ class _SiteHttpAdapter:
"""
request_headers = dict(headers or {})
seen = set()
+ failure = "站点请求重定向次数超过上限"
for _hop in range(9):
if url in seen:
- raise ValueError("站点请求重定向出现环路")
+ failure = "站点请求重定向出现环路"
+ break
seen.add(url)
request = RequestUtils(
headers=request_headers, cookies=cookie or "", ua=ua or "",
@@ -237,17 +265,22 @@ class _SiteHttpAdapter:
request_options: dict[str, Any] = {} if json_body is None else {"json": dict(json_body)}
if method.upper() == "GET":
request_options["allow_redirects"] = False
- with request.response_manager(method=method, url=url, **request_options) as response:
+ with ExitStack() as stack:
+ try:
+ response = stack.enter_context(
+ request.response_manager(method=method, url=url, **request_options)
+ )
+ except ValueError:
+ # 请求地址或响应 Location 畸形时 requests 会在打开响应时抛出,禁用自动跟随也会预解析 Location
+ failure = "站点请求地址或重定向目标无效"
+ break
if method.upper() != "GET" or response is None or response.status_code not in (301, 302, 303, 307, 308):
yield cast(Optional[SiteResponsePort], response)
return
- location = response.headers.get("Location")
- if not location:
- raise ValueError("站点请求重定向缺少目标地址")
- destination = urljoin(url, location)
- target = urlsplit(destination)
- if target.scheme not in ("http", "https") or not target.hostname or target.username or target.password:
- raise ValueError("站点请求重定向目标无效")
+ destination, invalid_reason = _redirect_destination(url, response)
+ if destination is None:
+ failure = invalid_reason or failure
+ break
if not UrlUtils.same_origin(url, destination):
cookie = None
request_headers = {
@@ -255,7 +288,8 @@ class _SiteHttpAdapter:
if key.lower() not in ("cookie", "authorization", "proxy-authorization", "referer")
}
url = destination
- raise ValueError("站点请求重定向次数超过上限")
+ logger.warning(f"{failure}:{url_origin_for_log(url)}")
+ yield None
class _SiteBrowserAdapter:
diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md
index b1ce2ac08..9ef854226 100644
--- a/docs/architecture-overview.md
+++ b/docs/architecture-overview.md
@@ -817,7 +817,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1142 |
-| 内部导入边 | 9,850 |
+| 内部导入边 | 9,852 |
| 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) |
diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json
index 5bba03cc2..79292e700 100644
--- a/tests/fixtures/architecture/dependency-baseline.json
+++ b/tests/fixtures/architecture/dependency-baseline.json
@@ -959,8 +959,8 @@
"runtime_only": true
}
},
- "edge_count": 9850,
- "edge_sha256": "4ee56369dee810a01662c3c2f63eb016d57c308836d1e6ca9254ca31f434dc6f",
+ "edge_count": 9852,
+ "edge_sha256": "281f9cb108557ce8bb78b1c4a7249fcc983d0b0b53b9691f0b6f72de79e7d61c",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -10232,6 +10232,8 @@
"app.startup.composition.site -> app.chain.site",
"app.startup.composition.site -> app.foundation",
"app.startup.composition.site -> app.foundation.url",
+ "app.startup.composition.site -> app.runtime",
+ "app.startup.composition.site -> app.runtime.log",
"app.startup.composition.subscription -> app.adapters",
"app.startup.composition.subscription -> app.adapters.external",
"app.startup.composition.subscription -> app.adapters.external.server",
diff --git a/tests/test_site_chain_ports.py b/tests/test_site_chain_ports.py
index eab900890..1b8e138dd 100644
--- a/tests/test_site_chain_ports.py
+++ b/tests/test_site_chain_ports.py
@@ -437,3 +437,130 @@ def test_site_chain_has_no_concrete_adapter_imports() -> None:
]
assert violations == []
+
+
+def _redirect(location: Optional[str]) -> SimpleNamespace:
+ """
+ 构造带可选目标地址的重定向响应。
+ """
+ return SimpleNamespace(status_code=302, headers={"Location": location} if location else {})
+
+
+def _scripted_request(monkeypatch, responses: list) -> list[dict]:
+ """
+ 用按序回放的 RequestUtils 替身替换组合根传输,并记录每一跳的构造参数和请求参数。
+ """
+ hops: list[dict] = []
+ queue = list(responses)
+
+ class ScriptedRequest:
+ """
+ 记录每一跳携带的 Cookie 与请求地址。
+ """
+
+ def __init__(self, **kwargs) -> None:
+ """
+ 保存 adapter 传入的 Cookie 等构造参数。
+ """
+ self.options = kwargs
+
+ @contextmanager
+ def response_manager(self, **kwargs):
+ """
+ 依次返回预设响应。
+ """
+ hops.append({**self.options, **kwargs})
+ item = queue.pop(0)
+ if isinstance(item, Exception):
+ # 模拟 requests 在打开响应时即抛出的异常,例如预解析畸形 Location
+ raise item
+ yield item
+
+ monkeypatch.setattr(site_composition, "RequestUtils", ScriptedRequest)
+ return hops
+
+
+@pytest.mark.parametrize("responses,reason", [
+ ([_redirect("/dl/1/auth?X-Amz-Signature=sig")] * 2, "站点请求重定向出现环路"),
+ ([_redirect(None)], "站点请求重定向缺少目标地址"),
+ ([_redirect("ftp://site.test/file")], "站点请求重定向目标无效"),
+ ([_redirect("https://user:pass@site.test/")], "站点请求重定向目标无效"),
+ ([_redirect("https://[bad/path")], "站点请求重定向目标无效"),
+ ([_redirect(f"/page{index}") for index in range(9)], "站点请求重定向次数超过上限"),
+ ([ValueError("Invalid IPv6 URL")], "站点请求地址或重定向目标无效"),
+ ([_redirect("/next"), ValueError("Invalid IPv6 URL")], "站点请求地址或重定向目标无效"),
+], ids=[
+ "loop", "missing-location", "invalid-scheme", "userinfo", "malformed", "too-many-hops",
+ "transport-rejects-location", "transport-rejects-next-hop",
+])
+def test_site_adapter_redirect_failures_yield_none(monkeypatch, responses, reason) -> None:
+ """
+ 重定向失败按端口约定提供 None 并告警,不抛出异常打断 CookieCloud 同步等批量流程;
+ 告警只含来源地址,路径与查询参数中的签名、passkey 等凭据都不写入日志。
+ """
+ hops = _scripted_request(monkeypatch, responses)
+ warnings = []
+ monkeypatch.setattr(site_composition.logger, "warning", warnings.append)
+
+ with site_composition._SiteHttpAdapter().open(
+ method="GET", url="https://site.test/index.php?passkey=secret", cookie="uid=1",
+ ) as response:
+ assert response is None
+
+ assert len(hops) == len(responses)
+ assert warnings == [f"{reason}:https://site.test"]
+
+
+def test_cookiecloud_sync_continues_after_redirect_loop(monkeypatch) -> None:
+ """
+ 单个新站点的重定向环路只记为该站点添加失败,同步继续处理后续站点。
+ """
+ hops = _scripted_request(monkeypatch, [_redirect("/")])
+ monkeypatch.setattr(site_composition.logger, "warning", lambda _message: None)
+ _configure(site_composition._SiteHttpAdapter())
+ chain = _chain()
+ chain.runtime_config.cookiecloud_blacklist = ""
+
+ result = chain._sync_cookiecloud_domain(
+ domain="loop.test",
+ cookie="uid=1",
+ indexer={"name": "环路站点", "domain": "https://loop.test/"},
+ site_info=None,
+ repository=SimpleNamespace(),
+ rsshelper=SimpleNamespace(),
+ )
+
+ # 跳回同一地址在第二跳发出前即判定为环路
+ assert result == (0, 0, 1, False)
+ assert [hop["url"] for hop in hops] == ["https://loop.test/"]
+
+
+def test_site_adapter_does_not_swallow_caller_errors(monkeypatch) -> None:
+ """
+ 调用方在响应上下文内抛出的异常必须原样传播,不能被当作重定向失败吞掉。
+ """
+ _scripted_request(monkeypatch, [_FakeResponse(200, text="ok")])
+ warnings = []
+ monkeypatch.setattr(site_composition.logger, "warning", warnings.append)
+
+ with pytest.raises(ValueError, match="调用方错误"):
+ with site_composition._SiteHttpAdapter().open(method="GET", url="https://site.test/") as response:
+ assert response is not None
+ raise ValueError("调用方错误")
+
+ assert warnings == []
+
+
+def test_favicon_failure_logs_only_site_origin(monkeypatch) -> None:
+ """
+ 图标或页面读取失败的日志只记录来源地址,页面提供的图标 URL 中的令牌不能写入日志。
+ """
+ _configure(_FakeHttpPort(_FakeResponse(200, text=''), None))
+ errors = []
+ monkeypatch.setattr(site_module.logger, "error", errors.append)
+
+ favicon_url, content = SiteChain._SiteChain__parse_favicon("https://site.test/?passkey=secret", "uid=1", "UA")
+
+ assert favicon_url == "https://site.test/icon.png?token=secret"
+ assert content is None
+ assert errors == ["获取站点图标失败:https://site.test"]
diff --git a/tests/test_url_utils.py b/tests/test_url_utils.py
index c7acd7ba8..a80f343df 100644
--- a/tests/test_url_utils.py
+++ b/tests/test_url_utils.py
@@ -4,7 +4,7 @@
import pytest
-from app.foundation.url import UrlUtils, mask_url_credentials, url_matches_trusted_hosts
+from app.foundation.url import UrlUtils, mask_url_credentials, url_matches_trusted_hosts, url_origin_for_log
@pytest.mark.parametrize(
@@ -128,3 +128,17 @@ def test_mask_url_credentials_applies_to_free_text_with_url():
assert "SECRET" not in masked
assert "passkey=" in masked
assert masked.startswith("Max retries exceeded with url: /torrentrss.php?")
+
+
+@pytest.mark.parametrize("url,expected", [
+ ("https://site.test/dl/1/auth?X-Amz-Signature=sig", "https://site.test"),
+ ("https://user:pass@site.test:8443/path?passkey=secret", "https://site.test:8443"),
+ ("http://site.test", "http://site.test"),
+ ("https://[bad/path", "(地址无法解析)"),
+ ("https://site.test:99999/", "(地址无法解析)"),
+])
+def test_url_origin_for_log_drops_path_query_and_userinfo(url, expected):
+ """
+ 日志地址只保留协议、主机与端口,路径、查询参数和 userinfo 中的凭据都不保留。
+ """
+ assert url_origin_for_log(url) == expected