""" GitHub OAuth Device Flow 与社区勋章名单的网络适配器。 """ from __future__ import annotations import re from typing import Any from app.adapters.network.http import AsyncRequestUtils from app.domain.github import ( COMMUNITY_MEDAL_SCHEMA, GithubAuthPort, GithubAuthTransportError, GithubDeviceCode, GithubMedalSnapshot, GithubSettingsReader, GithubTokenExchange, GithubUser, ) from app.foundation.url import UrlUtils from app.runtime.settings import get_runtime_setting # Device Flow 的 client_id 本身不是秘密;沿用当前内置 Copilot 登录使用的公开应用标识, # 后续如果注册专用 OAuth App,只需在组合根替换这一常量,不会影响授权协议和存储合同。 GITHUB_DEVICE_CLIENT_ID = "Ov23li8tweQw6odWQebz" # 社区勋章公开名单,由 MoviePilot-Wiki 定时任务计算与人工登记后发布。 COMMUNITY_MEDALS_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/community/medals.json" # 已知字段的类型约束;名单记录中这些字段类型不符时整条丢弃,其余字段原样透传。 _MEDAL_DATE = re.compile(r"\d{4}(-\d{2}-\d{2})?") 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 等部署设置提供器。 :param settings: 代理和请求头配置读取器;省略时读取运行时设置 """ self._settings = settings or _RuntimeGithubSettings() async def request_device_code( self, client_id: str, scope: str = "read:user", ) -> GithubDeviceCode: """ 按调用方 scope 请求设备码,默认保留 Copilot 登录的只读用户权限。 :param client_id: GitHub OAuth 应用的公开客户端标识 :param scope: 请求的 OAuth 权限范围,默认为只读用户信息 :return: 设备码、用户授权地址与有效期、轮询间隔 :raises GithubAuthTransportError: 请求失败、服务端拒绝或设备授权响应无效 """ 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 设备码,并保留协议层授权状态。 :param client_id: 申请设备码的 OAuth 应用标识 :param device_code: GitHub 返回的设备授权码 :return: 本次轮询的令牌或 OAuth 错误状态;等待授权等状态不作为传输异常 :raises GithubAuthTransportError: 网络失败或响应不是有效 JSON 对象 """ 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。 :param client_id: 原授权所属的 OAuth 应用标识 :param refresh_token: GitHub 颁发的刷新令牌 :return: 更新后的令牌或服务端返回的 OAuth 错误状态 :raises GithubAuthTransportError: 网络失败或响应不是有效 JSON 对象 """ 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 并读取登录名。 :param access_token: 用于查询当前用户的 GitHub 访问令牌 :return: 经响应校验的数字用户 ID 与登录名 :raises GithubAuthTransportError: 请求失败、令牌失效或用户信息无效;401 响应会标记 unauthorized """ 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 未返回用户信息") user_id = payload.get("id") if not isinstance(user_id, int) or user_id <= 0: raise GithubAuthTransportError("GitHub 未返回有效的用户身份") return GithubUser(login=login, user_id=user_id) async def get_medal_snapshot(self, user_id: int) -> GithubMedalSnapshot: """ 拉取 Wiki 勋章名单并只返回该数字 ID 的记录。 名单是公开文件,请求不携带实例 Token,避免凭据泄露给 GitHub 镜像站。 配置 ``GITHUB_PROXY`` 时先走镜像站,失败再经 ``PROXY``(未配置时直连)访问原地址, 与插件市场读取 Wiki 的出口顺序一致。 :param user_id: 已验证的 GitHub 数字用户 ID :return: 名单生成时间与该用户的有效勋章记录;没有记录时列表为空 :raises GithubAuthTransportError: 所有出口均未取得有效名单 """ cause: Exception | None = None for url, direct in self._medal_routes(): try: response = await self._request(direct=direct).get_res(url, raise_exception=True) if response is None or response.status_code >= 400: raise GithubAuthTransportError("社区勋章名单请求失败") return self._medal_snapshot(self._json_payload(response), user_id) # 镜像站可能以 200 返回错误页,继续尝试下一出口 except Exception as error: # noqa: BLE001 cause = error raise GithubAuthTransportError("暂时无法获取社区勋章名单") from cause def _medal_routes(self) -> list[tuple[str, bool]]: """ 返回勋章名单的请求地址及是否绕过部署代理;镜像站优先且不叠加代理。 :return: 按尝试顺序排列的地址与直连标志;镜像优先,原始地址兜底 """ routes: list[tuple[str, bool]] = [] mirror = str(self._settings.get("GITHUB_PROXY") or "").strip() if mirror: routes.append((f"{UrlUtils.standardize_base_url(mirror)}{COMMUNITY_MEDALS_URL}", True)) routes.append((COMMUNITY_MEDALS_URL, False)) return routes @classmethod def _medal_snapshot(cls, payload: dict[str, Any], user_id: int) -> GithubMedalSnapshot: """ 校验名单顶层协议并取出目标 ID 的记录;其他持有人的记录不在此处展开校验。 名单形如 ``holders["<数字 ID>"] = {"login": ..., "medals": [...]}``;持有人对象中的 ``login`` 等其他字段与顶层 ``unlinked``(无 GitHub 账号的共建者)都不参与实例查表。 :param payload: 勋章名单 JSON 对象 :param user_id: 要提取勋章的 GitHub 数字用户 ID :return: 过滤无效条目后的勋章快照;用户不存在时勋章为空 :raises GithubAuthTransportError: 名单版本或目标持有人结构无效 """ schema = payload.get("schema") generated_at = payload.get("generated_at") holders = payload.get("holders") if type(schema) is not int or schema != COMMUNITY_MEDAL_SCHEMA: raise GithubAuthTransportError("社区勋章名单版本不受支持") if not isinstance(generated_at, str) or not generated_at.strip() or not isinstance(holders, dict): raise GithubAuthTransportError("社区勋章名单格式无效") holder = holders.get(str(user_id)) if holder is None: records: list[Any] = [] elif isinstance(holder, dict) and isinstance(holder.get("medals"), list): records = holder["medals"] else: raise GithubAuthTransportError("社区勋章名单格式无效") return GithubMedalSnapshot( generated_at=generated_at.strip(), medals=tuple(record for record in records if cls._valid_medal(record)), ) @staticmethod def _valid_medal(record: Any) -> bool: """ 判断单条勋章记录可否发布。 记录必须是带非空字符串 ``key`` 的对象;未认识的勋章键和字段为向前兼容原样透传, 已知字段类型不符时整条丢弃,避免前端渲染出半残勋章,也不让一条坏记录拖垮整份名单。 :param record: 待检查的单条勋章数据 :return: 满足键名和已知字段约束时返回 True,否则返回 False """ if not isinstance(record, dict) or not isinstance(record.get("key"), str) or not record["key"]: return False tier = record.get("tier") years = record.get("years") checks = ( tier is None or (type(tier) is int and 1 <= tier <= 4), "at" not in record or (isinstance(record["at"], str) and _MEDAL_DATE.fullmatch(record["at"]) is not None), years is None or (isinstance(years, list) and all(type(year) is int for year in years)), "facts" not in record or isinstance(record["facts"], dict), "serial" not in record or record["serial"] is None or type(record["serial"]) in (int, str), all(field not in record or isinstance(record[field], str) for field in ("specialty", "basis", "note")), ) return all(checks) def _request(self, *, headers: dict[str, str] | None = None, direct: bool = False) -> AsyncRequestUtils: """ 构造 GitHub 请求客户端,不复用当前业务 Token 请求头;访问镜像站时不叠加部署代理。 :param headers: 合并到默认 JSON 请求头的附加字段,可显式提供授权信息 :param direct: 是否绕过部署代理;访问镜像站时为 True :return: 超时 20 秒、验证证书且不读取环境代理的异步请求适配器 """ 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=None if direct else self._settings.get("PROXY"), timeout=20, verify=True, trust_env=False, ) @staticmethod def _json_payload(response: Any) -> dict[str, Any]: """ 读取 GitHub JSON 响应,避免把原始响应文本写入业务错误。 :param response: 提供 json 方法的 HTTP 响应 :return: 解析后的 JSON 对象 :raises GithubAuthTransportError: 解析失败或响应顶层不是对象 """ try: payload = response.json() # 外部 JSON 异常统一收敛 except Exception as error: # noqa: BLE001 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 错误字段转换为不泄露响应原文的业务异常。 :param payload: 已解析的 OAuth 响应对象 :param message: 发现错误时使用的安全错误说明 :raises GithubAuthTransportError: 响应带有非空 error 字段 """ if payload.get("error"): raise GithubAuthTransportError(message) @classmethod def _exchange_payload(cls, payload: dict[str, Any]) -> GithubTokenExchange: """ 把设备码或刷新响应转换为统一 Token 交换结果。 :param payload: 设备授权或刷新令牌的响应对象 :return: 规范化后的令牌和错误状态;缺失或无法转换的可选值为 None """ 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 响应中的可选文本字段。 :param value: 服务端返回的可选文本值 :return: 转为文本并去除首尾空白后的值;空值或假值返回 None """ text = str(value or "").strip() return text or None @staticmethod def _optional_int(value: Any) -> int | None: """ 规范化 GitHub 响应中的可选整数时间字段。 :param value: 服务端返回的秒数或其他可转整数值 :return: 转换后的整数;缺失或不可转换时返回 None """ try: return int(value) if value is not None else None except (TypeError, ValueError): return None