fix: allow media server image proxy paths

This commit is contained in:
jxxghp
2026-05-25 12:25:57 +08:00
parent 766d2699ea
commit d713ea54c1
2 changed files with 179 additions and 5 deletions

View File

@@ -1,9 +1,11 @@
import asyncio
import json
import posixpath
import re
from collections import deque
from datetime import datetime
from typing import Any, Optional, Union, Annotated
from urllib.parse import urljoin, urlparse
from urllib.parse import parse_qs, unquote, urljoin, urlparse
import aiofiles
import pillow_avif # noqa 用于自动注册AVIF支持
@@ -50,6 +52,21 @@ from version import APP_VERSION
router = APIRouter()
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_MEDIA_SERVER_IMAGE_PATH_PATTERNS = (
re.compile(
r"^/(?:emby/)?Items/[^/]+/Images/"
r"(?:Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter)"
r"(?:/[^/]+)?$",
re.IGNORECASE,
),
re.compile(
r"^/library/metadata/[^/]+/"
r"(?:thumb|art|banner|poster|clearlogo|clearart|background)"
r"(?:/[^/]+)?$",
re.IGNORECASE,
),
re.compile(r"^/api/v1/sys/img/.+", re.IGNORECASE),
)
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -342,6 +359,95 @@ async def _close_nettest_response(response: Any) -> None:
logger.debug(f"关闭网络测试响应失败: {err}")
def _normalize_proxy_image_path(path: str) -> str:
"""
归一化代理图片路径,用于识别媒体服务器图片接口。
URL path 可能包含编码后的特殊字符,这里先解码再规范化路径,避免
`%2e%2e` 或重复斜杠绕过后续的媒体图片路径判断。
"""
decoded_path = unquote(path or "/")
normalized_path = posixpath.normpath(decoded_path)
if not normalized_path.startswith("/"):
normalized_path = f"/{normalized_path}"
return normalized_path
def _is_known_media_server_image_path(path: str) -> bool:
"""
判断路径是否属于已知媒体服务器图片读取接口。
这里仅覆盖 MoviePilot 自身会返回给前端的封面、背景图和图片流接口,
不允许媒体服务器同 host 下的任意 API 路径继续通过图片代理访问。
"""
normalized_path = _normalize_proxy_image_path(path)
return any(
pattern.match(normalized_path)
for pattern in _MEDIA_SERVER_IMAGE_PATH_PATTERNS
)
def _is_plex_transcode_image_url(url: str) -> bool:
"""
校验 Plex 图片转码接口只转码 Plex 自身 metadata 图片路径。
Plex 的 posterUrl/artUrl 可能使用 `/photo/:/transcode` 包装真实图片路径,
因此需要额外检查 query 里的 `url` 仍然是 metadata 图片路径,而不是
任意可被 Plex 代取的地址。
"""
parsed_url = urlparse(url)
if _normalize_proxy_image_path(parsed_url.path) != "/photo/:/transcode":
return False
source_path = parse_qs(parsed_url.query).get("url", [None])[0]
if not source_path:
return False
source_url = urlparse(source_path)
if source_url.scheme or source_url.netloc:
return False
return _is_known_media_server_image_path(source_path)
def _is_ugreen_image_stream_url(url: str) -> bool:
"""
校验绿联本机图片流接口只代理官方 scraper 图片。
绿联本地图片需要带加密鉴权头,目前模块只会把 scraper.ugnas.com 的签名图
转成 getImaStream本检查避免用户把该接口改造成任意远程 URL 中转。
"""
parsed_url = urlparse(url)
if _normalize_proxy_image_path(parsed_url.path) != "/ugreen/v2/video/getImaStream":
return False
source_url = parse_qs(parsed_url.query).get("name", [None])[0]
if not source_url:
return False
parsed_source = urlparse(source_url)
return (
parsed_source.scheme in {"http", "https"}
and parsed_source.netloc.lower() == "scraper.ugnas.com"
)
def _is_allowed_media_server_image_url(
url: str,
media_server_domains: set[str],
) -> bool:
"""
判断内网媒体服务器 URL 是否可作为图片代理目标。
私有地址默认仍然禁止;只有 URL host 精确命中已配置媒体服务器,并且路径是
已知图片接口时才允许访问,用于兼容前端媒体库和最近入库图片展示。
"""
if not media_server_domains:
return False
if not SecurityUtils.is_safe_url(url, media_server_domains, strict=True):
return False
return (
_is_known_media_server_image_path(urlparse(url).path)
or _is_plex_transcode_image_url(url)
or _is_ugreen_image_stream_url(url)
)
async def fetch_image(
url: str,
proxy: Optional[bool] = None,
@@ -349,6 +455,7 @@ async def fetch_image(
if_none_match: Optional[str] = None,
cookies: Optional[str | dict] = None,
allowed_domains: Optional[set[str]] = None,
media_server_domains: Optional[set[str]] = None,
) -> Optional[Response]:
"""
处理图片缓存逻辑支持HTTP缓存和磁盘缓存
@@ -360,7 +467,11 @@ async def fetch_image(
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
# 验证URL安全性
if not SecurityUtils.is_safe_url(url, allowed_domains, block_private=True):
if not SecurityUtils.is_safe_url(
url, allowed_domains, block_private=True
) and not _is_allowed_media_server_image_url(
url, media_server_domains or set()
):
logger.warn(f"Blocked unsafe image URL: {url}")
return None
@@ -404,7 +515,8 @@ async def proxy_img(
for config in MediaServerHelper().get_configs().values()
if config and config.config and config.config.get("host")
]
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
media_server_domains = set(hosts)
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
cookies = (
MediaServerChain().get_image_cookies(server=None, image_url=imgurl)
if use_cookies
@@ -417,6 +529,7 @@ async def proxy_img(
cookies=cookies,
if_none_match=if_none_match,
allowed_domains=allowed_domains,
media_server_domains=media_server_domains,
)