feat(dashboard): scoped path-token auth for plugin views and complete the views rename (#10415)

* refactor(dashboard): complete the plugin pages to views rename

Finish the remaining Pages -> Views rename on the dashboard:

- Fix the plugin card "Open View" button crash by pointing
  openPluginWebui at the renamed PluginView route
- Rename PluginPagePage.vue to PluginViewPage.vue and update its
  internals (loadPluginView, pluginViewTitle, .plugin-view-* classes)
- Rename openPluginWebui -> openPluginView and the open-webui emit to
  open-view; pluginPageTitle/Description -> pluginViewTitle/Description;
  isPluginPageRoute -> isPluginViewRoute
- Rename i18n keys (pluginWebui -> pluginView, openWebui -> openView,
  pluginPageType -> pluginViewType, messages) across en/zh/ja/ru locales

The postMessage bridge channel name (astrbot-plugin-page) is kept
unchanged because it is part of the wire protocol shared with
plugin_page_bridge.js.

* feat(dashboard): authenticate plugin view assets with scoped path tokens

Serve plugin view assets from
/api/v1/plugins/{id}/views/{page}/_t/{token}/... so the scoped token
travels in the URL path. Relative URLs inside the view then inherit the
token through normal path resolution, which removes the need for
server-side HTML/CSS/JS URL rewriting on this route.

- Add the _t routes with a require_plugin_view_token dependency
  (v1 paths bypass the dashboard auth middleware)
- Extend the plugin page asset token to a session-length TTL (7 days,
  aligned with the dashboard session) with a page_session purpose claim,
  still scoped to a single plugin and page
- Serialize the view entry content_path as the _t URL, keeping the
  asset_token query for plugins that parse location.search
- Keep the legacy /api/plugin/page/content/...?asset_token= form working
  during the transition
- Rename the OpenAPI tag Plugin Pages -> Plugin Views, document the new
  route, regenerate the frontend API client, and update the zh/en
  plugin-pages docs

* refactor(dashboard): remove the legacy plugin page content route and URL rewriting

The path-token view route makes the compatibility surface unnecessary:

- Drop /api/plugin/page/content/... (query asset_token form)
- Drop the redundant authenticated asset routes /plugins/page/assets,
  /plugins/view/assets, and /plugins/{id}/(pages|views)/{page}/assets/...
  which had no callers outside the generated client
- Remove the HTML/CSS/JS URL rewriting helpers; view assets are always
  served raw and only HTML gets bridge SDK injection plus theme handling
- Slim PluginPageAuth down to the bridge SDK endpoint, the only
  non-v1 path still authenticating scoped query tokens
- serialize_plugin_page no longer falls back to the legacy content path
- Update the plugin-pages docs (zh/en) and the OpenAPI spec, and
  regenerate the frontend API client

* refactor(dashboard): drop legacy plugin page API aliases

Remove the page-named plugin view API aliases now that the views rename
is complete and legacy compatibility is intentionally dropped:

- DELETE /api/v1/plugins/page(s) and /plugins/{plugin_id}/pages{,/{page_name}}
  aliases; /plugins/view(s) and /plugins/{plugin_id}/views{,/{page_name}}
  are the only entry config routes
- Remove the unused /api/v1/plugins/page-bridge-sdk.js route; the live
  bridge SDK stays at /api/plugin/page/bridge-sdk.js
- Dashboard calls the view-named endpoints; regenerate the API client
  and the OpenAPI scopes docs

* refactor(dashboard): keep plugin page APIs as deprecated view aliases

Restore the page-named plugin view routes removed in the previous commit
and keep them for a transition period, with view as the canonical API:

- /plugins/page(s) and /plugins/{plugin_id}/pages{,/{page_name}} serve as
  aliases of the corresponding /views routes (same handlers)
- The OpenAPI spec marks the page operations deprecated and points new
  clients at the view routes; the v1 page-bridge-sdk.js route is restored
- The dashboard keeps calling the view endpoints by default

* refactor(dashboard): rename the plugin view path parameter to view_name

The view entry and path-token asset routes now use {view_name} instead of
{page_name} in the OpenAPI-visible path, and the service layer renames its
internal page_name variables to match. Wire-compatible surfaces stay
unchanged: the page_name JWT claim, the pageName JSON field, query
parameters, and the deprecated /pages aliases. Error messages and the
plugin view docs (zh/en) complete the Page to View rename.
This commit is contained in:
Soulter
2026-10-06 18:18:22 +08:00
committed by GitHub
parent 2de9059293
commit 8831d95025
30 changed files with 703 additions and 1271 deletions
+80 -107
View File
@@ -5,6 +5,7 @@ from collections.abc import Callable
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
@@ -16,7 +17,7 @@ from astrbot.dashboard.asgi_runtime import (
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,
@@ -38,6 +39,7 @@ from astrbot.dashboard.services.config_service import (
ConfigFileService,
)
from astrbot.dashboard.services.plugin_page_service import (
PLUGIN_PAGE_ASSET_TOKEN_TYPE,
PluginPageContentPayload,
PluginPageService,
PluginPageServiceError,
@@ -59,6 +61,47 @@ legacy_router = APIRouter(tags=["Dashboard Plugins"], include_in_schema=False)
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
@@ -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,
@@ -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),
)
@@ -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),
@@ -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(...),
@@ -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,
)
@@ -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),
@@ -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),
@@ -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")
@@ -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"),
)
@@ -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,
+11 -45
View File
@@ -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:
@@ -21,44 +23,8 @@ class PluginPageAuth:
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)
+103 -322
View File
@@ -9,7 +9,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import cast
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from urllib.parse import quote, urlencode, urlunsplit
import aiofiles
import jwt
@@ -21,7 +21,9 @@ from astrbot.core.star.star import StarMetadata
from astrbot.core.star.star_manager import PluginManager
PLUGIN_PAGE_ASSET_TOKEN_TYPE = "plugin_page_asset"
PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS = 60
# Aligned with the dashboard session lifetime (7 days): a plugin view
# cannot outlive its parent dashboard page anyway.
PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60
# Directory names that hold plugin views inside a plugin package, in
# preference order. "views" is preferred; "pages" stays as an alias.
PLUGIN_PAGE_ROOT_DIR_NAMES = ("views", "pages")
@@ -34,22 +36,6 @@ _HTML_ASSET_ATTR_RE = re.compile(
r"(?P<attr>src|href)=(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)",
re.IGNORECASE,
)
_CSS_URL_RE = re.compile(
r"url\(\s*(?P<quote>[\"\']?)(?P<url>.*?)(?P=quote)\s*\)",
re.IGNORECASE,
)
_JS_DYNAMIC_IMPORT_RE = re.compile(
r"(?P<prefix>\bimport\s*\(\s*)(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)(?P<suffix>\s*\))",
re.IGNORECASE,
)
_JS_MODULE_FROM_RE = re.compile(
r"(?P<prefix>\b(?:import|export)\s+(?:[^;]*?\s+from\s+))(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)",
re.IGNORECASE | re.DOTALL,
)
_JS_SIDE_EFFECT_IMPORT_RE = re.compile(
r"(?P<prefix>\bimport\s+)(?P<quote>[\"\'])(?P<url>[^\"'\r\n]+)(?P=quote)",
re.IGNORECASE,
)
@dataclass
@@ -178,8 +164,8 @@ class PluginPageService:
return None
plugin_name = payload.get("plugin_name")
page_name = payload.get("page_name")
if not isinstance(plugin_name, str) or not isinstance(page_name, str):
view_name = payload.get("page_name")
if not isinstance(plugin_name, str) or not isinstance(view_name, str):
return None
plugin = self.get_plugin_metadata_by_name(plugin_name)
@@ -207,15 +193,15 @@ class PluginPageService:
)
page_title = (
# "views" is the preferred i18n key prefix; "pages" stays as an alias.
self.get_by_path(locale_data, f"views.{page_name}.title")
or self.get_by_path(locale_data, f"pages.{page_name}.title")
or page_name
self.get_by_path(locale_data, f"views.{view_name}.title")
or self.get_by_path(locale_data, f"pages.{view_name}.title")
or view_name
)
return {
"pluginName": plugin.name,
"displayName": display_name,
"pageName": page_name,
"pageName": view_name,
"pageTitle": page_title,
"locale": resolved_locale,
"i18n": plugin_i18n,
@@ -226,15 +212,15 @@ class PluginPageService:
self,
*,
plugin_name: str | None,
page_name: str | None,
view_name: str | None,
jwt_secret: str | None = None,
username: str | None,
locale: str,
) -> dict:
if not plugin_name:
raise PluginPageServiceError("缺少插件名")
if not page_name:
raise PluginPageServiceError("缺少 Page 名称")
if not view_name:
raise PluginPageServiceError("缺少 View 名称")
plugin = self.get_plugin_metadata_by_name(plugin_name)
if not plugin:
@@ -244,20 +230,20 @@ class PluginPageService:
page = await self.serialize_plugin_page_for_request(
plugin,
page_name,
view_name,
include_content_path=True,
jwt_secret=jwt_secret,
username=username,
locale=locale,
)
if not page:
raise PluginPageServiceError("插件 Page 不存在")
raise PluginPageServiceError("插件 View 不存在")
return page
async def serialize_plugin_page_for_request(
self,
plugin: StarMetadata,
page_name: str,
view_name: str,
*,
include_content_path: bool = False,
jwt_secret: str | None = None,
@@ -270,7 +256,7 @@ class PluginPageService:
asset_token = (
self.issue_plugin_page_asset_token(
plugin_name=plugin_name,
page_name=page_name,
view_name=view_name,
jwt_secret=jwt_secret or self._jwt_secret(),
username=username,
locale=locale,
@@ -279,7 +265,7 @@ class PluginPageService:
)
return await self.serialize_plugin_page(
plugin,
page_name,
view_name,
include_content_path=include_content_path,
asset_token=asset_token,
)
@@ -287,7 +273,7 @@ class PluginPageService:
def prepare_plugin_page_query_params(
self,
plugin_name: str,
page_name: str,
view_name: str,
*,
asset_token: str,
jwt_secret: str | None = None,
@@ -299,7 +285,7 @@ class PluginPageService:
asset_token = (
self.issue_plugin_page_asset_token(
plugin_name=plugin_name,
page_name=page_name,
view_name=view_name,
jwt_secret=jwt_secret or self._jwt_secret(),
username=username,
locale=locale,
@@ -327,7 +313,7 @@ class PluginPageService:
) -> PluginPageContentPayload:
if not self.bridge_file.is_file():
raise PluginPageServiceError(
"Plugin Page bridge SDK not found",
"Plugin view bridge SDK not found",
status_code=404,
)
bridge_js = await self.read_plugin_page_text(self.bridge_file)
@@ -351,7 +337,7 @@ class PluginPageService:
self,
*,
plugin_name: str,
page_name: str,
view_name: str,
asset_path: str,
asset_token: str,
jwt_secret: str | None = None,
@@ -366,7 +352,7 @@ class PluginPageService:
raise PluginPageServiceError("Plugin is disabled", status_code=403)
try:
page = await self.get_plugin_page(plugin, page_name)
page = await self.get_plugin_page(plugin, view_name)
file_path = await self.resolve_plugin_page_file(
plugin,
page.name,
@@ -374,7 +360,7 @@ class PluginPageService:
)
except (FileNotFoundError, ValueError) as exc:
raise PluginPageServiceError(
"Plugin Page asset not found",
"Plugin view asset not found",
status_code=404,
) from exc
@@ -387,44 +373,21 @@ class PluginPageService:
locale=locale,
theme=theme,
)
served_asset_path = asset_path or page.entry_file
suffix = file_path.suffix.lower()
if suffix == ".html":
html_text = await self.read_plugin_page_text(file_path)
return PluginPageContentPayload(
content=self.rewrite_plugin_page_html(
content=self.process_plugin_page_html(
html_text,
plugin_name,
page.name,
served_asset_path,
theme=theme,
extra_query_params=extra_query_params,
),
content_type="text/html; charset=utf-8",
)
if suffix == ".css":
css_text = await self.read_plugin_page_text(file_path)
if suffix in {".css", ".js", ".mjs"}:
return PluginPageContentPayload(
content=self.rewrite_plugin_page_css(
css_text,
plugin_name,
page.name,
served_asset_path,
extra_query_params=extra_query_params,
),
content_type="text/css; charset=utf-8",
)
if suffix in {".js", ".mjs"}:
js_text = await self.read_plugin_page_text(file_path)
return PluginPageContentPayload(
content=self.rewrite_plugin_page_js(
js_text,
plugin_name,
page.name,
served_asset_path,
extra_query_params=extra_query_params,
),
content_type="application/javascript; charset=utf-8",
content=await self.read_plugin_page_text(file_path),
content_type=self.guess_plugin_page_mime_type(file_path),
)
return PluginPageContentPayload(
content=await self.read_plugin_page_binary(file_path),
@@ -452,40 +415,37 @@ class PluginPageService:
def normalize_plugin_page_path(
raw_path: str,
*,
base_dir: str | None = None,
allow_empty: bool = False,
) -> str:
path = raw_path.replace("\\", "/").strip()
if base_dir:
path = posixpath.join(base_dir, path)
normalized = posixpath.normpath(path)
if normalized in {"", "."}:
if allow_empty:
return ""
raise ValueError("Invalid plugin Page asset path")
raise ValueError("Invalid plugin view asset path")
if (
normalized.startswith("../")
or normalized == ".."
or normalized.startswith("/")
):
raise ValueError("Invalid plugin Page asset path")
raise ValueError("Invalid plugin view asset path")
return normalized
@staticmethod
def normalize_plugin_page_name(raw_name: str) -> str:
page_name = raw_name.strip()
if not page_name:
raise ValueError("Invalid plugin Page name")
normalized = posixpath.normpath(page_name.replace("\\", "/"))
view_name = raw_name.strip()
if not view_name:
raise ValueError("Invalid plugin view name")
normalized = posixpath.normpath(view_name.replace("\\", "/"))
if (
normalized != page_name
normalized != view_name
or normalized in {".", ".."}
or normalized.startswith(".")
or "/" in page_name
or "\\" in page_name
or "/" in view_name
or "\\" in view_name
):
raise ValueError("Invalid plugin Page name")
return page_name
raise ValueError("Invalid plugin view name")
return view_name
def get_plugin_root_dir(self, plugin: StarMetadata) -> Path:
if not plugin.root_dir_name:
@@ -528,7 +488,7 @@ class PluginPageService:
for page_dir in page_dirs:
try:
page_name = self.normalize_plugin_page_name(page_dir.name)
view_name = self.normalize_plugin_page_name(page_dir.name)
except ValueError:
continue
entry_path = page_dir / PLUGIN_PAGE_ENTRY_FILE_NAME
@@ -536,8 +496,8 @@ class PluginPageService:
continue
pages.append(
PluginPage(
name=page_name,
title=page_name,
name=view_name,
title=view_name,
entry_file=PLUGIN_PAGE_ENTRY_FILE_NAME,
)
)
@@ -546,34 +506,34 @@ class PluginPageService:
async def get_plugin_page(
self,
plugin: StarMetadata,
page_name: str,
view_name: str,
) -> PluginPage:
normalized_name = self.normalize_plugin_page_name(page_name)
normalized_name = self.normalize_plugin_page_name(view_name)
for page in await self.discover_plugin_pages(plugin):
if page.name == normalized_name:
return page
raise FileNotFoundError("Plugin Page entry not found")
raise FileNotFoundError("Plugin view entry not found")
async def resolve_plugin_page_root(
self,
plugin: StarMetadata,
page_name: str,
view_name: str,
) -> Path:
normalized_name = self.normalize_plugin_page_name(page_name)
normalized_name = self.normalize_plugin_page_name(view_name)
pages_root = await self.resolve_plugin_pages_root(plugin)
page_root = (pages_root / normalized_name).resolve(strict=False)
page_root.relative_to(pages_root)
if not await aio_ospath.isdir(str(page_root)):
raise FileNotFoundError("Plugin Page root directory does not exist")
raise FileNotFoundError("Plugin view root directory does not exist")
return page_root
async def resolve_plugin_page_file(
self,
plugin: StarMetadata,
page_name: str,
view_name: str,
asset_path: str,
) -> Path:
page = await self.get_plugin_page(plugin, page_name)
page = await self.get_plugin_page(plugin, view_name)
page_root = await self.resolve_plugin_page_root(plugin, page.name)
target_name = (
self.normalize_plugin_page_path(asset_path, allow_empty=True)
@@ -582,83 +542,32 @@ class PluginPageService:
target_path = (page_root / target_name).resolve(strict=False)
target_path.relative_to(page_root)
if not await aio_ospath.isfile(str(target_path)):
raise FileNotFoundError("Plugin Page asset not found")
raise FileNotFoundError("Plugin view asset not found")
return target_path
@staticmethod
def is_rewritable_asset_url(raw_url: str) -> bool:
value = raw_url.strip()
lower = value.lower()
if not value:
return False
if value.startswith(("#", "/#")):
return False
if lower.startswith(
(
"http://",
"https://",
"//",
"data:",
"javascript:",
"mailto:",
"tel:",
"blob:",
)
):
return False
return True
@staticmethod
def resolve_referenced_asset_path(
base_asset_path: str,
referenced_url: str,
) -> str:
parts = urlsplit(referenced_url)
referenced_path = parts.path.strip()
if not referenced_path:
raise ValueError("Plugin Page referenced asset path is empty")
base_dir = posixpath.dirname(base_asset_path) if base_asset_path else ""
normalized = PluginPageService.normalize_plugin_page_path(
referenced_path,
base_dir=base_dir,
)
if not normalized:
raise ValueError("Plugin Page referenced asset path is invalid")
return normalized
def build_plugin_page_asset_url(
self,
def build_plugin_page_view_content_path(
plugin_name: str,
page_name: str,
asset_path: str,
original_query: str = "",
original_fragment: str = "",
extra_query_params: dict[str, str] | None = None,
) -> str:
path = self.build_plugin_page_content_path(plugin_name, page_name, asset_path)
query_dict = dict(parse_qsl(original_query, keep_blank_values=True))
if extra_query_params:
for key, value in extra_query_params.items():
if value:
query_dict[key] = value
query = urlencode(query_dict)
return urlunsplit(("", "", path, query, original_fragment))
@staticmethod
def build_plugin_page_content_path(
plugin_name: str,
page_name: str,
view_name: str,
token: str,
asset_path: str = "",
) -> str:
"""Build a path-token view content URL.
The token travels in the path so relative URLs inside the view inherit
it through normal URL resolution, without content rewriting.
"""
encoded_plugin_name = quote(plugin_name, safe="")
encoded_page_name = quote(
PluginPageService.normalize_plugin_page_name(page_name),
encoded_view_name = quote(
PluginPageService.normalize_plugin_page_name(view_name),
safe="",
)
base = (
f"/api/v1/plugins/{encoded_plugin_name}/views/"
f"{encoded_view_name}/_t/{quote(token, safe='')}"
)
if not asset_path:
return (
f"/api/plugin/page/content/{encoded_plugin_name}/{encoded_page_name}/"
)
return base + "/"
safe_asset_path = PluginPageService.normalize_plugin_page_path(
asset_path,
allow_empty=True,
@@ -666,10 +575,7 @@ class PluginPageService:
encoded_path = "/".join(
quote(part, safe="") for part in safe_asset_path.split("/")
)
return (
f"/api/plugin/page/content/{encoded_plugin_name}/"
f"{encoded_page_name}/{encoded_path}"
)
return f"{base}/{encoded_path}"
@staticmethod
def get_plugin_page_bridge_sdk_url(
@@ -678,170 +584,39 @@ class PluginPageService:
query = urlencode(extra_query_params or {})
return urlunsplit(("", "", "/api/plugin/page/bridge-sdk.js", query, ""))
@staticmethod
def is_js_relative_module_specifier(raw_url: str) -> bool:
value = raw_url.strip()
return value.startswith(("./", "../", "/"))
def rewrite_relative_asset_url(
self,
raw_url: str,
base_asset_path: str,
plugin_name: str,
page_name: str,
extra_query_params: dict[str, str] | None = None,
) -> str | None:
candidate = raw_url.strip()
if not self.is_rewritable_asset_url(candidate):
return None
parts = urlsplit(candidate)
asset_path = self.resolve_referenced_asset_path(base_asset_path, candidate)
return self.build_plugin_page_asset_url(
plugin_name,
page_name,
asset_path,
original_query=parts.query,
original_fragment=parts.fragment,
extra_query_params=extra_query_params,
)
def rewrite_plugin_page_html(
def process_plugin_page_html(
self,
html_text: str,
plugin_name: str,
page_name: str,
entry_asset_path: str,
*,
theme: str | None,
extra_query_params: dict[str, str] | None = None,
) -> str:
def replace_attr(match: re.Match[str]) -> str:
"""Process view HTML before serving.
Applies the theme and injects (or retargets) the bridge SDK script.
Relative asset URLs are left untouched: view assets are served from
path-token URLs, so relative references resolve correctly on their own.
"""
def replace_bridge_url(match: re.Match[str]) -> str:
raw_url = match.group("url")
attr = match.group("attr")
quote_char = match.group("quote")
if raw_url.strip() == "/api/plugin/page/bridge-sdk.js":
url = self.get_plugin_page_bridge_sdk_url(extra_query_params)
return f"{attr}={quote_char}{url}{quote_char}"
if not self.is_rewritable_asset_url(raw_url):
if raw_url.strip() != "/api/plugin/page/bridge-sdk.js":
return match.group(0)
url = self.get_plugin_page_bridge_sdk_url(extra_query_params)
return f"{match.group('attr')}={match.group('quote')}{url}{match.group('quote')}"
try:
rewritten_url = self.rewrite_relative_asset_url(
raw_url,
entry_asset_path,
plugin_name,
page_name,
extra_query_params=extra_query_params,
)
if not rewritten_url:
return match.group(0)
return f"{attr}={quote_char}{rewritten_url}{quote_char}"
except ValueError:
return match.group(0)
rewritten_html = _HTML_ASSET_ATTR_RE.sub(replace_attr, html_text)
processed_html = _HTML_ASSET_ATTR_RE.sub(replace_bridge_url, html_text)
if theme:
rewritten_html = self.apply_theme_to_html(rewritten_html, theme)
if "/api/plugin/page/bridge-sdk.js" not in rewritten_html:
processed_html = self.apply_theme_to_html(processed_html, theme)
if "/api/plugin/page/bridge-sdk.js" not in processed_html:
bridge_tag = f'<script src="{self.get_plugin_page_bridge_sdk_url(extra_query_params)}"></script>'
if "</body>" in rewritten_html:
rewritten_html = rewritten_html.replace(
if "</body>" in processed_html:
processed_html = processed_html.replace(
"</body>", f"{bridge_tag}</body>", 1
)
else:
rewritten_html += bridge_tag
return rewritten_html
def rewrite_plugin_page_css(
self,
css_text: str,
plugin_name: str,
page_name: str,
css_asset_path: str,
extra_query_params: dict[str, str] | None = None,
) -> str:
def replace_url(match: re.Match[str]) -> str:
raw_url = match.group("url").strip()
quote_char = match.group("quote") or ""
try:
rewritten_url = self.rewrite_relative_asset_url(
raw_url,
css_asset_path,
plugin_name,
page_name,
extra_query_params=extra_query_params,
)
if not rewritten_url:
return match.group(0)
return f"url({quote_char}{rewritten_url}{quote_char})"
except ValueError:
return match.group(0)
return _CSS_URL_RE.sub(replace_url, css_text)
def rewrite_plugin_page_js(
self,
js_text: str,
plugin_name: str,
page_name: str,
js_asset_path: str,
extra_query_params: dict[str, str] | None = None,
) -> str:
def rewrite_specifier(raw_url: str) -> str:
if not self.is_js_relative_module_specifier(raw_url):
return raw_url
if not self.is_rewritable_asset_url(raw_url):
return raw_url
rewritten = self.rewrite_relative_asset_url(
raw_url,
js_asset_path,
plugin_name,
page_name,
extra_query_params=extra_query_params,
)
return rewritten or raw_url
def replace_dynamic(match: re.Match[str]) -> str:
raw_url = match.group("url")
try:
rewritten = rewrite_specifier(raw_url)
except ValueError:
return match.group(0)
return (
f"{match.group('prefix')}{match.group('quote')}{rewritten}"
f"{match.group('quote')}{match.group('suffix')}"
)
def replace_from(match: re.Match[str]) -> str:
raw_url = match.group("url")
try:
rewritten = rewrite_specifier(raw_url)
except ValueError:
return match.group(0)
return (
f"{match.group('prefix')}{match.group('quote')}"
f"{rewritten}{match.group('quote')}"
)
rewritten_js = _JS_DYNAMIC_IMPORT_RE.sub(replace_dynamic, js_text)
rewritten_js = _JS_MODULE_FROM_RE.sub(replace_from, rewritten_js)
def replace_side_effect(match: re.Match[str]) -> str:
raw_url = match.group("url")
if raw_url.startswith(("{", "*")):
return match.group(0)
try:
rewritten = rewrite_specifier(raw_url)
except ValueError:
return match.group(0)
return (
f"{match.group('prefix')}{match.group('quote')}"
f"{rewritten}{match.group('quote')}"
)
return _JS_SIDE_EFFECT_IMPORT_RE.sub(replace_side_effect, rewritten_js)
processed_html += bridge_tag
return processed_html
@staticmethod
async def read_plugin_page_text(file_path: Path) -> str:
@@ -860,7 +635,7 @@ class PluginPageService:
async def serialize_plugin_page(
self,
plugin: StarMetadata,
page_name: str,
view_name: str,
*,
include_content_path: bool = False,
asset_token: str = "",
@@ -869,7 +644,7 @@ class PluginPageService:
if not plugin_name:
return None
try:
page = await self.get_plugin_page(plugin, page_name)
page = await self.get_plugin_page(plugin, view_name)
await self.resolve_plugin_page_file(plugin, page.name, "")
except (FileNotFoundError, ValueError):
return None
@@ -879,13 +654,16 @@ class PluginPageService:
"title": page.title,
"i18n_key": f"pages.{page.name}",
}
if include_content_path:
extra_query_params = {"asset_token": asset_token} if asset_token else None
page_data["content_path"] = self.build_plugin_page_asset_url(
plugin_name,
page.name,
"",
extra_query_params=extra_query_params,
if include_content_path and asset_token:
page_data["content_path"] = (
self.build_plugin_page_view_content_path(
plugin_name,
page.name,
asset_token,
)
# Kept as a query echo for view scripts that read the token
# from location.search.
+ f"?asset_token={quote(asset_token, safe='')}"
)
return page_data
@@ -901,7 +679,7 @@ class PluginPageService:
self,
*,
plugin_name: str,
page_name: str,
view_name: str,
jwt_secret: str | None = None,
username: str | None,
locale: str,
@@ -916,8 +694,11 @@ class PluginPageService:
payload = {
"username": username,
"token_type": PLUGIN_PAGE_ASSET_TOKEN_TYPE,
# Distinguishes long-lived view-session tokens from future
# one-shot presigned tokens (e.g. direct downloads).
"purpose": "page_session",
"plugin_name": plugin_name,
"page_name": page_name,
"page_name": view_name,
"locale": locale,
"iat": now,
"exp": now + timedelta(seconds=PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS),
File diff suppressed because one or more lines are too long
@@ -1934,39 +1934,6 @@ export type SetPluginEnabledByIdResponse = (SuccessEnvelope);
export type SetPluginEnabledByIdError = unknown;
export type ListPluginPagesByIdData = {
query: {
plugin_id: string;
};
};
export type ListPluginPagesByIdResponse = (SuccessEnvelope);
export type ListPluginPagesByIdError = unknown;
export type GetPluginPageByIdData = {
query: {
page_name: string;
plugin_id: string;
};
};
export type GetPluginPageByIdResponse = (string);
export type GetPluginPageByIdError = unknown;
export type GetPluginPageAssetByIdData = {
query: {
asset_path: string;
page_name: string;
plugin_id: string;
};
};
export type GetPluginPageAssetByIdResponse = (unknown);
export type GetPluginPageAssetByIdError = unknown;
export type GetPluginData = {
body?: {
delete_config?: boolean;
@@ -2297,42 +2264,6 @@ export type DeletePluginSourceByIdResponse = (SuccessEnvelope);
export type DeletePluginSourceByIdError = unknown;
export type ListPluginPagesData = {
path: {
plugin_id: string;
};
};
export type ListPluginPagesResponse = (SuccessEnvelope);
export type ListPluginPagesError = unknown;
export type GetPluginPageData = {
path: {
page_name: string;
plugin_id: string;
};
};
export type GetPluginPageResponse = (string);
export type GetPluginPageError = unknown;
export type GetPluginPageAssetData = {
path: {
/**
* URL-encoded relative asset path.
*/
asset_path: string;
page_name: string;
plugin_id: string;
};
};
export type GetPluginPageAssetResponse = (unknown);
export type GetPluginPageAssetError = unknown;
export type ListPluginViewsByIdData = {
query: {
plugin_id: string;
@@ -2343,6 +2274,16 @@ export type ListPluginViewsByIdResponse = (SuccessEnvelope);
export type ListPluginViewsByIdError = unknown;
export type ListPluginPagesByIdData = {
query: {
plugin_id: string;
};
};
export type ListPluginPagesByIdResponse = (SuccessEnvelope);
export type ListPluginPagesByIdError = unknown;
export type GetPluginViewByIdData = {
query: {
page_name: string;
@@ -2354,17 +2295,16 @@ export type GetPluginViewByIdResponse = (string);
export type GetPluginViewByIdError = unknown;
export type GetPluginViewAssetByIdData = {
export type GetPluginPageByIdData = {
query: {
asset_path: string;
page_name: string;
plugin_id: string;
};
};
export type GetPluginViewAssetByIdResponse = (unknown);
export type GetPluginPageByIdResponse = (string);
export type GetPluginViewAssetByIdError = unknown;
export type GetPluginPageByIdError = unknown;
export type ListPluginViewsData = {
path: {
@@ -2376,10 +2316,20 @@ export type ListPluginViewsResponse = (SuccessEnvelope);
export type ListPluginViewsError = unknown;
export type ListPluginPagesData = {
path: {
plugin_id: string;
};
};
export type ListPluginPagesResponse = (SuccessEnvelope);
export type ListPluginPagesError = unknown;
export type GetPluginViewData = {
path: {
page_name: string;
plugin_id: string;
view_name: string;
};
};
@@ -2387,20 +2337,35 @@ export type GetPluginViewResponse = (string);
export type GetPluginViewError = unknown;
export type GetPluginViewAssetData = {
export type GetPluginPageData = {
path: {
plugin_id: string;
view_name: string;
};
};
export type GetPluginPageResponse = (string);
export type GetPluginPageError = unknown;
export type GetPluginViewTokenAssetData = {
path: {
/**
* URL-encoded relative asset path.
*/
asset_path: string;
page_name: string;
plugin_id: string;
/**
* Scoped plugin page asset token.
*/
token: string;
view_name: string;
};
};
export type GetPluginViewAssetResponse = (unknown);
export type GetPluginViewTokenAssetResponse = (unknown);
export type GetPluginViewAssetError = unknown;
export type GetPluginViewTokenAssetError = (unknown);
export type GetPluginPageBridgeSdkResponse = (string);
+1 -1
View File
@@ -1425,7 +1425,7 @@ export const pluginApi = {
},
page(pluginId: string, pageName: string) {
return typed<any>(
openApiV1.getPluginPageById({
openApiV1.getPluginViewById({
query: { plugin_id: pluginId, page_name: pageName },
}) as any,
);
@@ -38,7 +38,7 @@ const emit = defineEmits([
"view-readme",
"view-changelog",
"toggle-pin",
"open-webui",
"open-view",
"change-source",
]);
@@ -179,8 +179,8 @@ const togglePin = () => {
emit("toggle-pin", props.extension);
};
const openWebui = () => {
emit("open-webui", props.extension);
const openView = () => {
emit("open-view", props.extension);
};
</script>
@@ -403,7 +403,7 @@ const openWebui = () => {
<v-tooltip
v-if="hasPages"
location="top"
:text="tm('buttons.openWebui')"
:text="tm('buttons.openView')"
>
<template v-slot:activator="{ props: actionProps }">
<v-btn
@@ -413,7 +413,7 @@ const openWebui = () => {
variant="tonal"
color="primary"
:disabled="!extension.activated"
@click.stop="openWebui"
@click.stop="openView"
></v-btn>
</template>
</v-tooltip>
@@ -3,7 +3,7 @@ import { pluginApi } from "@/api/v1";
import type { menu } from "@/layouts/full/vertical-sidebar/sidebarItem";
const DEFAULT_ICON = "mdi-puzzle";
const GROUP_I18N_KEY = "core.navigation.pluginWebui";
const GROUP_I18N_KEY = "core.navigation.pluginView";
const GROUP_ICON = "mdi-puzzle-outline";
interface PluginEntry {
@@ -63,6 +63,6 @@
"normal": "Normal Config",
"system": "System Config"
},
"pluginWebui": "Plugin Views",
"pluginPageType": "Plugin View"
"pluginView": "Plugin Views",
"pluginViewType": "Plugin View"
}
@@ -47,7 +47,7 @@
"viewRepo": "Repository",
"openPages": "Pages",
"openPage": "Open",
"openWebui": "Open Plugin UI",
"openView": "Open View",
"close": "Close",
"save": "Save",
"saveAndClose": "Save and Close",
@@ -305,9 +305,9 @@
"invalidRepositoryUrl": "Please enter a valid Git repository URL",
"enterJsonUrl": "Please enter a URL that returns plugin list JSON data",
"pluginNotFound": "Plugin not found",
"pluginDisabled": "This plugin is closed. Open it before accessing its page.",
"pluginPageNotFound": "Plugin view not found",
"pluginPageLoadFailed": "Failed to load the plugin view",
"pluginDisabled": "This plugin is closed. Open it before accessing its view.",
"pluginViewNotFound": "Plugin view not found",
"pluginViewLoadFailed": "Failed to load the plugin view",
"missingMarketPluginId": "This source record is missing legal plugin identifiers, so it cannot be installed from the market",
"sourceBindSuccess": "Plugin source updated",
"validatingPlugin": "Validating plugin...",
@@ -63,6 +63,6 @@
"normal": "一般設定",
"system": "システム設定"
},
"pluginWebui": "プラグインビュー",
"pluginPageType": "プラグインビュー"
"pluginView": "プラグインビュー",
"pluginViewType": "プラグインビュー"
}
@@ -47,7 +47,7 @@
"viewRepo": "リポジトリ",
"openPages": "Pages",
"openPage": "開く",
"openWebui": "プラグイン UI を開く",
"openView": "ビューを開く",
"close": "閉じる",
"save": "保存",
"saveAndClose": "保存して閉じる",
@@ -306,8 +306,8 @@
"enterJsonUrl": "プラグイン一覧の JSON データを返す URL を入力してください",
"pluginNotFound": "プラグインが見つかりません",
"pluginDisabled": "このプラグインは無効です。ページにアクセスする前に有効化してください。",
"pluginPageNotFound": "プラグインビューが見つかりません",
"pluginPageLoadFailed": "プラグインビューの読み込みに失敗しました",
"pluginViewNotFound": "プラグインビューが見つかりません",
"pluginViewLoadFailed": "プラグインビューの読み込みに失敗しました",
"missingMarketPluginId": "有効なプラグイン識別子がないため、このプラグインマーケットのソースからインストールできません",
"sourceBindSuccess": "プラグインソースを更新しました",
"validatingPlugin": "プラグインを検証中…",
@@ -63,6 +63,6 @@
"normal": "Обычная конфигурация",
"system": "Системная конфигурация"
},
"pluginWebui": "Представления плагинов",
"pluginPageType": "Представление плагина"
"pluginView": "Представления плагинов",
"pluginViewType": "Представление плагина"
}
@@ -47,7 +47,7 @@
"viewRepo": "Репозиторий",
"openPages": "Pages",
"openPage": "Открыть",
"openWebui": "Открыть UI плагина",
"openView": "Открыть представление",
"close": "Закрыть",
"save": "Сохранить",
"saveAndClose": "Сохранить и закрыть",
@@ -306,8 +306,8 @@
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON",
"pluginNotFound": "Плагин не найден",
"pluginDisabled": "Этот плагин сейчас отключён. Включите его, чтобы открыть страницу.",
"pluginPageNotFound": "Представление плагина не найдено",
"pluginPageLoadFailed": "Не удалось загрузить представление плагина",
"pluginViewNotFound": "Представление плагина не найдено",
"pluginViewLoadFailed": "Не удалось загрузить представление плагина",
"missingMarketPluginId": "Эта запись источника не содержит идентификаторов плагина, поэтому его нельзя установить из магазина",
"sourceBindSuccess": "Источник плагина обновлен",
"validatingPlugin": "Проверка плагина...",
@@ -63,6 +63,6 @@
"normal": "普通配置",
"system": "系统配置"
},
"pluginWebui": "插件可视化视图",
"pluginPageType": "插件可视化视图"
"pluginView": "插件可视化视图",
"pluginViewType": "插件可视化视图"
}
@@ -47,7 +47,7 @@
"viewRepo": "仓库",
"openPages": "Pages",
"openPage": "打开",
"openWebui": "打开插件UI界面",
"openView": "打开视图",
"close": "关闭",
"save": "保存",
"saveAndClose": "保存并关闭",
@@ -305,9 +305,9 @@
"invalidRepositoryUrl": "请输入有效的 Git 仓库链接",
"enterJsonUrl": "请输入返回插件列表JSON数据的URL地址",
"pluginNotFound": "未找到该插件",
"pluginDisabled": "该插件当前已关闭,请先打开后再访问页面",
"pluginPageNotFound": "未找到该插件可视化视图",
"pluginPageLoadFailed": "加载插件可视化视图失败",
"pluginDisabled": "该插件当前已关闭,请先打开后再访问视图",
"pluginViewNotFound": "未找到该插件可视化视图",
"pluginViewLoadFailed": "加载插件可视化视图失败",
"missingMarketPluginId": "缺少合法的插件标识,无法从该插件市场源安装",
"sourceBindSuccess": "插件源已更新",
"validatingPlugin": "正在校验插件...",
+3 -3
View File
@@ -23,7 +23,7 @@ const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(
() => route.path === "/chat" || route.path.startsWith("/chat/"),
);
const isPluginPageRoute = computed(
const isPluginViewRoute = computed(
() => route.path.startsWith("/plugin-view/") || route.path.startsWith("/plugin-page/"),
);
const isProviderPageRoute = computed(() => route.path === "/providers");
@@ -35,7 +35,7 @@ const isViewportLockedRoute = computed(
isPlatformPageRoute.value,
);
const isFullScreenRoute = computed(
() => isCurrentChatRoute.value || isPluginPageRoute.value,
() => isCurrentChatRoute.value || isPluginViewRoute.value,
);
const shouldMountChat = ref(isCurrentChatRoute.value);
@@ -160,7 +160,7 @@ onMounted(() => {
height: '100%',
width: '100%',
overflow: isViewportLockedRoute ? 'hidden' : undefined,
position: isPluginPageRoute ? 'relative' : undefined,
position: isPluginViewRoute ? 'relative' : undefined,
}"
>
<div
@@ -182,7 +182,7 @@ const isVuetifyIcon = computed(() => typeof props.item?.icon === 'string');
</div>
<div class="plugin-hover-card__row">
<AppWindow :size="13" class="plugin-hover-card__icon" />
<span>{{ t('core.navigation.pluginPageType') }}</span>
<span>{{ t('core.navigation.pluginViewType') }}</span>
</div>
</div>
</v-tooltip>
+1 -1
View File
@@ -99,7 +99,7 @@ const MainRoutes = {
{
name: 'PluginView',
path: '/plugin-view/:pluginName/:pageName',
component: () => import('@/views/PluginPagePage.vue')
component: () => import('@/views/PluginViewPage.vue')
},
{
// Legacy alias of the plugin view route.
+4 -4
View File
@@ -79,7 +79,7 @@ export function usePluginI18n() {
return resolve(plugin?.i18n, `${base}.${attr}`, fallback);
};
const pluginPageTitle = (plugin, page, fallback = "") => {
const pluginViewTitle = (plugin, page, fallback = "") => {
const pageFallback =
fallback ||
(page && typeof page === "object"
@@ -89,7 +89,7 @@ export function usePluginI18n() {
return pluginPageText(plugin, page, "title", pageFallback);
};
const pluginPageDescription = (plugin, page, fallback = "") => {
const pluginViewDescription = (plugin, page, fallback = "") => {
const pageFallback =
fallback ||
(page && typeof page === "object" ? page.description || page.desc : "") ||
@@ -109,8 +109,8 @@ export function usePluginI18n() {
pluginDesc,
pluginShortDesc,
pluginPageText,
pluginPageTitle,
pluginPageDescription,
pluginViewTitle,
pluginViewDescription,
configText,
};
}
@@ -15,7 +15,7 @@ const { tm } = useModuleI18n("features/extension");
const {
locale,
pluginName: pluginDisplayName,
pluginPageTitle,
pluginViewTitle,
} = usePluginI18n();
const customizer = useCustomizerStore();
@@ -32,7 +32,7 @@ let iframeMessageOrigin = null;
const pluginName = computed(() => String(route.params.pluginName || ""));
const pageName = computed(() => String(route.params.pageName || ""));
const localizedPageTitle = computed(() =>
pluginPageTitle(
pluginViewTitle(
plugin.value,
page.value || pageName.value,
page.value?.title || pageName.value || tm("buttons.openPages"),
@@ -551,7 +551,7 @@ const handleIframeLoad = () => {
sendIframeContext();
};
const loadPluginPage = async () => {
const loadPluginView = async () => {
loading.value = true;
errorMessage.value = "";
plugin.value = null;
@@ -564,7 +564,7 @@ const loadPluginPage = async () => {
const detailResponse = await pluginApi.get(pluginName.value);
if (detailResponse.data?.status === "error") {
throw new Error(
detailResponse.data.message || tm("messages.pluginPageLoadFailed"),
detailResponse.data.message || tm("messages.pluginViewLoadFailed"),
);
}
@@ -582,7 +582,7 @@ const loadPluginPage = async () => {
const entryResponse = await pluginApi.page(pluginName.value, pageName.value);
if (entryResponse.data?.status === "error") {
throw new Error(
entryResponse.data.message || tm("messages.pluginPageLoadFailed"),
entryResponse.data.message || tm("messages.pluginViewLoadFailed"),
);
}
@@ -592,7 +592,7 @@ const loadPluginPage = async () => {
typeof pageEntry.content_path !== "string" ||
!pageEntry.content_path.length
) {
errorMessage.value = tm("messages.pluginPageNotFound");
errorMessage.value = tm("messages.pluginViewNotFound");
return;
}
@@ -605,7 +605,7 @@ const loadPluginPage = async () => {
errorMessage.value =
error?.response?.data?.message ||
error?.message ||
tm("messages.pluginPageLoadFailed");
tm("messages.pluginViewLoadFailed");
} finally {
loading.value = false;
}
@@ -620,7 +620,7 @@ onBeforeUnmount(() => {
cleanupSSEConnections();
});
watch([pluginName, pageName], loadPluginPage, { immediate: true });
watch([pluginName, pageName], loadPluginView, { immediate: true });
watch(locale, () => {
sendIframeContext();
});
@@ -630,13 +630,13 @@ watch(() => customizer.uiTheme, () => {
</script>
<template>
<div class="plugin-page-page">
<div v-if="loading" class="plugin-page-state">
<div class="plugin-view-page">
<div v-if="loading" class="plugin-view-state">
<v-progress-circular indeterminate color="primary" />
<span>{{ tm("status.loading") }}</span>
</div>
<div v-else-if="errorMessage" class="plugin-page-state">
<div v-else-if="errorMessage" class="plugin-view-state">
<v-alert type="error" variant="tonal" class="ma-6">
{{ errorMessage }}
</v-alert>
@@ -646,7 +646,7 @@ watch(() => customizer.uiTheme, () => {
v-else
ref="iframeRef"
:src="iframeSrc"
class="plugin-page-frame"
class="plugin-view-frame"
referrerpolicy="no-referrer"
sandbox="allow-scripts allow-forms allow-downloads"
@load="handleIframeLoad"
@@ -655,7 +655,7 @@ watch(() => customizer.uiTheme, () => {
</template>
<style scoped>
.plugin-page-page {
.plugin-view-page {
position: absolute;
top: 0;
left: 0;
@@ -665,14 +665,14 @@ watch(() => customizer.uiTheme, () => {
flex-direction: column;
}
.plugin-page-frame {
.plugin-view-frame {
width: 100%;
flex: 1;
border: 0;
background: transparent;
}
.plugin-page-state {
.plugin-view-state {
flex: 1;
display: flex;
align-items: center;
@@ -155,11 +155,11 @@ const openPluginDetail = (extension) => {
});
};
const openPluginWebui = (extension) => {
const openPluginView = (extension) => {
const pages = extension?.pages;
if (!Array.isArray(pages) || pages.length === 0 || !extension?.name) return;
router.push({
name: "PluginPage",
name: "PluginView",
params: {
pluginName: extension.name,
pageName: pages[0],
@@ -381,7 +381,7 @@ const togglePinnedExtension = (extension) => {
@view-handlers="showPluginInfo(extension)"
@view-readme="viewReadme(extension)"
@view-changelog="viewChangelog(extension)"
@open-webui="openPluginWebui(extension)"
@open-view="openPluginView(extension)"
@change-source="openPluginSourceBindingDialog(extension)"
>
</ExtensionCard>
@@ -40,8 +40,8 @@ const { locale } = useI18n();
const {
pluginName,
pluginDesc: resolvePluginDesc,
pluginPageTitle,
pluginPageDescription,
pluginViewTitle,
pluginViewDescription,
} = usePluginI18n();
const markdown = new MarkdownIt({
@@ -463,7 +463,7 @@ const getHandlerCommand = (handler) =>
const getHandlerDisplayName = (handler, groupKey) => {
if (groupKey === "page") {
return pluginPageTitle(
return pluginViewTitle(
pluginData.value,
handler,
handler?.title ||
@@ -501,7 +501,7 @@ const getComponentDescription = (component) => {
component?.description || component?.desc || tm("status.unknown");
if (getComponentGroupKey(component) === "page") {
return String(
pluginPageDescription(pluginData.value, component, fallback),
pluginViewDescription(pluginData.value, component, fallback),
).trim();
}
return String(fallback).trim();
@@ -512,7 +512,7 @@ const openComponentPage = (component) => {
const targetPageName = component?.page_name || component?.name;
if (!targetPluginName || !targetPageName) return;
router.push({
name: "PluginPage",
name: "PluginView",
params: {
pluginName: targetPluginName,
pageName: targetPageName,
+2 -6
View File
@@ -254,7 +254,6 @@ Manage plugins, plugin configuration, plugin sources, and marketplace data.
| `GET` | `/api/v1/plugins/market/categories` | — |
| `GET` | `/api/v1/plugins/page` | — |
| `GET` | `/api/v1/plugins/page-bridge-sdk.js` | — |
| `GET` | `/api/v1/plugins/page/assets` | — |
| `GET` | `/api/v1/plugins/pages` | — |
| `GET` | `/api/v1/plugins/readme` | — |
| `POST` | `/api/v1/plugins/reload` | — |
@@ -262,7 +261,6 @@ Manage plugins, plugin configuration, plugin sources, and marketplace data.
| `POST` | `/api/v1/plugins/validate/repo` | — |
| `POST` | `/api/v1/plugins/version-support/check` | — |
| `GET` | `/api/v1/plugins/view` | — |
| `GET` | `/api/v1/plugins/view/assets` | — |
| `GET` | `/api/v1/plugins/views` | — |
| `GET` | `/api/v1/plugins/{plugin_id}` | — |
| `DELETE` | `/api/v1/plugins/{plugin_id}` | — |
@@ -276,15 +274,13 @@ Manage plugins, plugin configuration, plugin sources, and marketplace data.
| `PATCH` | `/api/v1/plugins/{plugin_id}/enabled` | — |
| `PUT` | `/api/v1/plugins/{plugin_id}/log-level` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{view_name}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/readme` | — |
| `POST` | `/api/v1/plugins/{plugin_id}/reload` | — |
| `POST` | `/api/v1/plugins/{plugin_id}/source` | — |
| `POST` | `/api/v1/plugins/{plugin_id}/update` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views/{page_name}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views/{page_name}/assets/{asset_path}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views/{view_name}` | — |
## `mcp`
+7 -13
View File
@@ -7,7 +7,7 @@ If you only need a small set of editable settings, prefer [`_conf_schema.json`](
## Directory Layout
Each direct child directory under `views/` is one View. AstrBot only discovers `views/<page_name>/index.html`; directories without `index.html` are ignored.
Each direct child directory under `views/` is one View. AstrBot only discovers `views/<view_name>/index.html`; directories without `index.html` are ignored.
```text
astrbot_plugin_view_demo/
@@ -23,13 +23,13 @@ astrbot_plugin_view_demo/
└─ index.html
```
Use simple directory names for `page_name`, such as `settings` or `bridge-demo`. Do not use an empty name, `.`, `..`, a name starting with `.`, or a name containing `/` or `\`.
Use simple directory names for `view_name`, such as `settings` or `bridge-demo`. Do not use an empty name, `.`, `..`, a name starting with `.`, or a name containing `/` or `\`.
Users open views from the plugin detail page in the WebUI.
## Development Flow
1. Create `views/<page_name>/index.html` in the plugin directory.
1. Create `views/<view_name>/index.html` in the plugin directory.
2. Use the `window.AstrBotPluginView` bridge from the View.
3. Register backend APIs with `context.register_web_api()` in `main.py`.
4. Read requests and return responses with `astrbot.api.web`.
@@ -540,7 +540,7 @@ window.addEventListener("beforeunload", () => {
## View Internationalization
Plugin Views reuse plugin i18n resource files. Add `views.<page_name>` to `.astrbot-plugin/i18n/<locale>.json`:
Plugin Views reuse plugin i18n resource files. Add `views.<view_name>` to `.astrbot-plugin/i18n/<locale>.json`:
```json
{
@@ -611,15 +611,9 @@ Use normal relative paths:
<img src="./assets/logo.svg" alt="" />
```
AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Do not hardcode `/api/plugin/page/content/...`, append `asset_token` yourself, or rely on `..` to escape the View root.
AstrBot authenticates view assets with a path token: the `content_path` returned by the entry config endpoint looks like `/api/v1/plugins/<plugin>/views/<view>/_t/<token>/`, and relative URLs inside the page resolve under that prefix and inherit the token through normal URL resolution. Content is served as-is, without rewriting. The token lives as long as the dashboard session and is scoped to exactly that plugin and view.
AstrBot rewrites:
- HTML `src` and `href`
- CSS `url(...)`
- JavaScript `import`
- JavaScript `export ... from`
- JavaScript dynamic `import()`
Do not hardcode content URLs, append `asset_token` yourself, or rely on `..` to escape the View root. The legacy `/api/plugin/page/content/...?asset_token=...` form has been removed.
If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires a real file at that path.
@@ -644,7 +638,7 @@ Backend handlers must still validate input. Do not trust paths, filenames, forma
## Debugging Tips
- View is missing: check that `views/<page_name>/index.html` exists, the plugin is enabled, and the plugin detail page has been refreshed.
- View is missing: check that `views/<view_name>/index.html` exists, the plugin is enabled, and the plugin detail page has been refreshed.
- Bridge is missing: make sure your script runs after the bridge SDK is injected; external `type="module"` scripts are recommended.
- API is not matched: make sure the registered route includes the plugin name prefix, such as `/{PLUGIN_NAME}/stats`, while the View endpoint is `stats`.
- Query or JSON is empty: pass GET values through `apiGet(endpoint, params)` and POST JSON through `apiPost(endpoint, body)`.
+83 -239
View File
@@ -116,7 +116,7 @@
"name": "Plugin Sources"
},
{
"name": "Plugin Pages"
"name": "Plugin Views"
},
{
"name": "MCP"
@@ -3294,108 +3294,6 @@
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/pages": {
"get": {
"tags": [
"Plugin Pages"
],
"summary": "List plugin pages by query ID",
"operationId": "listPluginPagesById",
"x-astrbot-scope": "plugin",
"parameters": [
{
"name": "plugin_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
},
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/page": {
"get": {
"tags": [
"Plugin Pages"
],
"summary": "Get plugin page entry HTML by query ID",
"operationId": "getPluginPageById",
"x-astrbot-scope": "plugin",
"parameters": [
{
"name": "plugin_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "page_name",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Html"
}
},
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/page/assets": {
"get": {
"tags": [
"Plugin Pages"
],
"summary": "Get a plugin page asset by query ID",
"operationId": "getPluginPageAssetById",
"x-astrbot-scope": "plugin",
"parameters": [
{
"name": "plugin_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "page_name",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "asset_path",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Plugin asset content"
}
},
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/{plugin_id}": {
"get": {
"tags": [
@@ -4310,100 +4208,12 @@
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/{plugin_id}/pages": {
"get": {
"tags": [
"Plugin Pages"
],
"summary": "List plugin pages",
"operationId": "listPluginPages",
"x-astrbot-scope": "plugin",
"parameters": [
{
"$ref": "#/components/parameters/PluginId"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
},
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/{plugin_id}/pages/{page_name}": {
"get": {
"tags": [
"Plugin Pages"
],
"summary": "Get plugin page entry HTML",
"operationId": "getPluginPage",
"x-astrbot-scope": "plugin",
"parameters": [
{
"$ref": "#/components/parameters/PluginId"
},
{
"name": "page_name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Html"
}
},
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}": {
"get": {
"tags": [
"Plugin Pages"
],
"summary": "Get a plugin page asset",
"operationId": "getPluginPageAsset",
"x-astrbot-scope": "plugin",
"parameters": [
{
"$ref": "#/components/parameters/PluginId"
},
{
"name": "page_name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "asset_path",
"in": "path",
"required": true,
"description": "URL-encoded relative asset path.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Plugin asset content"
}
},
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/views": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "List plugin views by query ID (alias of /plugins/pages)",
"summary": "List plugin views by query ID",
"operationId": "listPluginViewsById",
"x-astrbot-scope": "plugin",
"parameters": [
@@ -4424,12 +4234,39 @@
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/pages": {
"get": {
"tags": [
"Plugin Views"
],
"summary": "List plugin views by query ID (alias of /plugins/views)",
"description": "Deprecated compatibility alias kept for a transition period. New clients should use /plugins/views.\n\n**Required scope:** `plugin`",
"operationId": "listPluginPagesById",
"deprecated": true,
"x-astrbot-scope": "plugin",
"parameters": [
{
"name": "plugin_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
}
},
"/api/v1/plugins/view": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Get plugin view entry HTML by query ID (alias of /plugins/page)",
"summary": "Get plugin view entry HTML by query ID",
"operationId": "getPluginViewById",
"x-astrbot-scope": "plugin",
"parameters": [
@@ -4458,13 +4295,15 @@
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/view/assets": {
"/api/v1/plugins/page": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Get a plugin view asset by query ID (alias of /plugins/page/assets)",
"operationId": "getPluginViewAssetById",
"summary": "Get plugin view entry HTML by query ID (alias of /plugins/view)",
"description": "Deprecated compatibility alias kept for a transition period. New clients should use /plugins/view.\n\n**Required scope:** `plugin`",
"operationId": "getPluginPageById",
"deprecated": true,
"x-astrbot-scope": "plugin",
"parameters": [
{
@@ -4482,30 +4321,21 @@
"schema": {
"type": "string"
}
},
{
"name": "asset_path",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Plugin asset content"
"$ref": "#/components/responses/Html"
}
},
"description": "**Required scope:** `plugin`"
}
}
},
"/api/v1/plugins/{plugin_id}/views": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "List plugin views (alias of /plugins/{plugin_id}/pages)",
"summary": "List plugin views",
"operationId": "listPluginViews",
"x-astrbot-scope": "plugin",
"parameters": [
@@ -4521,12 +4351,34 @@
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/{plugin_id}/views/{page_name}": {
"/api/v1/plugins/{plugin_id}/pages": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Get plugin view entry HTML (alias of /plugins/{plugin_id}/pages/{page_name})",
"summary": "List plugin views (alias of /plugins/{plugin_id}/views)",
"description": "Deprecated compatibility alias kept for a transition period. New clients should use /plugins/{plugin_id}/views.\n\n**Required scope:** `plugin`",
"operationId": "listPluginPages",
"deprecated": true,
"x-astrbot-scope": "plugin",
"parameters": [
{
"$ref": "#/components/parameters/PluginId"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
}
},
"/api/v1/plugins/{plugin_id}/views/{view_name}": {
"get": {
"tags": [
"Plugin Views"
],
"summary": "Get plugin view entry HTML",
"operationId": "getPluginView",
"x-astrbot-scope": "plugin",
"parameters": [
@@ -4534,7 +4386,7 @@
"$ref": "#/components/parameters/PluginId"
},
{
"name": "page_name",
"name": "view_name",
"in": "path",
"required": true,
"schema": {
@@ -4550,48 +4402,40 @@
"description": "**Required scope:** `plugin`"
}
},
"/api/v1/plugins/{plugin_id}/views/{page_name}/assets/{asset_path}": {
"/api/v1/plugins/{plugin_id}/pages/{view_name}": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Get a plugin view asset (alias of /plugins/{plugin_id}/pages/{page_name}/assets/{asset_path})",
"operationId": "getPluginViewAsset",
"summary": "Get plugin view entry HTML (alias of /plugins/{plugin_id}/views/{view_name})",
"description": "Deprecated compatibility alias kept for a transition period. New clients should use /plugins/{plugin_id}/views/{view_name}.\n\n**Required scope:** `plugin`",
"operationId": "getPluginPage",
"deprecated": true,
"x-astrbot-scope": "plugin",
"parameters": [
{
"$ref": "#/components/parameters/PluginId"
},
{
"name": "page_name",
"name": "view_name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "asset_path",
"in": "path",
"required": true,
"description": "URL-encoded relative asset path.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Plugin asset content"
"$ref": "#/components/responses/Html"
}
},
"description": "**Required scope:** `plugin`"
}
}
},
"/api/v1/plugins/page-bridge-sdk.js": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Get the plugin page bridge SDK",
"operationId": "getPluginPageBridgeSdk",
@@ -4614,7 +4458,7 @@
"/api/v1/plugins/extensions/{plugin_path}": {
"get": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Proxy a plugin extension GET route",
"operationId": "getPluginExtensionRoute",
@@ -4639,7 +4483,7 @@
},
"post": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Proxy a plugin extension POST route",
"operationId": "postPluginExtensionRoute",
@@ -4680,7 +4524,7 @@
},
"put": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Proxy a plugin extension PUT route",
"operationId": "putPluginExtensionRoute",
@@ -4714,7 +4558,7 @@
},
"patch": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Proxy a plugin extension PATCH route",
"operationId": "patchPluginExtensionRoute",
@@ -4748,7 +4592,7 @@
},
"delete": {
"tags": [
"Plugin Pages"
"Plugin Views"
],
"summary": "Proxy a plugin extension DELETE route",
"operationId": "deletePluginExtensionRoute",
+2 -6
View File
@@ -254,7 +254,6 @@ outline: deep
| `GET` | `/api/v1/plugins/market/categories` | — |
| `GET` | `/api/v1/plugins/page` | — |
| `GET` | `/api/v1/plugins/page-bridge-sdk.js` | — |
| `GET` | `/api/v1/plugins/page/assets` | — |
| `GET` | `/api/v1/plugins/pages` | — |
| `GET` | `/api/v1/plugins/readme` | — |
| `POST` | `/api/v1/plugins/reload` | — |
@@ -262,7 +261,6 @@ outline: deep
| `POST` | `/api/v1/plugins/validate/repo` | — |
| `POST` | `/api/v1/plugins/version-support/check` | — |
| `GET` | `/api/v1/plugins/view` | — |
| `GET` | `/api/v1/plugins/view/assets` | — |
| `GET` | `/api/v1/plugins/views` | — |
| `GET` | `/api/v1/plugins/{plugin_id}` | — |
| `DELETE` | `/api/v1/plugins/{plugin_id}` | — |
@@ -276,15 +274,13 @@ outline: deep
| `PATCH` | `/api/v1/plugins/{plugin_id}/enabled` | — |
| `PUT` | `/api/v1/plugins/{plugin_id}/log-level` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/pages/{view_name}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/readme` | — |
| `POST` | `/api/v1/plugins/{plugin_id}/reload` | — |
| `POST` | `/api/v1/plugins/{plugin_id}/source` | — |
| `POST` | `/api/v1/plugins/{plugin_id}/update` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views/{page_name}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views/{page_name}/assets/{asset_path}` | — |
| `GET` | `/api/v1/plugins/{plugin_id}/views/{view_name}` | — |
## `mcp`
+7 -13
View File
@@ -7,7 +7,7 @@
## 目录结构
`views/` 下的每个一级子目录是一个独立视图。AstrBot 只扫描 `views/<page_name>/index.html`,没有 `index.html` 的目录会被忽略。
`views/` 下的每个一级子目录是一个独立视图。AstrBot 只扫描 `views/<view_name>/index.html`,没有 `index.html` 的目录会被忽略。
```text
astrbot_plugin_view_demo/
@@ -23,13 +23,13 @@ astrbot_plugin_view_demo/
└─ index.html
```
`page_name` 应使用简单目录名,例如 `settings`、`bridge-demo`。不要使用空目录名、`.`、`..`、以 `.` 开头的目录名,或包含 `/`、`\` 的名称。
`view_name` 应使用简单目录名,例如 `settings`、`bridge-demo`。不要使用空目录名、`.`、`..`、以 `.` 开头的目录名,或包含 `/`、`\` 的名称。
用户可以在 WebUI 的插件页点击插件卡片进入插件详情页,然后打开插件声明的可视化视图。
## 开发流程
1. 在插件目录下创建 `views/<page_name>/index.html`。
1. 在插件目录下创建 `views/<view_name>/index.html`。
2. 在视图中通过 `window.AstrBotPluginView` bridge 调用后端能力。
3. 在 `main.py` 中使用 `context.register_web_api()` 注册插件后端 API。
4. 后端 handler 使用 `astrbot.api.web` 读取请求并返回响应。
@@ -540,7 +540,7 @@ window.addEventListener("beforeunload", () => {
##视图国际化
插件可视化视图复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `views.<page_name>`:
插件可视化视图复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `views.<view_name>`:
```json
{
@@ -611,15 +611,9 @@ body {
<img src="./assets/logo.svg" alt="" />
```
AstrBot 会重写相对资源路径并追加短期 `asset_token`。不要手动拼接 `/api/plugin/page/content/...`,不要自行追加 `asset_token`,也不要依赖 `..` 逃逸视图根目录。
AstrBot 通过路径令牌(path token)为视图资源鉴权:入口配置接口返回的 `content_path` 形如 `/api/v1/plugins/<插件>/views/<视图>/_t/<令牌>/`,页面内的相对路径会沿该前缀自然解析并继承令牌,内容按原样返回、不做重写。令牌与 Dashboard 会话同寿命,且只作用于对应插件的对应视图。
会被重写的资源引用包括:
- HTML `src` 和 `href`
- CSS `url(...)`
- JavaScript `import`
- JavaScript `export ... from`
- JavaScript 动态 `import()`
不要手动拼接内容 URL,不要自行追加 `asset_token`,也不要依赖 `..` 逃逸视图根目录。旧版 `/api/plugin/page/content/...?asset_token=...` 形式已被移除。
如果构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。
@@ -644,7 +638,7 @@ allow-scripts allow-forms allow-downloads
## 调试建议
-视图没出现:检查 `views/<page_name>/index.html` 是否存在、插件是否启用、插件详情页是否已刷新。
-视图没出现:检查 `views/<view_name>/index.html` 是否存在、插件是否启用、插件详情页是否已刷新。
- bridge 不存在:确认脚本在 bridge SDK 注入之后运行;推荐使用外部 `type="module"` 脚本。
- API 未匹配:确认注册路由包含插件名前缀,例如 `/{PLUGIN_NAME}/stats`,而视图端 endpoint 是 `stats`。
- query 或 JSON 为空:GET 参数放到 `apiGet(endpoint, params)`,POST JSON 放到 `apiPost(endpoint, body)`。
+88 -144
View File
@@ -78,7 +78,7 @@ tags:
- name: Files
- name: Plugins
- name: Plugin Sources
- name: Plugin Pages
- name: Plugin Views
- name: Commands
- name: Tools
- name: MCP
@@ -2159,69 +2159,6 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/plugins/pages:
get:
tags: [Plugin Pages]
summary: List plugin pages by query ID
operationId: listPluginPagesById
x-astrbot-scope: plugin
parameters:
- name: plugin_id
in: query
required: true
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/plugins/page:
get:
tags: [Plugin Pages]
summary: Get plugin page entry HTML by query ID
operationId: getPluginPageById
x-astrbot-scope: plugin
parameters:
- name: plugin_id
in: query
required: true
schema:
type: string
- name: page_name
in: query
required: true
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Html"
/api/v1/plugins/page/assets:
get:
tags: [Plugin Pages]
summary: Get a plugin page asset by query ID
operationId: getPluginPageAssetById
x-astrbot-scope: plugin
parameters:
- name: plugin_id
in: query
required: true
schema:
type: string
- name: page_name
in: query
required: true
schema:
type: string
- name: asset_path
in: query
required: true
schema:
type: string
responses:
"200":
description: Plugin asset content
/api/v1/plugins/{plugin_id}:
get:
tags: [Plugins]
@@ -2755,65 +2692,29 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/plugins/{plugin_id}/pages:
/api/v1/plugins/views:
get:
tags: [Plugin Pages]
summary: List plugin pages
operationId: listPluginPages
tags: [Plugin Views]
summary: List plugin views by query ID
operationId: listPluginViewsById
x-astrbot-scope: plugin
parameters:
- $ref: "#/components/parameters/PluginId"
- name: plugin_id
in: query
required: true
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/plugins/{plugin_id}/pages/{page_name}:
/api/v1/plugins/pages:
get:
tags: [Plugin Pages]
summary: Get plugin page entry HTML
operationId: getPluginPage
x-astrbot-scope: plugin
parameters:
- $ref: "#/components/parameters/PluginId"
- name: page_name
in: path
required: true
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Html"
/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}:
get:
tags: [Plugin Pages]
summary: Get a plugin page asset
operationId: getPluginPageAsset
x-astrbot-scope: plugin
parameters:
- $ref: "#/components/parameters/PluginId"
- name: page_name
in: path
required: true
schema:
type: string
- name: asset_path
in: path
required: true
description: URL-encoded relative asset path.
schema:
type: string
responses:
"200":
description: Plugin asset content
# Views aliases. "views" is the preferred naming; the /pages routes above
# stay valid as aliases.
/api/v1/plugins/views:
get:
tags: [Plugin Pages]
summary: List plugin views by query ID (alias of /plugins/pages)
operationId: listPluginViewsById
tags: [Plugin Views]
summary: List plugin views by query ID (alias of /plugins/views)
description: Deprecated compatibility alias kept for a transition period. New clients should use /plugins/views.
operationId: listPluginPagesById
deprecated: true
x-astrbot-scope: plugin
parameters:
- name: plugin_id
@@ -2827,8 +2728,8 @@ paths:
/api/v1/plugins/view:
get:
tags: [Plugin Pages]
summary: Get plugin view entry HTML by query ID (alias of /plugins/page)
tags: [Plugin Views]
summary: Get plugin view entry HTML by query ID
operationId: getPluginViewById
x-astrbot-scope: plugin
parameters:
@@ -2846,11 +2747,13 @@ paths:
"200":
$ref: "#/components/responses/Html"
/api/v1/plugins/view/assets:
/api/v1/plugins/page:
get:
tags: [Plugin Pages]
summary: Get a plugin view asset by query ID (alias of /plugins/page/assets)
operationId: getPluginViewAssetById
tags: [Plugin Views]
summary: Get plugin view entry HTML by query ID (alias of /plugins/view)
description: Deprecated compatibility alias kept for a transition period. New clients should use /plugins/view.
operationId: getPluginPageById
deprecated: true
x-astrbot-scope: plugin
parameters:
- name: plugin_id
@@ -2863,19 +2766,14 @@ paths:
required: true
schema:
type: string
- name: asset_path
in: query
required: true
schema:
type: string
responses:
"200":
description: Plugin asset content
$ref: "#/components/responses/Html"
/api/v1/plugins/{plugin_id}/views:
get:
tags: [Plugin Pages]
summary: List plugin views (alias of /plugins/{plugin_id}/pages)
tags: [Plugin Views]
summary: List plugin views
operationId: listPluginViews
x-astrbot-scope: plugin
parameters:
@@ -2884,15 +2782,29 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/plugins/{plugin_id}/views/{page_name}:
/api/v1/plugins/{plugin_id}/pages:
get:
tags: [Plugin Pages]
summary: Get plugin view entry HTML (alias of /plugins/{plugin_id}/pages/{page_name})
tags: [Plugin Views]
summary: List plugin views (alias of /plugins/{plugin_id}/views)
description: Deprecated compatibility alias kept for a transition period. New clients should use /plugins/{plugin_id}/views.
operationId: listPluginPages
deprecated: true
x-astrbot-scope: plugin
parameters:
- $ref: "#/components/parameters/PluginId"
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/plugins/{plugin_id}/views/{view_name}:
get:
tags: [Plugin Views]
summary: Get plugin view entry HTML
operationId: getPluginView
x-astrbot-scope: plugin
parameters:
- $ref: "#/components/parameters/PluginId"
- name: page_name
- name: view_name
in: path
required: true
schema:
@@ -2901,19 +2813,49 @@ paths:
"200":
$ref: "#/components/responses/Html"
/api/v1/plugins/{plugin_id}/views/{page_name}/assets/{asset_path}:
/api/v1/plugins/{plugin_id}/pages/{view_name}:
get:
tags: [Plugin Pages]
summary: Get a plugin view asset (alias of /plugins/{plugin_id}/pages/{page_name}/assets/{asset_path})
operationId: getPluginViewAsset
tags: [Plugin Views]
summary: Get plugin view entry HTML (alias of /plugins/{plugin_id}/views/{view_name})
description: Deprecated compatibility alias kept for a transition period. New clients should use /plugins/{plugin_id}/views/{view_name}.
operationId: getPluginPage
deprecated: true
x-astrbot-scope: plugin
parameters:
- $ref: "#/components/parameters/PluginId"
- name: page_name
- name: view_name
in: path
required: true
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Html"
/api/v1/plugins/{plugin_id}/views/{view_name}/_t/{token}/{asset_path}:
get:
tags: [Plugin Views]
summary: Get a plugin view asset via a scoped path token
description: >-
Serves plugin view assets without URL rewriting. The token is a
plugin-page-scoped JWT issued by the view entry config endpoint and
travels in the path so relative URLs inherit it through normal path
resolution. Use an empty asset_path (trailing slash) for the entry
document.
operationId: getPluginViewTokenAsset
parameters:
- $ref: "#/components/parameters/PluginId"
- name: view_name
in: path
required: true
schema:
type: string
- name: token
in: path
required: true
description: Scoped plugin page asset token.
schema:
type: string
- name: asset_path
in: path
required: true
@@ -2923,10 +2865,12 @@ paths:
responses:
"200":
description: Plugin asset content
"401":
description: Missing, invalid, expired, or wrongly scoped token
/api/v1/plugins/page-bridge-sdk.js:
get:
tags: [Plugin Pages]
tags: [Plugin Views]
summary: Get the plugin page bridge SDK
operationId: getPluginPageBridgeSdk
x-astrbot-scope: plugin
@@ -2940,7 +2884,7 @@ paths:
/api/v1/plugins/extensions/{plugin_path}:
get:
tags: [Plugin Pages]
tags: [Plugin Views]
summary: Proxy a plugin extension GET route
operationId: getPluginExtensionRoute
x-astrbot-scope: plugin
@@ -2955,7 +2899,7 @@ paths:
"200":
$ref: "#/components/responses/Ok"
post:
tags: [Plugin Pages]
tags: [Plugin Views]
summary: Proxy a plugin extension POST route
operationId: postPluginExtensionRoute
x-astrbot-scope: plugin
@@ -2980,7 +2924,7 @@ paths:
"200":
$ref: "#/components/responses/Ok"
put:
tags: [Plugin Pages]
tags: [Plugin Views]
summary: Proxy a plugin extension PUT route
operationId: putPluginExtensionRoute
x-astrbot-scope: plugin
@@ -3000,7 +2944,7 @@ paths:
"200":
$ref: "#/components/responses/Ok"
patch:
tags: [Plugin Pages]
tags: [Plugin Views]
summary: Proxy a plugin extension PATCH route
operationId: patchPluginExtensionRoute
x-astrbot-scope: plugin
@@ -3020,7 +2964,7 @@ paths:
"200":
$ref: "#/components/responses/Ok"
delete:
tags: [Plugin Pages]
tags: [Plugin Views]
summary: Proxy a plugin extension DELETE route
operationId: deletePluginExtensionRoute
x-astrbot-scope: plugin
+161 -161
View File
@@ -11,7 +11,7 @@ from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import parse_qs, urlsplit, urlunsplit
from urllib.parse import parse_qs, urlsplit
import jwt
import pyotp
@@ -127,11 +127,6 @@ def _removed_md5_hint_alias_key() -> str:
return "le" + "gacy_pwd_hint"
def _strip_query(url: str) -> str:
parsed = urlsplit(url)
return urlunsplit(("", "", parsed.path, "", parsed.fragment))
def _assert_cookie_samesite_strict(cookie_header: str) -> None:
"""Assert that a cookie header carries a strict SameSite attribute.
@@ -1850,18 +1845,18 @@ async def test_plugin_web_api_supports_dynamic_route(
assert calls == ["example"]
def test_plugin_page_content_path_escapes_plugin_name():
def test_plugin_page_view_content_path_escapes_plugin_name():
assert (
PluginPageService.build_plugin_page_content_path(
"plugin with space", "main page"
PluginPageService.build_plugin_page_view_content_path(
"plugin with space", "main page", "token"
)
== "/api/plugin/page/content/plugin%20with%20space/main%20page/"
== "/api/v1/plugins/plugin%20with%20space/views/main%20page/_t/token/"
)
assert (
PluginPageService.build_plugin_page_content_path(
"plugin with space", "main page", "assets/main file.js"
PluginPageService.build_plugin_page_view_content_path(
"plugin with space", "main page", "token", "assets/main file.js"
)
== "/api/plugin/page/content/plugin%20with%20space/main%20page/assets/main%20file.js"
== "/api/v1/plugins/plugin%20with%20space/views/main%20page/_t/token/assets/main%20file.js"
)
@@ -1942,27 +1937,87 @@ async def test_plugin_page_entry_returns_signed_content_path(
assert data["data"]["title"] == PLUGIN_PAGE_DEMO_PAGE_NAME
assert data["data"]["i18n_key"] == f"pages.{PLUGIN_PAGE_DEMO_PAGE_NAME}"
assert data["data"]["content_path"].startswith(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
f"/api/v1/plugins/{PLUGIN_PAGE_DEMO_NAME}/views/{PLUGIN_PAGE_DEMO_PAGE_NAME}/_t/"
)
assert "asset_token=" in data["data"]["content_path"]
@pytest.mark.asyncio
async def test_plugin_page_content_requires_auth(
async def test_plugin_page_view_token_path_serves_raw_assets(
app: FastAPIAppAdapter,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
"""Path-token view URLs serve assets without content rewriting."""
test_client = app.test_client()
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
entry_response = await test_client.get(
(
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
),
headers=authenticated_header,
)
assert response.status_code == 401
data = await response.get_json()
assert data["status"] == "error"
assert entry_response.status_code == 200
content_path = (await entry_response.get_json())["data"]["content_path"]
# The signed entry document is fetchable anonymously via its path token.
anonymous_client = app.test_client()
html_response = await anonymous_client.get(content_path)
assert html_response.status_code == 200
html_text = (await html_response.get_data()).decode("utf-8")
assert "Single plugin Page with internal navigation" in html_text
# Bridge SDK is still injected and carries the token for its own fetch.
bridge_sdk_url = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
html_text,
)
assert bridge_sdk_url is not None
assert "asset_token=" in bridge_sdk_url.group(1)
# Relative asset URLs are NOT rewritten on the path-token route.
app_js_url = re.search(
r'src="([^"]*app\.js[^"]*)"',
html_text,
)
assert app_js_url is not None
assert "/api/plugin/page/content/" not in app_js_url.group(1)
# A relative asset resolves under the path-token prefix and is served raw.
asset_response = await anonymous_client.get(urlsplit(content_path).path + "app.js")
assert asset_response.status_code == 200
# A token scoped to another plugin is rejected.
other_path = urlsplit(content_path).path.replace(
f"/plugins/{PLUGIN_PAGE_DEMO_NAME}/",
"/plugins/another_plugin/",
)
other_response = await anonymous_client.get(other_path)
assert other_response.status_code == 401
# No token at all is rejected.
no_token_response = await anonymous_client.get(
f"/api/v1/plugins/{PLUGIN_PAGE_DEMO_NAME}/views/"
f"{PLUGIN_PAGE_DEMO_PAGE_NAME}/_t//app.js"
)
assert no_token_response.status_code in (401, 404)
@pytest.mark.asyncio
async def test_plugin_page_content_supports_cookie_auth(
async def test_plugin_page_legacy_content_route_is_removed(
app: FastAPIAppAdapter,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
"""The query-token content route was replaced by path-token view URLs."""
test_client = app.test_client()
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/",
headers=authenticated_header,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_plugin_page_view_supports_cookie_auth(
app: FastAPIAppAdapter,
core_lifecycle_td: AstrBotCoreLifecycle,
registered_plugin_page: StarMetadata,
@@ -1977,38 +2032,39 @@ async def test_plugin_page_content_supports_cookie_auth(
)
assert login_response.status_code == 200
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
entry_response = await test_client.get(
(
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
),
)
assert entry_response.status_code == 200
content_path = (await entry_response.get_json())["data"]["content_path"]
response = await test_client.get(content_path)
assert response.status_code == 200
content = (await response.get_data()).decode("utf-8")
assert "Single plugin Page with internal navigation" in content
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert response.headers["Cache-Control"] == "no-store"
assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"]
assert "asset_token=" in content
asset_url_match = re.search(
r'src="([^"]+/app\.js[^"]*)"',
content,
)
# Relative asset URLs stay untouched and resolve under the token path.
asset_url_match = re.search(r'src="([^"]*app\.js[^"]*)"', content)
assert asset_url_match is not None
asset_response = await test_client.get(asset_url_match.group(1))
asset_response = await test_client.get(
urlsplit(content_path).path + asset_url_match.group(1)
)
assert asset_response.status_code == 200
asset_content = (await asset_response.get_data()).decode("utf-8")
assert "renderTabs" in asset_content
assert 'from "react"' in asset_content
assert (
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/shared/common.js"
in asset_content
)
assert "asset_token=" in asset_content
assert "shared/common.js" in asset_content
assert "/api/plugin/page/content/" not in asset_content
bridge_url_match = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
content,
)
bridge_url_match = re.search(r'src="([^"]+/bridge-sdk\.js[^"]*)"', content)
assert bridge_url_match is not None
assert "asset_token=" in bridge_url_match.group(1)
bridge_response = await test_client.get(bridge_url_match.group(1))
assert bridge_response.status_code == 200
bridge_content = (await bridge_response.get_data()).decode("utf-8")
@@ -2016,45 +2072,35 @@ async def test_plugin_page_content_supports_cookie_auth(
@pytest.mark.asyncio
async def test_plugin_page_content_issues_scoped_asset_token(
async def test_plugin_page_view_issues_scoped_asset_token(
app: FastAPIAppAdapter,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
authorized_client = app.test_client()
response = await authorized_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/",
test_client = app.test_client()
entry_response = await test_client.get(
(
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
),
headers=authenticated_header,
)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
assert entry_response.status_code == 200
content_path = (await entry_response.get_json())["data"]["content_path"]
asset_token = parse_qs(urlsplit(content_path).query).get("asset_token", [""])[0]
assert asset_token
app_js_url = re.search(
r'src="([^"]+/app\.js[^"]*)"',
html_text,
)
html_response = await test_client.get(content_path)
assert html_response.status_code == 200
html_text = (await html_response.get_data()).decode("utf-8")
bridge_sdk_url = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
html_text,
)
css_url = re.search(
r'href="([^"]+/base\.css[^"]*)"',
html_text,
)
assert app_js_url is not None
assert bridge_sdk_url is not None
assert css_url is not None
assert "asset_token=" in app_js_url.group(1)
assert "asset_token=" in bridge_sdk_url.group(1)
assert "asset_token=" in css_url.group(1)
query = parse_qs(urlsplit(app_js_url.group(1)).query)
asset_token = query.get("asset_token", [""])[0]
assert asset_token
anonymous_client = app.test_client()
app_js_response = await anonymous_client.get(app_js_url.group(1))
assert app_js_response.status_code == 200
bridge_response = await anonymous_client.get(bridge_sdk_url.group(1))
assert bridge_response.status_code == 200
bridge_js = (await bridge_response.get_data()).decode("utf-8")
@@ -2063,30 +2109,20 @@ async def test_plugin_page_content_issues_scoped_asset_token(
assert '"locale": "zh-CN"' in bridge_js
assert '"displayName": "插件页面演示"' in bridge_js
assert '"pageTitle": "Bridge 演示页"' in bridge_js
css_response = await anonymous_client.get(css_url.group(1))
assert css_response.status_code == 200
# A stale dashboard cookie does not block a valid view token.
stale_cookie_response = await anonymous_client.get(
app_js_url.group(1),
bridge_sdk_url.group(1),
headers={"Cookie": f"{DASHBOARD_JWT_COOKIE_NAME}=stale.dashboard.token"},
)
assert stale_cookie_response.status_code == 200
# Scoped view tokens are rejected outside plugin view paths.
out_of_scope_response = await anonymous_client.get(
f"/api/plugin/get?asset_token={asset_token}"
)
assert out_of_scope_response.status_code == 401
cross_plugin_response = await anonymous_client.get(
f"/api/plugin/page/content/another_plugin/{PLUGIN_PAGE_DEMO_PAGE_NAME}/app.js?asset_token={asset_token}"
)
assert cross_plugin_response.status_code == 401
cross_page_response = await anonymous_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/another-page/app.js?asset_token={asset_token}"
)
assert cross_page_response.status_code == 401
@pytest.mark.asyncio
async def test_plugin_page_bridge_sdk_includes_is_dark_when_theme_param_provided(
@@ -2096,10 +2132,16 @@ async def test_plugin_page_bridge_sdk_includes_is_dark_when_theme_param_provided
):
"""Bridge SDK initial context should include isDark based on ?theme= query param."""
authorized_client = app.test_client()
response = await authorized_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/",
entry_response = await authorized_client.get(
(
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
),
headers=authenticated_header,
)
assert entry_response.status_code == 200
content_path = (await entry_response.get_json())["data"]["content_path"]
response = await authorized_client.get(content_path)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
bridge_sdk_url = re.search(
@@ -2140,61 +2182,45 @@ async def test_plugin_page_bridge_sdk_includes_is_dark_when_theme_param_provided
@pytest.mark.asyncio
async def test_plugin_page_content_propagates_theme_in_rewritten_urls(
async def test_plugin_page_view_propagates_theme(
app: FastAPIAppAdapter,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
"""Theme query param should be propagated through rewritten asset and bridge URLs."""
"""The theme query param reaches the view HTML and the bridge SDK URL."""
test_client = app.test_client()
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
"?asset_token=&theme=dark",
entry_response = await test_client.get(
(
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
),
headers=authenticated_header,
)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
assert entry_response.status_code == 200
content_path = (await entry_response.get_json())["data"]["content_path"]
# Verify theme=dark appears in bridge SDK URL in rewritten HTML
bridge_sdk_url_match = re.search(
# theme=dark -> data-theme and color-scheme on <html>, theme on bridge URL
dark_response = await test_client.get(content_path + "&theme=dark")
assert dark_response.status_code == 200
dark_html = (await dark_response.get_data()).decode("utf-8")
assert 'data-theme="dark"' in dark_html
assert '<meta name="color-scheme" content="dark">' in dark_html
bridge_sdk_url = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
html_text,
dark_html,
)
assert bridge_sdk_url_match is not None
bridge_query = parse_qs(urlsplit(bridge_sdk_url_match.group(1)).query)
assert bridge_query.get("theme") == ["dark"]
assert bridge_sdk_url is not None
assert parse_qs(urlsplit(bridge_sdk_url.group(1)).query).get("theme") == ["dark"]
# Verify theme=dark appears in CSS asset URL in rewritten HTML
css_url_match = re.search(
r'href="([^"]+/base\.css[^"]*)"',
html_text,
)
assert css_url_match is not None
css_query = parse_qs(urlsplit(css_url_match.group(1)).query)
assert css_query.get("theme") == ["dark"]
# Verify data-theme is injected on <html> tag to prevent flash
assert 'data-theme="dark"' in html_text
# Verify color-scheme meta tag is injected for browser-level default styles
assert '<meta name="color-scheme" content="dark">' in html_text
# theme=light → data-theme="light" on <html> and color-scheme meta
light_response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
"?asset_token=&theme=light",
headers=authenticated_header,
)
# theme=light -> data-theme="light" and matching color-scheme meta
light_response = await test_client.get(content_path + "&theme=light")
assert light_response.status_code == 200
light_html = (await light_response.get_data()).decode("utf-8")
assert 'data-theme="light"' in light_html
assert '<meta name="color-scheme" content="light">' in light_html
# no theme param → no data-theme or color-scheme meta on <html>
no_theme_response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
"?asset_token=",
headers=authenticated_header,
)
# no theme param -> no data-theme or color-scheme meta on <html>
no_theme_response = await test_client.get(content_path)
assert no_theme_response.status_code == 200
no_theme_html = (await no_theme_response.get_data()).decode("utf-8")
assert "data-theme=" not in no_theme_html
@@ -2202,48 +2228,25 @@ async def test_plugin_page_content_propagates_theme_in_rewritten_urls(
@pytest.mark.asyncio
async def test_plugin_page_assets_require_dashboard_auth(
app: FastAPIAppAdapter,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
authorized_client = app.test_client()
response = await authorized_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/",
headers=authenticated_header,
)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
app_js_url = re.search(
r'src="([^"]+/app\.js[^"]*)"',
html_text,
)
bridge_sdk_url = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
html_text,
)
assert app_js_url is not None
assert bridge_sdk_url is not None
anonymous_client = app.test_client()
app_js_response = await anonymous_client.get(_strip_query(app_js_url.group(1)))
assert app_js_response.status_code == 401
bridge_response = await anonymous_client.get(_strip_query(bridge_sdk_url.group(1)))
assert bridge_response.status_code == 401
@pytest.mark.asyncio
async def test_plugin_page_content_blocks_path_traversal(
async def test_plugin_page_view_blocks_path_traversal(
app: FastAPIAppAdapter,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
test_client = app.test_client()
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/..%2Fmain.py",
entry_response = await test_client.get(
(
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
),
headers=authenticated_header,
)
assert entry_response.status_code == 200
content_path = urlsplit(
(await entry_response.get_json())["data"]["content_path"]
).path
response = await test_client.get(f"{content_path}..%2Fmain.py")
assert response.status_code == 404
@@ -2263,13 +2266,12 @@ async def test_logout_clears_cookie_for_plugin_page(
)
assert response.status_code == 200
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
entry_url = (
f"/api/plugin/page/entry?name={PLUGIN_PAGE_DEMO_NAME}"
f"&page={PLUGIN_PAGE_DEMO_PAGE_NAME}"
)
response = await test_client.get(entry_url)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
asset_url_match = re.search(r'src="([^"]+/app\.js[^"]*)"', html_text)
assert asset_url_match is not None
logout_response = await test_client.post("/api/auth/logout")
assert logout_response.status_code == 200
@@ -2286,12 +2288,10 @@ async def test_logout_clears_cookie_for_plugin_page(
assert "Max-Age=0" in clear_cookie_header
_assert_cookie_samesite_strict(clear_cookie_header)
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
)
response = await test_client.get(entry_url)
assert response.status_code == 401
asset_response = await test_client.get(_strip_query(asset_url_match.group(1)))
assert asset_response.status_code == 401
bridge_response = await test_client.get("/api/plugin/page/bridge-sdk.js")
assert bridge_response.status_code == 401
@pytest.mark.asyncio