From 86ededa739efb1fe3c4383426b526007599accb1 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 25 Oct 2026 06:28:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=E5=A2=9E=E5=8A=A0=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E4=B8=BB=E7=99=BB=E5=BD=95=E7=9A=84=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8A=E4=B8=8B=E6=96=87=20(#7176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/widget.py | 24 +++++++- app/application/security/widget.py | 15 ++++- app/schemas/widget.py | 10 ++++ docs/mcp-api.md | 5 +- docs/widgets.md | 5 ++ scripts/generate_agent_skill_docs.py | 2 +- skills/moviepilot-api/SKILL.md | 10 ++-- tests/test_builtin_skill_boundaries.py | 2 +- tests/test_widget.py | 82 +++++++++++++++++++++++++- 9 files changed, 144 insertions(+), 11 deletions(-) diff --git a/app/api/endpoints/widget.py b/app/api/endpoints/widget.py index e91d42e95..5e3ab1eeb 100644 --- a/app/api/endpoints/widget.py +++ b/app/api/endpoints/widget.py @@ -20,11 +20,33 @@ from app.application.security.widget import WidgetAccessError, WidgetAuthService from app.application.widget import WidgetSnapshotService from app.runtime.execution import run_in_threadpool from app.schemas.response import Response as ApiResponse -from app.schemas.widget import WidgetRegistrationRequest, WidgetRegistrationResponse, WidgetSnapshot +from app.schemas.widget import WidgetContext, WidgetRegistrationRequest, WidgetRegistrationResponse, WidgetSnapshot router = ResponseAPIRouter() +@router.get("/context", summary="读取当前登录的小组件账户上下文", response_model=ApiResponse[WidgetContext]) +def widget_context( + response: Response, + current_user: ApiPrincipal = Depends(get_current_active_user), + service: WidgetAuthService = Depends(get_widget_auth_service), +) -> ApiResponse[WidgetContext]: + """ + 通过既有主登录核对不可变身份与当前权限,不登记安装或返回凭据清单。 + + :param response: 禁止缓存当前账号的上下文 + :param current_user: 原认证确定的激活账号 + :param service: 当前宿主的只读授权上下文服务 + :return: 当前身份及可用内容,不含主登录或小组件密钥 + """ + response.headers.update(WIDGET_CACHE_HEADERS) + try: + result = service.context(current_user.id, current_user.notification_identity) + except WidgetAccessError as error: + widget_http_error(error) + return ApiResponse(success=True, data=result) + + @router.post( "/registrations", summary="启用或轮换本机小组件授权", response_model=ApiResponse[WidgetRegistrationResponse] ) diff --git a/app/application/security/widget.py b/app/application/security/widget.py index 5871b2b96..942e81baf 100644 --- a/app/application/security/widget.py +++ b/app/application/security/widget.py @@ -14,7 +14,7 @@ from typing import Protocol, TypeAlias from uuid import UUID, uuid4 from app.application.security.user import UserSnapshot -from app.schemas.widget import WidgetContent, WidgetRegistrationResponse +from app.schemas.widget import WidgetContent, WidgetContext, WidgetRegistrationResponse # 前台主 App 通过已有登录更新;扩展不能无限自续。 WIDGET_CREDENTIAL_LIFETIME = 30 * 24 * 60 * 60 @@ -179,6 +179,19 @@ class WidgetAuthService: raise WidgetAccessError(401, "小组件授权已失效,请在 App 中重新启用") return user + def context(self, user_id: int, owner: str) -> WidgetContext: + """ + 重新读取主登录账号的不可变身份和权限,不创建或轮换小组件授权。 + + :param user_id: 既有主认证给出的用户主键 + :param owner: 既有主认证给出的不可变身份,不接受请求参数指定 + :return: 当前身份和可用内容,无可用权限时返回空集合 + :raises WidgetAccessError: 账号禁用、删除或身份已变化 + """ + with self._scope() as transaction: + user = self._require_user(transaction.get_user(user_id), owner) + return WidgetContext(owner=user.notification_identity, availableContents=available_widget_contents(user)) + def register(self, user_id: int, owner: str, installation_id: UUID) -> WidgetRegistrationResponse: """ 原子轮换本安装凭据,只有成功提交后的新密钥可以返回客户端。 diff --git a/app/schemas/widget.py b/app/schemas/widget.py index ade6a9af8..fee6ee8c1 100644 --- a/app/schemas/widget.py +++ b/app/schemas/widget.py @@ -13,6 +13,16 @@ WidgetContent = Literal[ WidgetIcon = Literal["movie", "television", "episodes", "music", "download", "upload", "storage", "items", "clock"] +class WidgetContext(BaseModel): + """ + 当前主登录的小组件身份与权限;读取不会创建、轮换或返回任何凭据。 + """ + + version: Literal[1] = Field(default=1, description="小组件账户上下文协议版本") + owner: str = Field(pattern=r"^[0-9a-f]{32}$", description="当前激活用户的不可变身份,用于核对已启用资料") + availableContents: list[WidgetContent] = Field(max_length=6, description="按最新权限可选择的内容,无权限时为空") + + class WidgetRegistrationRequest(BaseModel): """ 当前登录用户明确启用某个本机安装;不接受 owner、权限或来源地址。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index d839de4d0..5167ad023 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -28,8 +28,9 @@ ### App 小组件只读接口 -小组件使用独立的有限期只读 Bearer,通过 `/api/v1/widget/registrations` 登记和 -`/api/v1/widget/snapshot` 刷新。该凭据不适用于普通 REST/MCP,也不加入 Agent 工具目录; +主 App 使用既有登录通过 `/api/v1/widget/context` 核对账号、`/api/v1/widget/registrations` 登记。 +扩展使用独立的有限期只读 Bearer 通过 `/api/v1/widget/snapshot` 刷新。 +该凭据不适用于普通 REST/MCP,小组件接口也不加入 Agent 工具目录; 详见[授权、权限与快照协议](widgets.md)。主登录凭据不得交给系统扩展。 ### 通知配置与交互身份 diff --git a/docs/widgets.md b/docs/widgets.md index b8e9250b7..7d33fb6a6 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -5,6 +5,11 @@ MoviePilot App 的系统小组件直接读取用户连接的 MoviePilot 实例 ## 启用与移除 +前台 App 先使用既有主登录读取 `GET /api/v1/widget/context`。标准响应的 `data` 只包含 +`version=1`、不可变 `owner` 和当前 `availableContents`,不会创建、轮换或列出任何登记。 +没有可用内容时返回空集合。App 用不可变身份核对组件资料与当前网页账号,不能仅比较可复用的 +数字用户 ID 或显示名;组件专用凭据不能调用该接口。此响应禁止缓存,不能用旧上下文替代当前校验。 + 前台 App 在用户明确启用后,使用既有登录调用 `POST /api/v1/widget/registrations`,请求体只包含随机 UUID `installationId`。 标准响应的 `data` 包含 `version=1`、`registrationId`、`installationId`、不可变 `owner`、 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index a27a41c8c..680bf713f 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -MOVIEPILOT_API_SKILL_VERSION = "64" +MOVIEPILOT_API_SKILL_VERSION = "65" DATABASE_OPERATION_SKILL_VERSION = "15" diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 67240a8c8..6a9c13050 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 64 +version: 65 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -227,9 +227,11 @@ frontmatter allowlist, and the matching gateway contract together. ## API Surface Scope System widget endpoints under `/api/v1/widget/*` are outside this skill and the Agent tool catalog. -They use a separate, expiring, read-only Bearer; never use it as an ordinary API/MCP login credential, -or enable, rotate, or export widget grants on a user's behalf. See `docs/widgets.md` for the protocol -and user scope. System extensions must not hold the main login token. +Context, registration, and owner revocation use the existing main login. Snapshot reads and self +revocation use a separate, expiring, read-only Bearer; never use it as an ordinary API/MCP login +credential, or enable, rotate, or export widget grants on a user's behalf. Reading context must not +issue or rotate credentials. See `docs/widgets.md` for the protocol and user scope. System extensions +must not hold the main login token. For installed plugins with a bound source, `plugin.market` only lists an update when that repository has a newer version. A higher version in another repository diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 1e873f5bc..835a36cd6 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "database-operation": "15", "feedback-issue": "12", "anysearch": "3", - "moviepilot-api": "64", + "moviepilot-api": "65", "moviepilot-update": "7", "organize-files": "6", "transfer-failed-retry": "6", diff --git a/tests/test_widget.py b/tests/test_widget.py index d0824d0c0..30d87fc2a 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -23,7 +23,7 @@ from sqlalchemy.orm import sessionmaker from app.adapters.web.security.access import verify_token from app.api.dependencies import widget as dependencies -from app.api.dependencies.auth import get_current_active_user +from app.api.dependencies.auth import get_current_active_user, get_current_user from app.api.dependencies.widget import get_widget_auth_service, get_widget_snapshot_service from app.api.endpoints import widget as endpoint from app.application.dashboard import DashboardQueryService @@ -157,6 +157,49 @@ def _expect_error(status, operation, *args): assert caught.value.status == status +def test_widget_context_reads_current_permissions_without_issuing_or_rotating(widget_case): + """ + 未启用也可核对当前账号;上下文不登记安装、轮换密钥或延长已有授权。 + """ + case = widget_case + result = case.auth.context(101, case.owners[0]) + assert result.model_dump() == { + "version": 1, + "owner": case.owners[0], + "availableContents": ["downloads", "imports", "movieSubscriptions", "tvSubscriptions", "musicSubscriptions"], + } + with case.factory() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(WidgetRegistration)) == 0 + issued = _register(case) + _update_user(case, permissions={"subscribe": True, "features": {"subscribe.movie": False, "subscribe.music": False}}) + case.clock[0] += 120 + assert case.auth.context(101, issued.owner).availableContents == ["tvSubscriptions"] + current = case.auth.authenticate(issued.secret) + assert current.registration.registration_id == str(issued.registrationId) + assert current.registration.expires_at == issued.expiresAt + with case.factory() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(WidgetRegistration)) == 1 + _update_user(case, permissions={}) + assert case.auth.context(101, issued.owner).availableContents == [] + + +@pytest.mark.parametrize("change", ["inactive", "deleted", "identity"]) +def test_widget_context_rejects_stale_main_account_identity(widget_case, change): + """ + 主登录校验后的并发禁用、删除和身份重建不能被上下文读取掩盖。 + """ + case = widget_case + if change == "deleted": + with case.factory() as session: + session.execute(sa.delete(User).where(User.id == 101)) + session.commit() + elif change == "identity": + _update_user(case, notification_identity=uuid4().hex) + else: + _update_user(case, is_active=False) + _expect_error(401, case.auth.context, 101, case.owners[0]) + + def test_widget_issue_rotation_owner_and_self_revoke(widget_case): """ 同安装隔离用户、轮换只撤销旧凭据、自撤销只影响自身,数据库没有原文。 @@ -482,6 +525,43 @@ def _client(case): return TestClient(app) +def test_widget_http_context_requires_main_login_and_never_exports_credentials(widget_case): + """ + 真实路由拒绝组件凭据,只返回当前账号最小上下文,错误及成功均不泄露密钥。 + """ + case = widget_case + issued = _register(case) + with _client(case) as client: + def main_user(_token=Depends(verify_token)): + """ + 隔离宿主数据库装配,但保留真实主令牌验证及原激活账号检查。 + """ + return SimpleNamespace(id=101, notification_identity=case.owners[0], is_active=True) + + client.app.dependency_overrides[get_current_user] = main_user + for headers in ({}, {"Authorization": f"Bearer {issued.secret}"}): + response = client.get("/api/v1/widget/context", headers=headers) + assert response.status_code == 401 + assert issued.secret not in response.text + client.app.dependency_overrides[get_current_active_user] = lambda: SimpleNamespace( + id=101, notification_identity=case.owners[0] + ) + response = client.get(f"/api/v1/widget/context?owner={case.owners[1]}") + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store, private" + assert response.headers["Vary"] == "Authorization" + assert response.json()["data"] == case.auth.context(101, issued.owner).model_dump() + assert set(response.json()["data"]) == {"version", "owner", "availableContents"} + assert issued.secret not in response.text + assert str(issued.registrationId) not in response.text + assert case.auth.authenticate(issued.secret).registration.expires_at == issued.expiresAt + _update_user(case, notification_identity=uuid4().hex) + response = client.get("/api/v1/widget/context") + assert response.status_code == 401 + assert response.headers["Cache-Control"] == "no-store, private" + assert "owner" not in response.json() + + def test_widget_http_header_only_no_cache_and_no_general_api_access(widget_case): """ 真实 HTTP 拒绝查询、Cookie、通用 API Key 和登录 JWT;只读凭据不能转成业务认证。