mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 20:34:48 +08:00
123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""
|
||
Web Push 协议发送(RFC 8030 投递、RFC 8291 aes128gcm 加密、RFC 8292 VAPID 签名)。
|
||
|
||
直接使用 http_ece 与 py_vapid 完成加密和签名,经 RequestUtils 投递,不导入 pywebpush:
|
||
后者只是这两个库的薄封装,却会在导入时加载 aiohttp。请求头、VAPID 声明与 pywebpush
|
||
2.3 的 ``webpush()`` 保持一致。
|
||
"""
|
||
|
||
import base64
|
||
import time
|
||
from typing import Any, Dict, Mapping, Optional, cast
|
||
from urllib.parse import urlparse
|
||
|
||
import http_ece
|
||
from cryptography.hazmat.primitives.asymmetric import ec
|
||
from py_vapid import Vapid
|
||
|
||
from app.adapters.network.http import RequestUtils
|
||
|
||
# VAPID JWT 有效期;与 pywebpush 默认一致,推送服务要求不超过 24 小时。
|
||
_VAPID_EXPIRE_SECONDS = 12 * 60 * 60
|
||
# 推送服务返回 2xx 中不超过 202 的状态码视为已接收。
|
||
_MAX_ACCEPTED_STATUS = 202
|
||
_DEFAULT_TIMEOUT_SECONDS = 20
|
||
|
||
|
||
class WebPushDeliveryError(Exception):
|
||
"""
|
||
Web Push 投递失败。
|
||
|
||
``response`` 为推送服务的 HTTP 响应,网络失败或订阅数据无效时为 None;
|
||
调用方据其状态码(404/410)判断订阅是否已失效。
|
||
"""
|
||
|
||
def __init__(self, message: str, response: Any = None):
|
||
super().__init__(message)
|
||
self.response = response
|
||
|
||
|
||
def _b64url_decode(value: str) -> bytes:
|
||
"""
|
||
解码浏览器订阅中去掉填充的 base64url 字段。
|
||
"""
|
||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||
|
||
|
||
def _vapid_headers(endpoint: str, private_key: str, subject: str) -> Dict[str, str]:
|
||
"""
|
||
按推送端点源站生成 VAPID Authorization 请求头。
|
||
"""
|
||
url = urlparse(endpoint)
|
||
claims = {
|
||
"sub": subject,
|
||
"aud": f"{url.scheme}://{url.netloc}",
|
||
"exp": int(time.time()) + _VAPID_EXPIRE_SECONDS,
|
||
}
|
||
return cast(Dict[str, str], Vapid.from_string(private_key=private_key).sign(claims))
|
||
|
||
|
||
def _encrypt(subscription: Mapping[str, Any], payload: bytes) -> bytes:
|
||
"""
|
||
用订阅公钥与 auth 密钥按 aes128gcm 加密消息体,每次使用新的临时 ECDH 密钥。
|
||
"""
|
||
keys = subscription.get("keys") or {}
|
||
if not keys.get("p256dh") or not keys.get("auth"):
|
||
raise WebPushDeliveryError("订阅缺少 p256dh 或 auth 密钥")
|
||
receiver_key = _b64url_decode(keys["p256dh"])
|
||
if len(receiver_key) != 65 or receiver_key[0] != 0x04:
|
||
raise WebPushDeliveryError("订阅的 p256dh 公钥格式无效")
|
||
return cast(bytes, http_ece.encrypt(
|
||
payload,
|
||
private_key=ec.generate_private_key(ec.SECP256R1()),
|
||
dh=receiver_key,
|
||
auth_secret=_b64url_decode(keys["auth"]),
|
||
version="aes128gcm",
|
||
))
|
||
|
||
|
||
def send_webpush(
|
||
subscription: Mapping[str, Any],
|
||
data: str,
|
||
vapid_private_key: str,
|
||
vapid_subject: str,
|
||
ttl: int = 0,
|
||
headers: Optional[Mapping[str, str]] = None,
|
||
timeout: int = _DEFAULT_TIMEOUT_SECONDS,
|
||
) -> Any:
|
||
"""
|
||
向单个浏览器订阅投递一条加密通知。
|
||
|
||
:param subscription: 浏览器 PushSubscription JSON(endpoint 与 keys.p256dh/auth)
|
||
:param data: 通知正文,通常是 Service Worker 解析的 JSON 字符串
|
||
:param vapid_private_key: VAPID 私钥(base64url 编码的原始私钥或 DER)
|
||
:param vapid_subject: VAPID 联系方式,形如 ``mailto:...`` 或站点 URL
|
||
:param ttl: 接收端离线时推送服务保留消息的秒数,0 表示不保留
|
||
:param headers: 端点专用的附加请求头,例如 WNS 缓存策略
|
||
:param timeout: 请求超时秒数
|
||
:return: 推送服务的 HTTP 响应
|
||
:raises WebPushDeliveryError: 订阅无效、网络失败或推送服务拒收
|
||
"""
|
||
endpoint = subscription.get("endpoint")
|
||
if not endpoint:
|
||
raise WebPushDeliveryError("订阅缺少 endpoint")
|
||
if not vapid_private_key:
|
||
raise WebPushDeliveryError("未配置 VAPID 私钥")
|
||
request_headers = {
|
||
**(headers or {}),
|
||
**_vapid_headers(endpoint, vapid_private_key, vapid_subject),
|
||
"Content-Encoding": "aes128gcm",
|
||
"TTL": str(ttl),
|
||
}
|
||
response = RequestUtils(
|
||
headers=request_headers, timeout=timeout, verify=True
|
||
).post_res(endpoint, data=_encrypt(subscription, data.encode("utf-8")))
|
||
if response is None:
|
||
raise WebPushDeliveryError("推送服务无响应")
|
||
if response.status_code > _MAX_ACCEPTED_STATUS:
|
||
raise WebPushDeliveryError(
|
||
f"推送服务拒收:{response.status_code} {response.reason},{response.text[:200]}",
|
||
response=response,
|
||
)
|
||
return response
|