Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 80 additions & 107 deletions astrbot/dashboard/api/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any
from urllib.parse import quote

import jwt
from fastapi import APIRouter, Body, Depends, Query, Request
from fastapi.responses import PlainTextResponse, Response

Expand All @@ -16,7 +17,7 @@
call_request_view,
)
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import error, ok
from astrbot.dashboard.responses import ApiError, error, ok
from astrbot.dashboard.schemas import (
EnabledPatch,
PluginByIdRequest,
Expand All @@ -38,6 +39,7 @@
ConfigFileService,
)
from astrbot.dashboard.services.plugin_page_service import (
PLUGIN_PAGE_ASSET_TOKEN_TYPE,
PluginPageContentPayload,
PluginPageService,
PluginPageServiceError,
Expand All @@ -59,6 +61,47 @@
require_plugin_scope = ScopeDependency("plugin")


async def require_plugin_view_token(request: Request) -> str:
"""Validate the scoped token embedded in a plugin view asset path.

V1 routes are not covered by the dashboard auth middleware, so path-token
view URLs authenticate here instead. The token must be a plugin page asset
token scoped to exactly the plugin and page named in the path.

Args:
request: Current FastAPI request with plugin_id/view_name/token path
params.

Returns:
The username the token was issued to.

Raises:
ApiError: 401 when the token is missing, invalid, expired, or scoped
to a different plugin or page.
"""
token = request.path_params.get("token", "")
try:
payload = jwt.decode(
token,
request.app.state.jwt_secret,
algorithms=["HS256"],
)
except jwt.InvalidTokenError as exc:
raise ApiError("Token 无效", status_code=401) from exc

if (
payload.get("token_type") != PLUGIN_PAGE_ASSET_TOKEN_TYPE
or payload.get("plugin_name") != request.path_params.get("plugin_id")
or payload.get("page_name") != request.path_params.get("view_name")
):
raise ApiError("Token 无效", status_code=401)

username = payload.get("username")
if not isinstance(username, str) or not username.strip():
raise ApiError("Token 无效", status_code=401)
return username


def get_service(request: Request) -> PluginService:
return request.app.state.services.plugins

Expand Down Expand Up @@ -261,30 +304,6 @@ def _plugin_page_payload_response(payload: PluginPageContentPayload):
)


async def _serve_plugin_page_content(
*,
request: Request,
page_service: PluginPageService,
username: str | None,
plugin_id: str,
page_name: str,
asset_path: str,
):
try:
payload = await page_service.serve_page_content(
plugin_name=plugin_id,
page_name=page_name,
asset_path=asset_path,
asset_token=request.query_params.get("asset_token", "").strip(),
username=username,
locale=_get_request_locale(request),
theme=_get_request_theme(request),
)
except PluginPageServiceError as exc:
return _plugin_page_error_response(exc.status_code, exc.public_message)
return _plugin_page_payload_response(payload)


