mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-12 16:51:21 +08:00
188 lines
8.1 KiB
Python
188 lines
8.1 KiB
Python
"""GitHub OAuth Device Flow 的网络适配器。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from app.adapters.network.http import AsyncRequestUtils
|
||
from app.domain.github import (
|
||
GithubAuthPort,
|
||
GithubAuthTransportError,
|
||
GithubDeviceCode,
|
||
GithubSettingsReader,
|
||
GithubTokenExchange,
|
||
GithubUser,
|
||
)
|
||
from app.runtime.settings import get_runtime_setting
|
||
|
||
# Device Flow 的 client_id 本身不是秘密;沿用当前内置 Copilot 登录使用的公开应用标识,
|
||
# 后续如果注册专用 OAuth App,只需在组合根替换这一常量,不会影响授权协议和存储合同。
|
||
GITHUB_DEVICE_CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||
|
||
|
||
class _RuntimeGithubSettings:
|
||
"""把 runtime 设置读取端口适配为 GitHub 客户端所需的 get 接口。"""
|
||
|
||
def get(self, key: str, default: Any = None) -> Any:
|
||
"""读取一个运行时设置,并在启动早期保留默认值语义。"""
|
||
return get_runtime_setting(key, default)
|
||
|
||
|
||
class GithubAuthClient(GithubAuthPort):
|
||
"""通过 MoviePilot 统一异步 HTTP 适配器访问 GitHub OAuth API。"""
|
||
|
||
_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||
_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||
_USER_URL = "https://api.github.com/user"
|
||
|
||
def __init__(self, settings: GithubSettingsReader | None = None) -> None:
|
||
"""保存代理、User-Agent 等部署设置提供器。"""
|
||
self._settings = settings or _RuntimeGithubSettings()
|
||
|
||
async def request_device_code(
|
||
self,
|
||
client_id: str,
|
||
scope: str = "read:user",
|
||
) -> GithubDeviceCode:
|
||
"""按调用方 scope 请求设备码,默认保留 Copilot 登录的只读用户权限。"""
|
||
try:
|
||
response = await self._request().post_res(
|
||
self._DEVICE_CODE_URL,
|
||
json={"client_id": client_id, "scope": scope},
|
||
raise_exception=True,
|
||
)
|
||
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
|
||
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
|
||
payload = self._json_payload(response)
|
||
self._raise_payload_error(payload, "申请 GitHub 设备码失败")
|
||
try:
|
||
return GithubDeviceCode(
|
||
device_code=str(payload["device_code"]),
|
||
user_code=str(payload["user_code"]),
|
||
verification_uri=str(payload.get("verification_uri") or payload.get("verification_url") or ""),
|
||
expires_in=max(int(payload.get("expires_in") or 900), 1),
|
||
interval_seconds=max(int(payload.get("interval") or 5), 1),
|
||
)
|
||
except (KeyError, TypeError, ValueError) as error:
|
||
raise GithubAuthTransportError("GitHub 返回的设备授权信息无效") from error
|
||
|
||
async def exchange_device_code(
|
||
self,
|
||
client_id: str,
|
||
device_code: str,
|
||
) -> GithubTokenExchange:
|
||
"""轮询 GitHub 设备码,并保留协议层授权状态。"""
|
||
try:
|
||
response = await self._request().post_res(
|
||
self._ACCESS_TOKEN_URL,
|
||
json={
|
||
"client_id": client_id,
|
||
"device_code": device_code,
|
||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||
},
|
||
raise_exception=True,
|
||
)
|
||
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
|
||
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
|
||
return self._exchange_payload(self._json_payload(response))
|
||
|
||
async def refresh_access_token(
|
||
self,
|
||
client_id: str,
|
||
refresh_token: str,
|
||
) -> GithubTokenExchange:
|
||
"""使用 GitHub 返回的 refresh_token 申请新的访问 Token。"""
|
||
try:
|
||
response = await self._request().post_res(
|
||
self._ACCESS_TOKEN_URL,
|
||
json={
|
||
"client_id": client_id,
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": refresh_token,
|
||
},
|
||
raise_exception=True,
|
||
)
|
||
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
|
||
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
|
||
return self._exchange_payload(self._json_payload(response))
|
||
|
||
async def get_user(self, access_token: str) -> GithubUser:
|
||
"""调用 GitHub 用户接口验证 Token 并读取登录名。"""
|
||
try:
|
||
response = await self._request(headers={"Authorization": f"Bearer {access_token}"}).get_res(
|
||
self._USER_URL,
|
||
raise_exception=True,
|
||
)
|
||
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
|
||
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
|
||
if response is None:
|
||
raise GithubAuthTransportError("GitHub 未返回用户校验结果")
|
||
if response.status_code == 401:
|
||
raise GithubAuthTransportError("GitHub Token 已失效", unauthorized=True)
|
||
if response.status_code >= 400:
|
||
raise GithubAuthTransportError("GitHub Token 校验失败")
|
||
payload = self._json_payload(response)
|
||
login = str(payload.get("login") or "").strip()
|
||
if not login:
|
||
raise GithubAuthTransportError("GitHub 未返回用户信息")
|
||
return GithubUser(login=login)
|
||
|
||
def _request(self, *, headers: dict[str, str] | None = None) -> AsyncRequestUtils:
|
||
"""构造 GitHub OAuth 请求客户端,不复用当前业务 Token 请求头。"""
|
||
request_headers = {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
"User-Agent": str(self._settings.get("USER_AGENT") or "MoviePilot"),
|
||
}
|
||
request_headers.update(headers or {})
|
||
return AsyncRequestUtils(
|
||
headers=request_headers,
|
||
proxies=self._settings.get("PROXY"),
|
||
timeout=20,
|
||
verify=True,
|
||
trust_env=False,
|
||
)
|
||
|
||
@staticmethod
|
||
def _json_payload(response: Any) -> dict[str, Any]:
|
||
"""读取 GitHub JSON 响应,避免把原始响应文本写入业务错误。"""
|
||
try:
|
||
payload = response.json()
|
||
except Exception as error: # noqa: BLE001 - 外部 JSON 异常统一收敛
|
||
raise GithubAuthTransportError("GitHub 返回了无效响应") from error
|
||
if not isinstance(payload, dict):
|
||
raise GithubAuthTransportError("GitHub 返回了无效响应")
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _raise_payload_error(payload: dict[str, Any], message: str) -> None:
|
||
"""将 GitHub OAuth 错误字段转换为不泄露响应原文的业务异常。"""
|
||
if payload.get("error"):
|
||
raise GithubAuthTransportError(message)
|
||
|
||
@classmethod
|
||
def _exchange_payload(cls, payload: dict[str, Any]) -> GithubTokenExchange:
|
||
"""把设备码或刷新响应转换为统一 Token 交换结果。"""
|
||
return GithubTokenExchange(
|
||
access_token=cls._optional_text(payload.get("access_token")),
|
||
refresh_token=cls._optional_text(payload.get("refresh_token")),
|
||
expires_in=cls._optional_int(payload.get("expires_in")),
|
||
refresh_token_expires_in=cls._optional_int(payload.get("refresh_token_expires_in")),
|
||
scope=cls._optional_text(payload.get("scope")),
|
||
error=cls._optional_text(payload.get("error")),
|
||
)
|
||
|
||
@staticmethod
|
||
def _optional_text(value: Any) -> str | None:
|
||
"""规范化 GitHub 响应中的可选文本字段。"""
|
||
text = str(value or "").strip()
|
||
return text or None
|
||
|
||
@staticmethod
|
||
def _optional_int(value: Any) -> int | None:
|
||
"""规范化 GitHub 响应中的可选整数时间字段。"""
|
||
try:
|
||
return int(value) if value is not None else None
|
||
except (TypeError, ValueError):
|
||
return None
|