diff --git a/app/modules/apppush/__init__.py b/app/modules/apppush/__init__.py index 1550fb293..d887576ca 100644 --- a/app/modules/apppush/__init__.py +++ b/app/modules/apppush/__init__.py @@ -7,6 +7,7 @@ import secrets import threading from collections.abc import Callable from typing import Any, Optional, Union +from urllib.parse import urlsplit from app.adapters.external.server import MoviePilotServerHelper from app.application.configuration import get_configured_system_config @@ -225,6 +226,25 @@ class AppPushModule(_ModuleBase, _MessageBase[object]): return tuple(dict.fromkeys(target)) return () + @staticmethod + def _notification_image(message: Message) -> Optional[str]: + """ + 优先传递独立海报,兼容旧消息图片;非网页图片不影响正文投递。 + + :param message: 现有通知链提供的海报和图片引用 + :return: 无凭据且长度受限的 HTTP(S) 地址,无可用图片时返回 None + """ + for value in (message.poster, message.image): + if not value or len(value) > 2048 or "\\" in value or any(ord(char) <= 32 or ord(char) == 127 for char in value): + continue + try: + parsed = urlsplit(value) + if parsed.scheme in {"http", "https"} and parsed.hostname and parsed.username is None and parsed.password is None: + return value + except ValueError: + continue + return None + def post_message(self, message: Message, **kwargs: object) -> None: """ 仅向路由确定的不可变账号身份投递;缺少绑定绝不退回全设备广播。 @@ -235,11 +255,13 @@ class AppPushModule(_ModuleBase, _MessageBase[object]): link = message.link or "/?shotcut=message" # 外部下载或媒体链接留在消息列表中,系统通知点击始终交给本实例。 route = link if link.startswith("/") and not link.startswith("//") and "\\" not in link else "/?shotcut=message" + image = self._notification_image(message) for owner in self._notification_owners(message): try: result = self._request(conf, "POST", "messages", { "owner": owner, "title": (message.title or "MoviePilot")[:256], "body": (message.text or "")[:1000], "route": route, + **({"image": image} if image else {}), }) if result.get("failed"): logger.warning(f"AppPush 有 {len(result['failed'])} 个设备投递失败,请检查 Server 配置") diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fb0aeb77e..eae0635d7 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -96,6 +96,8 @@ Web 会话沿用当前用户;无渠道调用不携带收件人,插件回执 账号通知身份由后台注入,客户端不能指定其他用户。注销同时撤销该安装配对的主屏幕通知网页。 前端仅在用户主动退出登录时请求 `unregister`;服务重启、认证失效及站点认证完成后的重新登录 只清理登录会话,保留通知绑定。`pair` 返回的绑定码十分钟有效且只能使用一次,完成绑定后无需保留或重新生成。 +AppPush 向中心传递独立海报(旧消息回退到图片);通知网页点开后显示本条通知的标题、正文和海报, +并提供 `moviepilot-app://notification` 链接。应先更新 Server,再更新 MoviePilot;图片由接收设备加载,系统通知中的图片展示以浏览器实际支持为准。 AppPush 地址 `PUSH_SERVER_URL` 留空时使用 `MP_SERVER_HOST`;首次操作自动生成并先行持久保存256位实例凭据,再向中心幂等登记现有 `user_uid`。UID 只关联来源,不能据此获取、重置或认领已有凭据。内部 `AppPushCredentials` 按中心地址隔离,不进入通知渠道配置;中心只保存凭据摘要。已有 `PUSH_RELAY_KEY` 手工配置继续兼容。 `POST /api/v1/notification/app/instance` 仅容器超级管理员可调用,正文为 `{"action":"rotate","source":"已保存的渠道名称"}`。轮换保留设备,候选凭据先落库,响应丢失可在重启后接续;返回值不包含密钥。普通用户的 `/notification/app` 不接受该动作。 Apple/VAPID 私钥只放在中心 Server,不放入 App 或用户容器。 diff --git a/tests/test_apppush.py b/tests/test_apppush.py index 928d4e43b..5cd687127 100644 --- a/tests/test_apppush.py +++ b/tests/test_apppush.py @@ -80,6 +80,37 @@ def test_apppush_reuses_message_switches_and_never_broadcasts(monkeypatch): assert len(calls) == 1 +@pytest.mark.parametrize(("poster", "image", "expected"), [ + ("https://images.test/poster.jpg", "https://images.test/backdrop.jpg", "https://images.test/poster.jpg"), + (None, "https://images.test/backdrop.jpg", "https://images.test/backdrop.jpg"), + ("data:image/png;base64,AA", "https://images.test/legacy.jpg", "https://images.test/legacy.jpg"), + (None, "javascript:alert(1)", None), + (None, "https://user:secret@images.test/poster.jpg", None), + (None, "https://images.test/" + "a" * 2048, None), + (None, None, None), +]) +def test_apppush_preserves_poster_and_keeps_invalid_images_out_of_relay(monkeypatch, poster, image, expected): + """ + 真实消息投递优先海报并兼容旧图片,无效附件不阻断正文和实例内跳转。 + """ + conf = NotificationConf(name="App", type="apppush", enabled=True, switchs=["资源下载"]) + module = AppPushModule.__new__(AppPushModule) + module._channel = NotificationChannel.AppPush + module._configs = {conf.name: conf} + calls = [] + monkeypatch.setattr(module, "get_configs", lambda: {conf.name: conf}) + monkeypatch.setattr(module, "_request", lambda *args: calls.append(args) or {"accepted": 1}) + message = Message(mtype=MessageType.Download, targets={"apppush": "a" * 32}, title="下载完成", + text="第一行\n第二行", link="/history", poster=poster, image=image) + message.set_notification_route_scope("user") + module.post_message(message) + assert len(calls) == 1 + payload = calls[0][3] + assert payload["title"] == message.title and payload["body"] == message.text and payload["route"] == "/history" + assert payload.get("image") == expected + assert ("image" in payload) == (expected is not None) + + @pytest.mark.parametrize("scope", [None, "user", "admin", "all"]) def test_apppush_account_sets_require_explicit_all_scope(monkeypatch, scope): """