async def _serve_plugin_page_bridge_sdk(
*,
request: Request,
Expand All @@ -307,13 +326,13 @@ async def _get_plugin_page_entry_config(
page_service: PluginPageService,
username: str | None,
plugin_id: str | None,
page_name: str | None,
view_name: str | None,
):
try:
return ok(
await page_service.get_plugin_page_entry_config(
plugin_name=plugin_id,
page_name=page_name,
view_name=view_name,
username=username,
locale=_get_request_locale(request),
)
Expand Down Expand Up @@ -863,8 +882,8 @@ async def set_plugin_enabled_by_id(
)


@router.get("/plugins/pages")
@router.get("/plugins/views")
@router.get("/plugins/pages") # deprecated alias
async def list_plugin_pages_by_id(
plugin_id: str = Query(...),
_auth: AuthContext = Depends(require_plugin_scope),
Expand All @@ -878,8 +897,8 @@ async def list_plugin_pages_by_id(
)


@router.get("/plugins/page")
@router.get("/plugins/view")
@router.get("/plugins/page") # deprecated alias
async def get_plugin_page_by_id(
request: Request,
plugin_id: str = Query(...),
Expand All @@ -892,27 +911,7 @@ async def get_plugin_page_by_id(
page_service=page_service,
username=auth.username,
plugin_id=plugin_id,
page_name=page_name,
)


@router.get("/plugins/page/assets")
@router.get("/plugins/view/assets")
async def get_plugin_page_asset_by_id(
request: Request,
plugin_id: str = Query(...),
page_name: str = Query(...),
asset_path: str = Query(...),
auth: AuthContext = Depends(require_plugin_scope),
page_service: PluginPageService = Depends(get_page_service),
):
return await _serve_plugin_page_content(
request=request,
page_service=page_service,
username=auth.username,
plugin_id=plugin_id,
page_name=page_name,
asset_path=asset_path,
view_name=page_name,
)


Expand Down Expand Up @@ -1157,8 +1156,8 @@ async def update_plugin(
)


@router.get("/plugins/{plugin_id}/pages")
@router.get("/plugins/{plugin_id}/views")
@router.get("/plugins/{plugin_id}/pages") # deprecated alias
async def list_plugin_pages(
plugin_id: str,
_auth: AuthContext = Depends(require_plugin_scope),
Expand All @@ -1172,11 +1171,11 @@ async def list_plugin_pages(
)


@router.get("/plugins/{plugin_id}/pages/{page_name}")
@router.get("/plugins/{plugin_id}/views/{page_name}")
@router.get("/plugins/{plugin_id}/views/{view_name}")
@router.get("/plugins/{plugin_id}/pages/{view_name}") # deprecated alias
async def get_plugin_page(
plugin_id: str,
page_name: str,
view_name: str,
request: Request,
auth: AuthContext = Depends(require_plugin_scope),
page_service: PluginPageService = Depends(get_page_service),
Expand All @@ -1186,28 +1185,39 @@ async def get_plugin_page(
page_service=page_service,
username=auth.username,
plugin_id=plugin_id,
page_name=page_name,
view_name=view_name,
)


@router.get("/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path:path}")
@router.get("/plugins/{plugin_id}/views/{page_name}/assets/{asset_path:path}")
async def get_plugin_page_asset(
@router.get("/plugins/{plugin_id}/views/{view_name}/_t/{token}/")
@router.get("/plugins/{plugin_id}/views/{view_name}/_t/{token}/{asset_path:path}")
async def get_plugin_view_token_asset(
plugin_id: str,
page_name: str,
asset_path: str,
view_name: str,
token: str,
request: Request,
auth: AuthContext = Depends(require_plugin_scope),
asset_path: str = "",
username: str = Depends(require_plugin_view_token),
page_service: PluginPageService = Depends(get_page_service),
):
return await _serve_plugin_page_content(
request=request,
page_service=page_service,
username=auth.username,
plugin_id=plugin_id,
page_name=page_name,
asset_path=asset_path,
)
"""Serve plugin view assets authenticated by a scoped path token.

Assets are served without URL rewriting because relative URLs inherit the
token through normal path resolution.
"""
try:
payload = await page_service.serve_page_content(
plugin_name=plugin_id,
view_name=view_name,
asset_path=asset_path,
asset_token=request.query_params.get("asset_token", "").strip() or token,
username=username,
locale=_get_request_locale(request),
theme=_get_request_theme(request),
)
except PluginPageServiceError as exc:
return _plugin_page_error_response(exc.status_code, exc.public_message)
return _plugin_page_payload_response(payload)


@legacy_router.get("/api/plugin/get")
Expand Down Expand Up @@ -1258,7 +1268,7 @@ async def dashboard_get_plugin_page_entry_config(
page_service=page_service,
username=username,
plugin_id=request.query_params.get("name"),
page_name=request.query_params.get("page"),
view_name=request.query_params.get("page"),
)


Expand Down Expand Up @@ -1472,43 +1482,6 @@ async def dashboard_get_plugin_page_bridge_sdk(
)


@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/")
async def dashboard_get_plugin_page_entry(
plugin_id: str,
page_name: str,
request: Request,
username: str = Depends(require_dashboard_user),
page_service: PluginPageService = Depends(get_page_service),
):
return await _serve_plugin_page_content(
request=request,
page_service=page_service,
username=username,
plugin_id=plugin_id,
page_name=page_name,
asset_path="",
)


@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/{asset_path:path}")
async def dashboard_get_plugin_page_asset(
plugin_id: str,
page_name: str,
asset_path: str,
request: Request,
username: str = Depends(require_dashboard_user),
page_service: PluginPageService = Depends(get_page_service),
):
return await _serve_plugin_page_content(
request=request,
page_service=page_service,
username=username,
plugin_id=plugin_id,
page_name=page_name,
asset_path=asset_path,
)


@legacy_router.api_route("/api/plug/{plugin_path:path}", methods=["GET", "POST"])
async def dashboard_plugin_extension_route(
plugin_path: str,
Expand Down
56 changes: 11 additions & 45 deletions astrbot/dashboard/plugin_page_auth.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from urllib.parse import unquote

PLUGIN_PAGE_CONTENT_PREFIX = "/api/plugin/page/content/"
PLUGIN_PAGE_BRIDGE_PATH = "/api/plugin/page/bridge-sdk.js"
PLUGIN_PAGE_TOKEN_TYPE = "plugin_page_asset"


class PluginPageAuth:
"""Auth helpers for plugin view requests handled by the auth middleware.
View assets authenticate through scoped path tokens validated by the v1
route dependency (v1 paths bypass this middleware). Only the shared
bridge SDK script still authenticates here, via a query token.
"""

@staticmethod
def is_protected_path(path: str) -> bool:
return path.startswith(PLUGIN_PAGE_CONTENT_PREFIX) or path.startswith(
PLUGIN_PAGE_BRIDGE_PATH
)
return path.startswith(PLUGIN_PAGE_BRIDGE_PATH)

@staticmethod
def is_asset_token(payload: dict) -> bool:
Expand All @@ -21,44 +23,8 @@ def extract_asset_token(query_params) -> str | None:
query_asset_token = query_params.get("asset_token", "").strip()
return query_asset_token or None

@staticmethod
def extract_plugin_name_from_path(path: str) -> str | None:
if not path.startswith(PLUGIN_PAGE_CONTENT_PREFIX):
return None
remainder = path[len(PLUGIN_PAGE_CONTENT_PREFIX) :]
plugin_part = remainder.split("/", 1)[0] if remainder else ""
return unquote(plugin_part) if plugin_part else None

@staticmethod
def extract_page_name_from_path(path: str) -> str | None:
if not path.startswith(PLUGIN_PAGE_CONTENT_PREFIX):
return None
remainder = path[len(PLUGIN_PAGE_CONTENT_PREFIX) :]
parts = remainder.split("/", 2)
page_part = parts[1] if len(parts) > 1 else ""
return unquote(page_part) if page_part else None

@classmethod
def is_scope_valid(cls, payload: dict, path: str) -> bool:
if not cls.is_protected_path(path):
return False
if path.startswith(PLUGIN_PAGE_BRIDGE_PATH):
return True

token_plugin_name = payload.get("plugin_name")
token_page_name = payload.get("page_name")
request_plugin_name = cls.extract_plugin_name_from_path(path)
request_page_name = cls.extract_page_name_from_path(path)
if (
not isinstance(token_plugin_name, str)
or not token_plugin_name
or not isinstance(token_page_name, str)
or not token_page_name
or not request_plugin_name
or not request_page_name
):
return False
return (
token_plugin_name == request_plugin_name
and token_page_name == request_page_name
)
# The bridge SDK is shared across views, so any view token may fetch
# it; every other path rejects scoped asset tokens.
return cls.is_protected_path(path)
Loading
Loading