WechatOnCloud/bridge/woc_bridge/routes/media.py
Kris 15fc62d478 feat: 完成微信UI自动化新架构全量开发与集成
- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现
- 添加WeChat 4.0分辨率适配Profile与图像模板资源
- 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标
- 新增头像下载安全校验、发布朋友圈路径白名单防护
- 优化密钥缓存、DB校验逻辑与初始化流程
- 补充完整错误码体系与启动清场机制
2026-07-16 01:19:47 +08:00

172 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import logging
import mimetypes
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
from fastapi import APIRouter
from fastapi.responses import Response
from woc_bridge.config import _require_db_reader
from woc_bridge.db.coordinator import _check_db_readable, with_db_retry
from woc_bridge.models import BridgeError
logger = logging.getLogger("woc-bridge")
router = APIRouter()
# 头像下载安全限制
_AVATAR_MAX_BYTES = 10 * 1024 * 1024 # 10MB 上限,头像通常 < 1MB
_AVATAR_ALLOWED_HOSTS = (
"wx.qlogo.cn",
"thirdwx.qlogo.cn",
"wxhead.clouddn.com",
"thirdqq.qlogo.cn",
"q.qlogo.cn",
)
# ---------------------------------------------------------------------------
# 路由GET /api/media/avatar/{wxid}
# ---------------------------------------------------------------------------
# 注意:此路由必须在 /api/media/{msg_id} 之前声明,否则会被 {msg_id}
# 匹配到FastAPI 按声明顺序匹配,先匹配先得)。
@router.get("/api/media/avatar/{wxid}")
@with_db_retry
async def get_avatar(wxid: str) -> Response:
"""获取联系人头像。
最小实现:优先从 contact.db 读取 big_head_url / small_head_url / avatar_url
若存在 HTTP(S) 链接则拉取并透传二进制;否则返回 MEDIA_NOT_FOUND。
本地 .dat 头像缓存解密仍留给后续 W-04 完善。
Args:
wxid: 路径参数,联系人 wxid
Returns:
Response头像二进制内容 + Content-Type
Raises:
BridgeError(MEDIA_NOT_FOUND): 未找到联系人或无有效头像 URLHTTP 404
BridgeError(DB_ENCRYPTED): DB 已加密无法读取联系人表HTTP 503
Notes:
- 路由声明顺序敏感:必须在 /api/media/{msg_id} 之前,否则
FastAPI 会把 "avatar" 当作 msg_id 匹配
"""
db_reader = _require_db_reader()
# DB 不可读时统一返回 503行为与 /api/media/{msg_id} 一致
await _check_db_readable()
contact = await asyncio.to_thread(db_reader.get_contact_detail, wxid)
url: Optional[str] = None
if contact:
url = (
contact.get("big_head_url")
or contact.get("small_head_url")
or contact.get("avatar_url")
)
if not url or not url.startswith(("http://", "https://")):
raise BridgeError(
code="MEDIA_NOT_FOUND",
message=f"未找到联系人头像 URL: {wxid}",
)
# 下载前校验 URL 安全性SSRF 防护:拒绝内网/非白名单域名)
try:
await asyncio.to_thread(_validate_avatar_url, url)
except ValueError as e:
logger.warning("avatar: wxid=%s url=%s 校验失败: %s", wxid, url, e)
raise BridgeError(
code="MEDIA_NOT_FOUND",
message=f"头像 URL 校验失败: {wxid}",
)
try:
data = await asyncio.to_thread(_fetch_avatar_url, url)
except Exception as e:
logger.warning("avatar: wxid=%s url=%s 拉取失败: %s", wxid, url, e)
raise BridgeError(
code="MEDIA_NOT_FOUND",
message=f"头像下载失败: {wxid}",
)
mime = mimetypes.guess_type(url)[0] or "image/jpeg"
logger.info("avatar: wxid=%s url=%s%d bytes (%s)", wxid, url, len(data), mime)
return Response(content=data, media_type=mime)
def _validate_avatar_url(url: str) -> None:
"""校验头像 URL 安全性:仅允许已知头像 CDN 域名,拒绝内网地址。"""
parsed = urllib.parse.urlparse(url)
host = parsed.hostname or ""
if not host:
raise ValueError("头像 URL 缺少 hostname")
# 白名单域名检查
if host not in _AVATAR_ALLOWED_HOSTS:
raise ValueError(f"头像 URL host 不在白名单内: {host}")
def _fetch_avatar_url(url: str) -> bytes:
"""同步拉取头像 URL 二进制内容(限制 10MB 防止 OOM"""
with urllib.request.urlopen(url, timeout=15) as resp:
# 读取 Content-Length 预检
content_length = resp.headers.get("Content-Length")
if content_length and int(content_length) > _AVATAR_MAX_BYTES:
raise ValueError(
f"头像文件过大 ({content_length} bytes),超过 {_AVATAR_MAX_BYTES} 上限"
)
data = resp.read(_AVATAR_MAX_BYTES + 1)
if len(data) > _AVATAR_MAX_BYTES:
raise ValueError(f"头像文件过大,超过 {_AVATAR_MAX_BYTES} 上限")
return data
# ---------------------------------------------------------------------------
# 路由GET /api/media/{msg_id}
# ---------------------------------------------------------------------------
@router.get("/api/media/{msg_id}")
@with_db_retry
async def get_media(msg_id: str) -> Response:
"""下载消息媒体文件(图片/语音/视频/文件)。
Args:
msg_id: 路径参数,消息 ID来自 /api/messages/since 返回的 msg_id
Returns:
Response二进制内容 + Content-Typeimage/jpeg / audio/amr /
video/mp4 / application/octet-stream 等)
Raises:
BridgeError(MEDIA_NOT_FOUND): 媒体文件未找到或解析失败HTTP 404
BridgeError(DB_ENCRYPTED): DB 已加密,无法查 media 表HTTP 503
BridgeError(DB_NOT_FOUND): 未找到微信消息 DBHTTP 500
Notes:
- 前置调 _check_db_readable()DB 加密时直接返回 503 DB_ENCRYPTED
不再走 sqlite3.DatabaseError 被兜底为 500 的旧路径
- DbReader.get_media_bytes 内部DB 查 msg_id → 找对应 .dat 文件
→ XOR 解密 → 返回 (data, mime)
- 大文件可能阻塞较久,建议调用方设置合理的 timeout
"""
db_reader = _require_db_reader()
# 前置 DB 可达性检查:加密时直接返回 503避免 sqlite3 异常被兜底 500
await _check_db_readable()
result = await asyncio.to_thread(db_reader.get_media_bytes, msg_id)
if result is None:
logger.info("media: msg_id=%s → 未找到", msg_id)
raise BridgeError(
code="MEDIA_NOT_FOUND",
message=f"未找到媒体文件: {msg_id}",
)
data, mime = result
logger.info("media: msg_id=%s%d bytes (%s)", msg_id, len(data), mime)
return Response(content=data, media_type=mime)