fix(image): cache site allowlist and include custom image hosts

This commit is contained in:
jxxghp
2026-09-30 01:09:32 +08:00
parent 8c46c9bee8
commit b4175a4f88
13 changed files with 318 additions and 39 deletions

View File

@@ -43,6 +43,7 @@ from app.application.messaging.message import MessageHelper
from app.application.network import get_configured_network_test_service
from app.application.rules import RuleHelper
from app.application.scheduling import get_scheduler
from app.application.security.image import normalize_image_domain, site_image_domains
from app.application.security.url import SecurityUtils
from app.application.site.query import get_configured_site_query_service
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
@@ -109,32 +110,20 @@ _PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
async def _get_image_proxy_allowed_domains() -> set[str]:
"""合并静态白名单、已配置站点和 Bangumi 图片主机,仍保留 DNS 安全校验。"""
"""合并站点快照与当前图片代理配置,仍逐请求执行 DNS 安全校验。"""
runtime_settings = get_runtime_settings()
allowed_domains = set(runtime_settings.get("SECURITY_IMAGE_DOMAINS", []))
# 停用站点仍需展示图标;每次读取配置,新增、改址和删除立即生效。
for site in await get_configured_site_query_service().list():
for address in (site.domain, site.url):
if not address:
continue
candidate = address if "://" in address else f"https://{address}"
try:
parsed = urlsplit(candidate)
except ValueError:
continue
if parsed.scheme in {"http", "https"} and parsed.netloc and not parsed.username:
# 保留协议,避免白名单解析器把 host:port 的主机误认成 scheme。
allowed_domains.add(f"{parsed.scheme}://{parsed.netloc.lower()}")
if not runtime_settings.get("BANGUMI_PROXY_ENABLE"):
return allowed_domains
image_proxy = str(runtime_settings.get("BANGUMI_IMAGE_DOMAIN") or "").strip()
if not image_proxy:
return allowed_domains
candidate = image_proxy if "://" in image_proxy else f"https://{image_proxy}"
parsed = urlsplit(candidate)
if parsed.scheme in {"http", "https"} and parsed.netloc:
allowed_domains.add(parsed.netloc)
allowed_domains.update(await site_image_domains.get(get_configured_site_query_service()))
image_keys = [
"TMDB_IMAGE_DOMAIN", "MUSIC_COVER_PROXY",
"WALLPAPER_IMAGE_URL", "CUSTOMIZE_WALLPAPER_API_URL",
]
if runtime_settings.get("BANGUMI_PROXY_ENABLE"):
image_keys.append("BANGUMI_IMAGE_DOMAIN")
for key in image_keys:
domain = normalize_image_domain(runtime_settings.get(key))
if domain:
allowed_domains.add(domain)
return allowed_domains

View File

@@ -0,0 +1,84 @@
"""图片域名归一化与站点白名单快照,不缓存 DNS 安全判定。"""
import asyncio
import threading
from time import monotonic
from typing import Optional
from urllib.parse import urlsplit
from weakref import WeakValueDictionary
from app.application.site.query import SiteQueryService
def normalize_image_domain(address: Optional[str]) -> Optional[str]:
"""提取 HTTP(S) 主机及端口,保留协议以兼容 URL 白名单解析。"""
address = (address or "").strip()
if not address:
return None
try:
parsed = urlsplit(address if "://" in address else f"https://{address}")
if parsed.scheme in {"http", "https"} and parsed.hostname and parsed.username is None:
return f"{parsed.scheme}://{parsed.netloc.lower()}"
except ValueError:
return None
return None
class SiteImageDomainCache:
"""缓存站点图片域名,同 loop 合并查询,写入失效后禁止旧查询回填。"""
def __init__(self, ttl: float = 60) -> None:
"""设置短期兜底有效期;锁只保护内存,不跨 await 持有线程锁。"""
self._ttl = ttl
self._lock = threading.Lock()
self._inflight: WeakValueDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = WeakValueDictionary()
self._generation = 0
self._source: Optional[SiteQueryService] = None
self._domains: frozenset[str] = frozenset()
self._expires = 0.0
def invalidate(self) -> None:
"""提交后立即撤销旧域名;递增代际防止在途查询恢复已删除的授权。"""
with self._lock:
self._generation += 1
self._expires = 0.0
self._source = None
self._domains = frozenset()
async def get(self, source: SiteQueryService) -> frozenset[str]:
"""返回不可变快照,命中时不查库;配置服务更换后自动重建。"""
loop = asyncio.get_running_loop()
with self._lock:
if self._source is source and monotonic() < self._expires:
return self._domains
inflight = self._inflight.get(loop)
if inflight is None:
inflight = asyncio.Lock()
self._inflight[loop] = inflight
async with inflight:
return await self._refresh(source)
async def _refresh(self, source: SiteQueryService) -> frozenset[str]:
"""锁内复查并加载,失效与查询交错时丢弃旧结果后重新读取。"""
while True:
with self._lock:
if self._source is source and monotonic() < self._expires:
return self._domains
generation = self._generation
sites = await source.list()
domains = frozenset(
domain
for site in sites
for address in (site.domain, site.url)
if (domain := normalize_image_domain(address))
)
with self._lock:
if generation != self._generation:
continue
self._source = source
self._domains = domains
self._expires = monotonic() + self._ttl
return domains
site_image_domains = SiteImageDomainCache()

