mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 06:58:51 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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": "正在校验插件...",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user