Files
MoviePilot/app/api/endpoints/github.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

307 lines
10 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.
"""
GitHub Token 授权接口及首次初始化专用授权接口。
"""
from dataclasses import asdict
from typing import Literal, cast
from fastapi import Depends, HTTPException
from app.api.dependencies.auth import get_current_active_superuser_async, get_user_service
from app.api.dependencies.github import get_community_medal_service, get_github_auth_service
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.community.medals import CommunityMedalService
from app.application.github_auth import (
GithubAuthError,
GithubAuthService,
GithubAuthStatus,
)
from app.application.security.user import UserService
from app.schemas.github import (
GithubCommunityProfile,
GithubDeviceAuthPoll,
GithubDeviceAuthPollRequest,
GithubDeviceAuthStart,
GithubManualTokenRequest,
GithubTokenStatus,
)
from app.schemas.response import Response as _SchemaResponse
router = ResponseAPIRouter()
initialization_router = ResponseAPIRouter()
@router.get(
"/community",
summary="查询当前 GitHub 用户的社区勋章",
response_model=_SchemaResponse[GithubCommunityProfile],
)
async def get_community_profile(
refresh: bool = False,
_: ApiPrincipal = Depends(get_current_active_superuser_async),
community: CommunityMedalService = Depends(get_community_medal_service),
) -> _SchemaResponse[GithubCommunityProfile]:
"""
仅向管理此实例凭据的超级管理员展示其 GitHub 身份的勋章,不继承给普通用户。
"""
profile = await community.profile(refresh=refresh)
return _SchemaResponse(success=True, data=GithubCommunityProfile.model_validate(asdict(profile)))
def _status_model(status: GithubAuthStatus) -> GithubTokenStatus:
"""
将应用层状态投影为不含 Token 原文的 API 模型。
"""
return GithubTokenStatus(
configured=status.configured,
valid=status.valid,
source=cast(Literal["oauth", "manual"] | None, status.source),
login=status.login,
masked_token=status.masked_token,
expires_at=status.expires_at,
needs_reauthorization=status.needs_reauthorization,
)
def _raise_auth_error(error: GithubAuthError) -> None:
"""
把授权应用错误转换为稳定的客户端错误,不返回外部响应原文。
"""
raise HTTPException(status_code=400, detail=str(error)) from error
async def _ensure_initialization_open(service: UserService) -> None:
"""
限制未认证授权接口只能在首次初始化窗口内使用。
"""
if await service.is_initialized():
raise HTTPException(status_code=409, detail="系统已经完成初始化")
@router.get(
"/auth/status",
summary="查询 GitHub Token 状态",
response_model=_SchemaResponse[GithubTokenStatus],
)
async def get_auth_status(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubTokenStatus]:
"""
返回当前管理员可见的 GitHub Token 脱敏状态。
"""
return _SchemaResponse(success=True, data=_status_model(await github_auth.status()))
@router.post(
"/auth/start",
summary="启动 GitHub 设备授权",
response_model=_SchemaResponse[GithubDeviceAuthStart],
)
async def start_auth(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubDeviceAuthStart]:
"""
为已登录管理员申请 GitHub Device Flow 设备码。
"""
try:
result = await github_auth.start_device_auth()
except GithubAuthError as error:
_raise_auth_error(error)
return _SchemaResponse(
success=True,
data=GithubDeviceAuthStart(
session_id=result.session_id,
verification_uri=result.verification_uri,
user_code=result.user_code,
expires_in=result.expires_in,
interval_seconds=result.interval_seconds,
),
)
@router.post(
"/auth/poll",
summary="轮询 GitHub 设备授权",
response_model=_SchemaResponse[GithubDeviceAuthPoll],
)
async def poll_auth(
payload: GithubDeviceAuthPollRequest,
_: ApiPrincipal = Depends(get_current_active_superuser_async),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubDeviceAuthPoll]:
"""
轮询管理员发起的 GitHub Device Flow。
"""
try:
result = await github_auth.poll_device_auth(payload.session_id)
except GithubAuthError as error:
_raise_auth_error(error)
status = _status_model(result.status) if result.status else None
return _SchemaResponse(
success=True,
data=GithubDeviceAuthPoll(
state=cast(Literal["pending", "authorized", "slow_down", "denied", "expired", "failed"], result.state),
message=result.message,
retry_after=result.retry_after,
status=status,
),
)
@router.post(
"/auth/manual",
summary="保存手动 GitHub Token",
response_model=_SchemaResponse[GithubTokenStatus],
)
async def save_manual_token(
payload: GithubManualTokenRequest,
_: ApiPrincipal = Depends(get_current_active_superuser_async),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubTokenStatus]:
"""
保存兼容 PAT 的手动 GitHub Token,并返回脱敏状态。
"""
try:
status = await github_auth.set_manual_token(payload.token)
except GithubAuthError as error:
_raise_auth_error(error)
return _SchemaResponse(success=True, data=_status_model(status))
@router.delete(
"/auth/token",
summary="清除 GitHub Token",
response_model=_SchemaResponse[None],
)
async def clear_token(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[None]:
"""
清除管理员配置的 GitHub Token。
"""
try:
await github_auth.clear_token()
except GithubAuthError as error:
_raise_auth_error(error)
return _SchemaResponse(success=True)
@initialization_router.get(
"/status",
summary="查询初始化阶段 GitHub Token 状态",
response_model=_SchemaResponse[GithubTokenStatus],
)
async def get_initialization_auth_status(
service: UserService = Depends(get_user_service),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubTokenStatus]:
"""
在实例尚未初始化时返回 GitHub Token 脱敏状态。
"""
await _ensure_initialization_open(service)
return _SchemaResponse(success=True, data=_status_model(await github_auth.status()))
@initialization_router.post(
"/start",
summary="从初始化页启动 GitHub 设备授权",
response_model=_SchemaResponse[GithubDeviceAuthStart],
)
async def start_initialization_auth(
service: UserService = Depends(get_user_service),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubDeviceAuthStart]:
"""
限制在首次初始化窗口内启动 GitHub Device Flow。
"""
await _ensure_initialization_open(service)
try:
result = await github_auth.start_device_auth()
except GithubAuthError as error:
_raise_auth_error(error)
return _SchemaResponse(
success=True,
data=GithubDeviceAuthStart(
session_id=result.session_id,
verification_uri=result.verification_uri,
user_code=result.user_code,
expires_in=result.expires_in,
interval_seconds=result.interval_seconds,
),
)
@initialization_router.post(
"/poll",
summary="从初始化页轮询 GitHub 设备授权",
response_model=_SchemaResponse[GithubDeviceAuthPoll],
)
async def poll_initialization_auth(
payload: GithubDeviceAuthPollRequest,
service: UserService = Depends(get_user_service),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubDeviceAuthPoll]:
"""
限制在首次初始化窗口内轮询 GitHub Device Flow。
"""
await _ensure_initialization_open(service)
try:
result = await github_auth.poll_device_auth(payload.session_id)
except GithubAuthError as error:
_raise_auth_error(error)
status = _status_model(result.status) if result.status else None
return _SchemaResponse(
success=True,
data=GithubDeviceAuthPoll(
state=cast(Literal["pending", "authorized", "slow_down", "denied", "expired", "failed"], result.state),
message=result.message,
retry_after=result.retry_after,
status=status,
),
)
@initialization_router.post(
"/manual",
summary="从初始化页保存手动 GitHub Token",
response_model=_SchemaResponse[GithubTokenStatus],
)
async def save_initialization_manual_token(
payload: GithubManualTokenRequest,
service: UserService = Depends(get_user_service),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[GithubTokenStatus]:
"""
限制在首次初始化窗口内保存兼容 PAT 的手动 Token。
"""
await _ensure_initialization_open(service)
try:
status = await github_auth.set_manual_token(payload.token)
except GithubAuthError as error:
_raise_auth_error(error)
return _SchemaResponse(success=True, data=_status_model(status))
@initialization_router.delete(
"/token",
summary="从初始化页清除 GitHub Token",
response_model=_SchemaResponse[None],
)
async def clear_initialization_token(
service: UserService = Depends(get_user_service),
github_auth: GithubAuthService = Depends(get_github_auth_service),
) -> _SchemaResponse[None]:
"""
限制在首次初始化窗口内清除 GitHub Token。
"""
await _ensure_initialization_open(service)
try:
await github_auth.clear_token()
except GithubAuthError as error:
_raise_auth_error(error)
return _SchemaResponse(success=True)