View File

@@ -7,6 +7,7 @@ from typing import Optional, TypeAlias
from app.application.configuration import SystemConfigStagingPort
from app.application.outbox import SyncUnitOfWork
from app.application.security.image import site_image_domains
from app.application.site.contract import (
SiteMutation,
SitePriorityMutation,
@@ -252,3 +253,4 @@ class SiteMutationCommand:
except Exception:
await self._unit_of_work.rollback()
raise
site_image_domains.invalidate()

View File

@@ -10,6 +10,7 @@ from typing import Optional, TypeVar, cast
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.application.security.image import site_image_domains
from app.application.site.contract import (
SiteIconSnapshot,
SiteMutation,
@@ -424,7 +425,9 @@ class TransactionalSiteRepository:
def add(self, mutation: SiteMutation) -> SiteWriteResult:
"""在独立同步事务中新增站点。"""
return self._write(lambda repository: SiteWriteResult(*repository.add(**mutation.to_payload())))
result = self._write(lambda repository: SiteWriteResult(*repository.add(**mutation.to_payload())))
site_image_domains.invalidate()
return result
def update(
self,
@@ -432,13 +435,15 @@ class TransactionalSiteRepository:
mutation: SiteMutation,
) -> Optional[SiteSnapshot]:
"""在独立同步事务中更新并返回站点快照。"""
return self._write(
result = self._write(
lambda repository: (
_project_site(record)
if (record := repository.update(site_id, mutation.to_payload())) is not None
else None
)
)
site_image_domains.invalidate()
return result
async def async_update(
self,
@@ -455,7 +460,9 @@ class TransactionalSiteRepository:
)
return _project_site(record) if record is not None else None
return await self._async_write(operation)
result = await self._async_write(operation)
site_image_domains.invalidate()
return result
def update_cookie(self, domain: str, cookies: str) -> SiteWriteResult:
"""在独立同步事务中更新站点 Cookie。"""

View File

@@ -762,8 +762,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1052 |
| 内部导入边 | 8,928 |
| Python 模块 | 1053 |
| 内部导入边 | 8,937 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment) |
@@ -773,7 +773,7 @@ flowchart LR
| Model/Oper 自动事务与自建 Session | 0 |
| 组合根外 `SystemConfigOper()` | 0 |
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;宿主依赖边现为 8,928,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。当前 `app.startup.lifecycle` 为 572、`app.factory` 为 584、`app.main` 为 586。性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;宿主依赖边现为 8,937,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。当前 `app.startup.lifecycle` 为 573、`app.factory` 为 585、`app.main` 为 587。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次

View File

@@ -23,7 +23,12 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
图片 REST 接口 `/api/v1/system/img/{proxy}` 和 `/api/v1/system/cache/image` 自动将已配置
站点的主域名及访问地址加入图片域名白名单(含停用站点,便于展示图标),无需手动配置
`SECURITY_IMAGE_DOMAINS`。站点新增、改址或删除后按最新配置生效;仍执行 DNS/私网校验,
`SECURITY_IMAGE_DOMAINS`。站点域名快照在内存中复用,同一事件循环的并发加载合并为一次查询;
站点新增、改址或删除提交后立即失效,另有 60 秒过期兜底。`TMDB_IMAGE_DOMAIN`、
`MUSIC_COVER_PROXY`、`WALLPAPER_IMAGE_URL`、`CUSTOMIZE_WALLPAPER_API_URL` 和启用时的
`BANGUMI_IMAGE_DOMAIN` 的 HTTP(S) 主机也自动放行,配置变更即时生效。壁纸 API 返回的其他
域名不自动扩展授权;必要时加入 `SECURITY_IMAGE_DOMAINS`。
域名放行后仍执行 DNS/私网校验,
Fake-IP 等非公网解析地址需要通过 `IMAGE_PROXY_ALLOWED_PRIVATE_RANGES` 明确允许。
## 2. 标准 MCP 协议 (JSON-RPC 2.0)

View File

@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 1052 / 8,928 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 |
| 宿主 Python 模块 / 内部依赖边 | 1053 / 8,937 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |

View File

@@ -1097,8 +1097,8 @@
"runtime_only": true
}
},
"edge_count": 8929,
"edge_sha256": "568144c98e697e2e7f27f672c1a65afb3a947631957df79786bf700a4abdeeae",
"edge_count": 8937,
"edge_sha256": "971c665cd7d8ca534b7a6f09ce61d0c9e7cf4ba00b3340dd60840c1fe924af8b",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -3056,6 +3056,7 @@
"app.api.endpoints.system -> app.application.rules",
"app.api.endpoints.system -> app.application.scheduling",
"app.api.endpoints.system -> app.application.security",
"app.api.endpoints.system -> app.application.security.image",
"app.api.endpoints.system -> app.application.security.url",
"app.api.endpoints.system -> app.application.site",
"app.api.endpoints.system -> app.application.site.query",
@@ -3908,6 +3909,9 @@
"app.application.security.cookie -> app.domain.site",
"app.application.security.cookie -> app.runtime",
"app.application.security.cookie -> app.runtime.log",
"app.application.security.image -> app.application",
"app.application.security.image -> app.application.site",
"app.application.security.image -> app.application.site.query",
"app.application.security.passkey -> app.application",
"app.application.security.passkey -> app.application.configuration",
"app.application.security.passkey -> app.runtime",
@@ -3983,6 +3987,8 @@
"app.application.site.mutation -> app.application",
"app.application.site.mutation -> app.application.configuration",
"app.application.site.mutation -> app.application.outbox",
"app.application.site.mutation -> app.application.security",
"app.application.site.mutation -> app.application.security.image",
"app.application.site.mutation -> app.application.site",
"app.application.site.mutation -> app.application.site.contract",
"app.application.site.mutation -> app.application.subscription",
@@ -5970,6 +5976,8 @@
"app.db.adapters.query -> app.schemas",
"app.db.adapters.query -> app.schemas.query",
"app.db.adapters.site -> app.application",
"app.db.adapters.site -> app.application.security",
"app.db.adapters.site -> app.application.security.image",
"app.db.adapters.site -> app.application.site",
"app.db.adapters.site -> app.application.site.contract",
"app.db.adapters.site -> app.db",
@@ -10030,7 +10038,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 1052,
"module_count": 1053,
"modules": [
"app",
"app.adapters",
@@ -10356,6 +10364,7 @@
"app.application.security",
"app.application.security.auth",
"app.application.security.cookie",
"app.application.security.image",
"app.application.security.otp",
"app.application.security.passkey",
"app.application.security.secrets",

View File

@@ -6,7 +6,7 @@
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 572,
"loaded_app_module_count": 573,
"max_ms": 1293.338,
"median_ms": 1156.239,
"min_ms": 1102.806,
@@ -17,7 +17,7 @@
]
},
"app.factory": {
"loaded_app_module_count": 584,
"loaded_app_module_count": 585,
"max_ms": 1127.911,
"median_ms": 1122.382,
"min_ms": 1119.221,
@@ -28,7 +28,7 @@
]
},
"app.main": {
"loaded_app_module_count": 586,
"loaded_app_module_count": 587,
"max_ms": 1188.652,
"median_ms": 1183.509,
"min_ms": 1174.522,

View File

@@ -0,0 +1,103 @@
"""图片域名快照的并发、失效和过期回归。"""
import asyncio
from unittest.mock import AsyncMock
import pytest
from app.application.security import image as image_policy
from app.application.security.image import SiteImageDomainCache, normalize_image_domain
from app.schemas.site import Site
@pytest.mark.parametrize(
("address", "expected"),
[
(" CDN.Example:8443/path ", "https://cdn.example:8443"),
("https://[2001:4860:4860::8888]:8443/path", "https://[2001:4860:4860::8888]:8443"),
("https://user@host/path", None),
("https://@host/path", None),
("https://[", None),
("file://host/path", None),
(None, None),
],
)
def test_normalize_image_domain(address: str | None, expected: str | None) -> None:
"""只归一化有效图片主机,拒绝用户信息和非 HTTP 协议。"""
assert normalize_image_domain(address) == expected
@pytest.mark.asyncio
async def test_concurrent_image_requests_share_one_site_query() -> None:
"""冷启动并发和后续命中均共享一次数据库查询,空结果也缓存。"""
cache = SiteImageDomainCache()
source = AsyncMock()
entered = asyncio.Event()
release = asyncio.Event()
async def load() -> list[Site]:
"""挂起查询以模拟同一页面并发加载图片。"""
entered.set()
await release.wait()
return []
source.list.side_effect = load
tasks = [asyncio.create_task(cache.get(source)) for _ in range(30)]
await entered.wait()
release.set()
assert await asyncio.gather(*tasks) == [frozenset()] * 30
assert await cache.get(source) == frozenset()
assert source.list.await_count == 1
@pytest.mark.asyncio
async def test_invalidated_inflight_query_cannot_restore_old_domain() -> None:
"""删除或改址发生在查询途中时,旧结果不能重新写入安全白名单。"""
cache = SiteImageDomainCache()
source = AsyncMock()
async def load() -> list[Site]:
"""第一次查询模拟读取旧快照后发生提交失效。"""
if source.list.await_count == 1:
cache.invalidate()
return [Site(url="https://old.example/")]
return [Site(url="https://new.example/")]
source.list.side_effect = load
assert await cache.get(source) == frozenset({"https://new.example"})
assert source.list.await_count == 2
assert await cache.get(source) == frozenset({"https://new.example"})
assert source.list.await_count == 2
@pytest.mark.asyncio
async def test_expiry_and_source_replacement_refresh_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
"""短期过期兜底及 lifespan 查询服务更换均重新加载站点。"""
clock = [100.0]
monkeypatch.setattr(image_policy, "monotonic", lambda: clock[0])
cache = SiteImageDomainCache(ttl=60)
source = AsyncMock()
source.list.return_value = [Site(domain="first.example")]
assert await cache.get(source) == frozenset({"https://first.example"})
clock[0] = 159
await cache.get(source)
assert source.list.await_count == 1
clock[0] = 160
source.list.return_value = []
assert await cache.get(source) == frozenset()
assert source.list.await_count == 2
replacement = AsyncMock()
replacement.list.return_value = [Site(domain="second.example")]
assert await cache.get(replacement) == frozenset({"https://second.example"})
@pytest.mark.asyncio
async def test_failed_query_is_retried() -> None:
"""查询失败不缓存结果,恢复后重新获取配置。"""
cache = SiteImageDomainCache()
source = AsyncMock()
source.list.side_effect = [RuntimeError("unavailable"), []]
with pytest.raises(RuntimeError, match="unavailable"):
await cache.get(source)
assert await cache.get(source) == frozenset()
assert source.list.await_count == 2

View File

@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from app.application.site import mutation as site_mutation
from app.application.site.contract import SiteMutation, SitePriorityMutation
from app.application.site.mutation import SiteMutationCommand
from app.schemas.site import Site
@@ -58,6 +59,32 @@ async def test_create_site_commits_before_updated_event():
assert mutation.values["public"] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("commit_fails", [False, True])
async def test_site_commit_invalidates_images_only_after_success(monkeypatch, commit_fails):
"""图片授权只在提交成功后失效,并先于站点事件发布。"""
calls = []
monkeypatch.setattr(site_mutation.site_image_domains, "invalidate", lambda: calls.append("invalidate"))
async def commit():
"""模拟提交成功或失败,记录真实调用顺序。"""
calls.append("commit")
if commit_fails:
raise RuntimeError("commit failed")
command, _ = _command(
unit_of_work=Mock(commit=AsyncMock(side_effect=commit), rollback=AsyncMock()),
publish_deleted=AsyncMock(side_effect=lambda _payload: calls.append("event")),
)
if commit_fails:
with pytest.raises(RuntimeError, match="commit failed"):
await command.delete(1)
assert calls == ["commit"]
else:
await command.delete(1)
assert calls == ["commit", "invalidate", "event"]
@pytest.mark.asyncio
async def test_create_site_ignores_schema_identity_field():
"""手动新增站点应忽略请求 Schema 自动补出的空主键。"""

View File

@@ -2,6 +2,7 @@
import pytest
from app.application.security.image import SiteImageDomainCache
from app.application.site.contract import (
SiteMutation,
SitePriorityMutation,
@@ -10,6 +11,7 @@ from app.application.site.contract import (
SiteUserDataSnapshot,
)
from app.application.site.query import SiteQueryService
from app.db.adapters import site as site_adapter
from app.db.adapters.site import SessionSiteRepository, TransactionalSiteRepository
from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
@@ -27,6 +29,31 @@ def _transactional_repository() -> TransactionalSiteRepository:
)
@pytest.mark.asyncio
async def test_site_writes_refresh_image_domains(db, monkeypatch) -> None:
"""同步新增、同步改址和异步改址提交后均撤销旧图片域名快照。"""
db.watermark(Site)
cache = SiteImageDomainCache()
monkeypatch.setattr(site_adapter, "site_image_domains", cache)
repository = _transactional_repository()
source = SiteQueryService(repository=repository)
original = await cache.get(source)
assert repository.add(SiteMutation({
"name": "图片缓存", "domain": "image-cache.example", "url": "https://old-image.example/",
})).success
snapshot = repository.get_by_domain("image-cache.example")
assert snapshot is not None
assert "https://old-image.example" in await cache.get(source) - original
repository.update(snapshot.id, SiteMutation({"url": "https://new-image.example/"}))
domains = await cache.get(source)
assert "https://old-image.example" not in domains
assert "https://new-image.example" in domains
await repository.async_update(snapshot.id, SiteMutation({"url": "https://async-image.example/"}))
domains = await cache.get(source)
assert "https://new-image.example" not in domains
assert "https://async-image.example" in domains
def test_transactional_repository_projects_frozen_site_snapshot(db) -> None:
"""独立仓储应在 Session 内复制 JSON 并返回不可变站点快照。"""
db.watermark(Site)

View File

@@ -18,6 +18,7 @@ from app.application.image import (
configure_image_ports,
reset_image_ports,
)
from app.application.security.image import SiteImageDomainCache
from app.schemas.site import Site
@@ -26,6 +27,7 @@ def configured_image_sites(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
"""隔离图片白名单的站点查询,避免其他用例的数据库状态影响结果。"""
query = AsyncMock()
query.list.return_value = []
monkeypatch.setattr(system_endpoint, "site_image_domains", SiteImageDomainCache())
monkeypatch.setattr(system_endpoint, "get_configured_site_query_service", lambda: query)
return query
@@ -86,9 +88,11 @@ def test_site_image_allowlist_tracks_configuration(configured_image_sites: Async
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == {
"https://qingwapt.com", "https://old.example",
}
system_endpoint.site_image_domains.invalidate()
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == {
"https://qingwapt.com", "https://new.example",
}
system_endpoint.site_image_domains.invalidate()
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == set()
@@ -173,10 +177,32 @@ def test_bangumi_image_proxy_domain_is_added_to_allowlist() -> None:
):
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == {
"lain.bgm.tv",
"image-proxy.example",
"https://image-proxy.example",
}
@pytest.mark.parametrize("key", [
"TMDB_IMAGE_DOMAIN", "MUSIC_COVER_PROXY", "BANGUMI_IMAGE_DOMAIN",
"WALLPAPER_IMAGE_URL", "CUSTOMIZE_WALLPAPER_API_URL",
])
@pytest.mark.parametrize("enabled", [False, True])
def test_custom_image_proxy_settings_are_live(
configured_image_sites: AsyncMock, key: str, enabled: bool,
) -> None:
"""图片代理配置即时更新;Bangumi 关闭时不放行其自定义代理。"""
settings = {key: "https://proxy.example:8443/base/?url=", "BANGUMI_PROXY_ENABLE": enabled}
with patch.object(system_endpoint, "get_runtime_settings", return_value=settings):
expected = key != "BANGUMI_IMAGE_DOMAIN" or enabled
assert ("https://proxy.example:8443" in asyncio.run(
system_endpoint._get_image_proxy_allowed_domains(),
)) is expected
settings[key] = "https://new.example/path/"
domains = asyncio.run(system_endpoint._get_image_proxy_allowed_domains())
assert "https://proxy.example:8443" not in domains
assert ("https://new.example" in domains) is expected
assert configured_image_sites.list.await_count == 1
def test_image_proxy_trusts_enabled_mediaserver_hosts() -> None:
"""已启用媒体服务器的 host / play_host 应被规范化为受信主机。"""
confs = [