From fff9c8ee190d70aaba09c65f174f47a43743a503 Mon Sep 17 00:00:00 2001 From: lxfight <1686540385@qq.com> Date: Sun, 3 May 2026 20:41:50 +0800 Subject: [PATCH] feat: supports plugin to register custom pages (webui) (#5940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugin): add webui metadata schema for plugins * feat(dashboard): serve plugin webui with scoped asset tokens * feat(dashboard): add plugin webui page and extension entry actions * test(dashboard): cover plugin webui auth and asset routing * fix(dashboard): use aiofiles for non-blocking plugin webui assets * fix(dashboard): streamline JWT extraction and validation for plugin webui paths * fix(dashboard): harden plugin webui bridge and auth cookie security * fix(dashboard): restore plugin webui bridge under sandbox iframe * refactor(dashboard): apply plugin webui review improvements * docs: 补充插件 WebUI 开发指南 * fix(plugin-webui): 统一 WebUI title 契约并修复桥接行为 * docs: 更新插件 WebUI 开发指南 * fix * feat: Introduce Plugin Pages feature - Added support for plugins to expose Dashboard pages via a `pages/` directory. - Updated `PluginDetailPage.vue` to include a button for opening plugin pages. - Refactored `useExtensionPage.js` to remove the deprecated `openPluginWebUI` function. - Updated documentation to replace references from "Plugin WebUI" to "Plugin Pages". - Created new documentation for Plugin Pages detailing structure, examples, and API usage. - Removed the old Plugin WebUI documentation. - Updated tests to reflect changes from Plugin WebUI to Plugin Pages, ensuring proper functionality and security checks. * feat: 增强插件页面功能,添加返回按钮逻辑并更新测试用例 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Soulter <905617992@qq.com> Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- astrbot/core/star/context.py | 9 +- astrbot/core/star/star_manager.py | 3 +- astrbot/dashboard/plugin_page_auth.py | 66 ++ astrbot/dashboard/plugin_page_bridge.js | 181 ++++ astrbot/dashboard/routes/auth.py | 65 +- astrbot/dashboard/routes/plugin.py | 824 +++++++++++++++++- astrbot/dashboard/server.py | 38 +- .../mdi-subset/materialdesignicons-subset.css | 6 +- .../materialdesignicons-webfont-subset.woff | Bin 18528 -> 18600 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 14936 -> 14984 bytes .../locales/en-US/features/extension.json | 9 +- .../locales/ru-RU/features/extension.json | 3 + .../locales/zh-CN/features/extension.json | 11 +- dashboard/src/router/MainRoutes.ts | 5 + dashboard/src/stores/auth.ts | 1 + dashboard/src/views/PluginPagePage.vue | 474 ++++++++++ .../views/extension/InstalledPluginsTab.vue | 2 +- .../src/views/extension/PluginDetailPage.vue | 26 + docs/.vitepress/config.mjs | 2 + docs/en/dev/star/guides/plugin-pages.md | 151 ++++ docs/zh/dev/star/guides/plugin-pages.md | 151 ++++ tests/test_dashboard.py | 449 +++++++++- 22 files changed, 2442 insertions(+), 34 deletions(-) create mode 100644 astrbot/dashboard/plugin_page_auth.py create mode 100644 astrbot/dashboard/plugin_page_bridge.js create mode 100644 dashboard/src/views/PluginPagePage.vue create mode 100644 docs/en/dev/star/guides/plugin-pages.md create mode 100644 docs/zh/dev/star/guides/plugin-pages.md diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 7fcdf6ac4..593bad936 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -49,6 +49,9 @@ logger = logging.getLogger("astrbot") if TYPE_CHECKING: from astrbot.core.cron.manager import CronJobManager +WebApiHandler = Callable[..., Awaitable[Any]] +RegisteredWebApi = tuple[str, WebApiHandler, list[str], str] + class PlatformManagerProtocol(Protocol): platform_insts: list[Platform] @@ -57,7 +60,7 @@ class PlatformManagerProtocol(Protocol): class Context: """暴露给插件的接口上下文。""" - registered_web_apis: list = [] + registered_web_apis: list[RegisteredWebApi] = [] # 向后兼容的变量 _register_tasks: list[Awaitable] = [] @@ -512,8 +515,8 @@ class Context: def register_web_api( self, route: str, - view_handler: Awaitable, - methods: list, + view_handler: WebApiHandler, + methods: list[str], desc: str, ) -> None: """注册 Web API。 diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 8fd5feb91..768bf1759 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -989,6 +989,7 @@ class PluginManager: metadata.display_name = metadata_yaml.display_name metadata.support_platforms = metadata_yaml.support_platforms metadata.astrbot_version = metadata_yaml.astrbot_version + metadata.pages = metadata_yaml.pages metadata.i18n = metadata_yaml.i18n except Exception as e: logger.warning( @@ -1376,7 +1377,7 @@ class PluginManager: 如果找不到插件元数据则返回 None。 """ - # this metric is for displaying plugins installation count in webui + # this metric is for displaying plugins installation count in pages asyncio.create_task( Metric.upload( et="install_star", diff --git a/astrbot/dashboard/plugin_page_auth.py b/astrbot/dashboard/plugin_page_auth.py new file mode 100644 index 000000000..f2571b3ee --- /dev/null +++ b/astrbot/dashboard/plugin_page_auth.py @@ -0,0 +1,66 @@ +from urllib.parse import unquote + +from quart import request + +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: + @staticmethod + def is_protected_path(path: str) -> bool: + return path.startswith(PLUGIN_PAGE_CONTENT_PREFIX) or path.startswith( + PLUGIN_PAGE_BRIDGE_PATH + ) + + @staticmethod + def is_asset_token(payload: dict) -> bool: + return payload.get("token_type") == PLUGIN_PAGE_TOKEN_TYPE + + @staticmethod + def extract_asset_token() -> str | None: + query_asset_token = request.args.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 + ) diff --git a/astrbot/dashboard/plugin_page_bridge.js b/astrbot/dashboard/plugin_page_bridge.js new file mode 100644 index 000000000..c14f7357e --- /dev/null +++ b/astrbot/dashboard/plugin_page_bridge.js @@ -0,0 +1,181 @@ +(function attachAstrBotPluginPageBridge() { + const CHANNEL = "astrbot-plugin-page"; + const SELF_ORIGIN = window.location.origin; + const pendingRequests = new Map(); + const sseHandlers = new Map(); + let requestCounter = 0; + let subscriptionCounter = 0; + let context = null; + let parentOrigin = null; + let resolveReady; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + + function getTargetOrigin() { + if (typeof parentOrigin === "string" && parentOrigin && parentOrigin !== "null") { + return parentOrigin; + } + if (SELF_ORIGIN !== "null") { + return SELF_ORIGIN; + } + return "*"; + } + + function isAllowedParentOrigin(origin) { + if (typeof origin !== "string" || !origin) { + return false; + } + if (parentOrigin) { + return origin === parentOrigin; + } + if (SELF_ORIGIN === "null") { + return true; + } + return origin === SELF_ORIGIN; + } + + function send(kind, payload) { + window.parent.postMessage( + { + channel: CHANNEL, + kind, + ...(payload || {}), + }, + getTargetOrigin(), + ); + } + + function makeRequest(action, payload) { + return new Promise((resolve, reject) => { + requestCounter += 1; + const requestId = `plugin_req_${requestCounter}`; + pendingRequests.set(requestId, { resolve, reject }); + send("request", { + requestId, + action, + ...(payload || {}), + }); + }); + } + + function parseMaybeJson(value) { + if (typeof value !== "string") { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } + } + + window.addEventListener("message", (event) => { + if (event.source !== window.parent) { + return; + } + if (!isAllowedParentOrigin(event.origin)) { + return; + } + if (!parentOrigin) { + parentOrigin = event.origin; + } + + const message = event.data; + if (!message || message.channel !== CHANNEL) { + return; + } + + if (message.kind === "context") { + context = message.context || null; + if (resolveReady) { + resolveReady(context); + resolveReady = null; + } + return; + } + + if (message.kind === "response") { + const pending = pendingRequests.get(message.requestId); + if (!pending) { + return; + } + pendingRequests.delete(message.requestId); + if (message.ok) { + pending.resolve(message.data); + } else { + pending.reject(new Error(message.error || "Plugin bridge request failed.")); + } + return; + } + + if (message.kind === "sse_message") { + const handlers = sseHandlers.get(message.subscriptionId); + if (handlers?.onMessage) { + handlers.onMessage({ + raw: message.data, + parsed: parseMaybeJson(message.data), + lastEventId: message.lastEventId, + }); + } + return; + } + + if (message.kind === "sse_state") { + const handlers = sseHandlers.get(message.subscriptionId); + if (message.state === "open" && handlers?.onOpen) { + handlers.onOpen(); + } + if (message.state === "error" && handlers?.onError) { + handlers.onError(); + } + } + }); + + window.AstrBotPluginPage = { + ready() { + return readyPromise; + }, + getContext() { + return context; + }, + apiGet(endpoint, params) { + return makeRequest("api:get", { endpoint, params }); + }, + apiPost(endpoint, body) { + return makeRequest("api:post", { endpoint, body }); + }, + upload(endpoint, file) { + return makeRequest("files:upload", { + endpoint, + file, + fileName: file?.name || "upload.bin", + }); + }, + download(endpoint, params, filename) { + return makeRequest("files:download", { endpoint, params, filename }); + }, + async subscribeSSE(endpoint, handlers, params) { + subscriptionCounter += 1; + const subscriptionId = `plugin_sse_${subscriptionCounter}`; + sseHandlers.set(subscriptionId, handlers || {}); + try { + await makeRequest("sse:subscribe", { + endpoint, + params, + subscriptionId, + }); + return subscriptionId; + } catch (error) { + sseHandlers.delete(subscriptionId); + throw error; + } + }, + async unsubscribeSSE(subscriptionId) { + sseHandlers.delete(subscriptionId); + return makeRequest("sse:unsubscribe", { subscriptionId }); + }, + }; + + send("ready"); +})(); diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index eac5f65b0..6dc530b4b 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -2,19 +2,23 @@ import asyncio import datetime import jwt -from quart import request +from quart import current_app, jsonify, make_response, request from astrbot import logger from astrbot.core import DEMO_MODE from .route import Response, Route, RouteContext +DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt" +DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 + class AuthRoute(Route): def __init__(self, context: RouteContext) -> None: super().__init__(context) self.routes = { "/auth/login": ("POST", self.login), + "/auth/logout": ("POST", self.logout), "/auth/account/edit": ("POST", self.edit_account), } self.register_routes() @@ -32,21 +36,27 @@ class AuthRoute(Route): ): change_pwd_hint = True logger.warning("为了保证安全,请尽快修改默认密码。") - - return ( - Response() - .ok( - { - "token": self.generate_jwt(username), - "username": username, - "change_pwd_hint": change_pwd_hint, - }, - ) - .__dict__ + token = self.generate_jwt(username) + payload = Response().ok( + { + "token": token, + "username": username, + "change_pwd_hint": change_pwd_hint, + }, ) + response = await make_response(jsonify(payload.__dict__)) + self._set_dashboard_jwt_cookie(response, token) + return response await asyncio.sleep(3) return Response().error("用户名或密码错误").__dict__ + async def logout(self): + response = await make_response( + jsonify(Response().ok(None, "已退出登录").__dict__) + ) + self._clear_dashboard_jwt_cookie(response) + return response + async def edit_account(self): if DEMO_MODE: return ( @@ -90,3 +100,34 @@ class AuthRoute(Route): raise ValueError("JWT secret is not set in the cmd_config.") token = jwt.encode(payload, jwt_token, algorithm="HS256") return token + + @staticmethod + def _use_secure_dashboard_jwt_cookie() -> bool: + return bool( + current_app.config.get( + "DASHBOARD_JWT_COOKIE_SECURE", + not current_app.debug and not current_app.testing, + ) + ) + + @staticmethod + def _set_dashboard_jwt_cookie(response, token: str) -> None: + response.set_cookie( + DASHBOARD_JWT_COOKIE_NAME, + token, + max_age=DASHBOARD_JWT_COOKIE_MAX_AGE, + httponly=True, + samesite="Strict", + secure=AuthRoute._use_secure_dashboard_jwt_cookie(), + path="/", + ) + + @staticmethod + def _clear_dashboard_jwt_cookie(response) -> None: + response.delete_cookie( + DASHBOARD_JWT_COOKIE_NAME, + httponly=True, + samesite="Strict", + secure=AuthRoute._use_secure_dashboard_jwt_cookie(), + path="/", + ) diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 95479fbda..d7193b8a5 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -1,16 +1,25 @@ import asyncio import hashlib import json +import mimetypes import os +import posixpath +import re import ssl import traceback from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import cast +from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit +import aiofiles import aiohttp import certifi -from quart import request +import jwt +from aiofiles import ospath as aio_ospath +from quart import Response as QuartResponse +from quart import g, make_response, request from astrbot.api import sp from astrbot.core import DEMO_MODE, file_token_service, logger @@ -21,6 +30,7 @@ from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.filter.regex import RegexFilter +from astrbot.core.star.star import StarMetadata from astrbot.core.star.star_handler import EventType, star_handlers_registry from astrbot.core.star.star_manager import ( PluginManager, @@ -36,15 +46,56 @@ from .route import Response, Route, RouteContext PLUGIN_UPDATE_CONCURRENCY = ( 3 # limit concurrent updates to avoid overwhelming plugin sources ) +_PLUGIN_PAGE_BRIDGE_FILE = ( + Path(__file__).resolve().parent.parent / "plugin_page_bridge.js" +) +_HTML_ASSET_ATTR_RE = re.compile( + r"(?Psrc|href)=(?P[\"\'])(?P.*?)(?P=quote)", + re.IGNORECASE, +) +_CSS_URL_RE = re.compile( + r"url\(\s*(?P[\"\']?)(?P.*?)(?P=quote)\s*\)", + re.IGNORECASE, +) +_JS_DYNAMIC_IMPORT_RE = re.compile( + r"(?P\bimport\s*\(\s*)(?P[\"\'])(?P.*?)(?P=quote)(?P\s*\))", + re.IGNORECASE, +) +_JS_MODULE_FROM_RE = re.compile( + r"(?P\b(?:import|export)\s+(?:[^;]*?\s+from\s+))(?P[\"\'])(?P.*?)(?P=quote)", + re.IGNORECASE | re.DOTALL, +) +_JS_SIDE_EFFECT_IMPORT_RE = re.compile( + r"(?P\bimport\s+)(?P[\"\'])(?P[^\"'\r\n]+)(?P=quote)", + re.IGNORECASE, +) +_PLUGIN_PAGE_ASSET_TOKEN_TYPE = "plugin_page_asset" +_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS = 60 +_PLUGIN_PAGE_ROOT_DIR_NAME = "pages" +_PLUGIN_PAGE_ENTRY_FILE_NAME = "index.html" + + +def _normalize_plugin_page_asset_path(asset_path: str) -> str: + return PluginRoute._normalize_plugin_page_path(asset_path, allow_empty=True) + + PLUGIN_COMPONENT_TYPE_ORDER = { - "skill": 0, - "command": 1, - "llm_tool": 2, - "listener": 3, - "hook": 4, + "page": 0, + "skill": 1, + "command": 2, + "llm_tool": 3, + "listener": 4, + "hook": 5, } +@dataclass +class PluginPage: + name: str + title: str + entry_file: str = _PLUGIN_PAGE_ENTRY_FILE_NAME + + @dataclass class RegistrySource: urls: list[str] @@ -63,6 +114,8 @@ class PluginRoute(Route): self.routes = { "/plugin/get": ("GET", self.get_plugins), "/plugin/detail": ("GET", self.get_plugin_detail), + "/plugin/check-compat": ("POST", self.check_plugin_compatibility), + "/plugin/page/entry": ("GET", self.get_plugin_page_entry_config), "/plugin/install": ("POST", self.install_plugin), "/plugin/install-upload": ("POST", self.install_plugin_upload), "/plugin/update": ("POST", self.update_plugin), @@ -83,6 +136,24 @@ class PluginRoute(Route): self.core_lifecycle = core_lifecycle self.plugin_manager = plugin_manager self.register_routes() + self.app.add_url_rule( + "/api/plugin/page/content///", + endpoint="plugin_page_content_entry", + view_func=self.get_plugin_page_entry, + methods=["GET"], + ) + self.app.add_url_rule( + "/api/plugin/page/content///", + endpoint="plugin_page_content_asset", + view_func=self.get_plugin_page_asset, + methods=["GET"], + ) + self.app.add_url_rule( + "/api/plugin/page/bridge-sdk.js", + endpoint="plugin_page_bridge_sdk", + view_func=self.get_plugin_page_bridge_sdk, + methods=["GET"], + ) self.translated_event_type = { EventType.AdapterMessageEvent: "平台消息下发时", @@ -98,12 +169,735 @@ class PluginRoute(Route): self._logo_cache = {} + async def get_plugin_page_entry(self, plugin_name: str, page_name: str): + return await self._serve_plugin_page_content(plugin_name, page_name, "") + + async def get_plugin_page_asset( + self, + plugin_name: str, + page_name: str, + asset_path: str, + ): + return await self._serve_plugin_page_content( + plugin_name, + page_name, + asset_path, + ) + + async def get_plugin_page_bridge_sdk(self): + if not await aio_ospath.isfile(str(_PLUGIN_PAGE_BRIDGE_FILE)): + return await self._plugin_page_error_response( + 404, "Plugin Page bridge SDK not found" + ) + bridge_js = await self._read_plugin_page_binary(_PLUGIN_PAGE_BRIDGE_FILE) + response = cast( + QuartResponse, + await make_response( + bridge_js, {"Content-Type": "application/javascript; charset=utf-8"} + ), + ) + return self._apply_plugin_page_security_headers(response) + + def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: + for plugin in self.plugin_manager.context.get_all_stars(): + if plugin.name == plugin_name: + return plugin + return None + + @staticmethod + 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") + if ( + normalized.startswith("../") + or normalized == ".." + or normalized.startswith("/") + ): + raise ValueError("Invalid plugin Page 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("\\", "/")) + if ( + normalized != page_name + or normalized in {".", ".."} + or normalized.startswith(".") + or "/" in page_name + or "\\" in page_name + ): + raise ValueError("Invalid plugin Page name") + return page_name + + def _get_plugin_root_dir(self, plugin: StarMetadata) -> Path: + if not plugin.root_dir_name: + raise FileNotFoundError("Plugin directory metadata is missing") + + base_dir = Path( + self.plugin_manager.reserved_plugin_path + if plugin.reserved + else self.plugin_manager.plugin_store_path + ).resolve(strict=False) + plugin_root = (base_dir / plugin.root_dir_name).resolve(strict=False) + plugin_root.relative_to(base_dir) + return plugin_root + + async def _resolve_plugin_pages_root( + self, + plugin: StarMetadata, + ) -> Path: + plugin_root = self._get_plugin_root_dir(plugin) + pages_root = (plugin_root / _PLUGIN_PAGE_ROOT_DIR_NAME).resolve(strict=False) + pages_root.relative_to(plugin_root) + if pages_root == plugin_root: + raise FileNotFoundError("Plugin Pages root directory is invalid") + if not await aio_ospath.isdir(str(pages_root)): + raise FileNotFoundError("Plugin Pages root directory does not exist") + return pages_root + + async def _discover_plugin_pages(self, plugin: StarMetadata) -> list[PluginPage]: + try: + pages_root = await self._resolve_plugin_pages_root(plugin) + except (FileNotFoundError, ValueError): + return [] + + pages: list[PluginPage] = [] + try: + page_dirs = sorted( + (item for item in pages_root.iterdir() if item.is_dir()), + key=lambda item: item.name.lower(), + ) + except OSError: + return [] + + for page_dir in page_dirs: + try: + page_name = self._normalize_plugin_page_name(page_dir.name) + except ValueError: + continue + entry_path = page_dir / _PLUGIN_PAGE_ENTRY_FILE_NAME + if not await aio_ospath.isfile(str(entry_path)): + continue + pages.append( + PluginPage( + name=page_name, + title=page_name, + entry_file=_PLUGIN_PAGE_ENTRY_FILE_NAME, + ) + ) + return pages + + async def _get_plugin_page( + self, + plugin: StarMetadata, + page_name: str, + ) -> PluginPage: + normalized_name = self._normalize_plugin_page_name(page_name) + for page in await self._discover_plugin_pages(plugin): + if page.name == normalized_name: + return page + raise FileNotFoundError("Plugin Page entry not found") + + async def _resolve_plugin_page_root( + self, + plugin: StarMetadata, + page_name: str, + ) -> Path: + normalized_name = self._normalize_plugin_page_name(page_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") + return page_root + + async def _resolve_plugin_page_file( + self, + plugin: StarMetadata, + page_name: str, + asset_path: str, + ) -> Path: + page = await self._get_plugin_page(plugin, page_name) + page_root = await self._resolve_plugin_page_root(plugin, page.name) + target_name = _normalize_plugin_page_asset_path(asset_path) or page.entry_file + 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") + 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 = PluginRoute._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, + 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, + asset_path: str = "", + ) -> str: + encoded_plugin_name = quote(plugin_name, safe="") + encoded_page_name = quote( + PluginRoute._normalize_plugin_page_name(page_name), + safe="", + ) + if not asset_path: + return ( + f"/api/plugin/page/content/{encoded_plugin_name}/{encoded_page_name}/" + ) + safe_asset_path = _normalize_plugin_page_asset_path(asset_path) + 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}" + ) + + @staticmethod + def _get_plugin_page_bridge_sdk_url( + extra_query_params: dict[str, str] | None = None, + ) -> str: + 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( + self, + html_text: str, + plugin_name: str, + page_name: str, + entry_asset_path: str, + extra_query_params: dict[str, str] | None = None, + ) -> str: + def replace_attr(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): + return match.group(0) + + 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) + if "/api/plugin/page/bridge-sdk.js" not in rewritten_html: + bridge_tag = f'' + if "" in rewritten_html: + rewritten_html = rewritten_html.replace( + "", f"{bridge_tag}", 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')}{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')}{rewritten}{match.group('quote')}" + + return _JS_SIDE_EFFECT_IMPORT_RE.sub(replace_side_effect, rewritten_js) + + @staticmethod + async def _read_plugin_page_text(file_path: Path) -> str: + async with aiofiles.open(file_path, encoding="utf-8") as file: + return await file.read() + + @staticmethod + async def _read_plugin_page_binary(file_path: Path) -> bytes: + async with aiofiles.open(file_path, mode="rb") as file: + return await file.read() + + @staticmethod + def _guess_plugin_page_mime_type(file_path: Path) -> str: + return mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + + async def _serialize_plugin_page( + self, + plugin: StarMetadata, + page_name: str, + *, + include_content_path: bool = False, + ) -> dict | None: + plugin_name = plugin.name.strip() if isinstance(plugin.name, str) else "" + if not plugin_name: + return None + try: + page = await self._get_plugin_page(plugin, page_name) + await self._resolve_plugin_page_file(plugin, page.name, "") + except (FileNotFoundError, ValueError): + return None + + page_data = { + "name": page.name, + "title": page.title, + } + if include_content_path: + asset_token = ( + self._issue_plugin_page_asset_token(plugin_name, page.name) or "" + ) + 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, + ) + return page_data + + async def _serialize_plugin_pages(self, plugin: StarMetadata) -> list[dict]: + pages = [] + for page in await self._discover_plugin_pages(plugin): + page_data = await self._serialize_plugin_page(plugin, page.name) + if page_data: + pages.append(page_data) + return pages + + def _issue_plugin_page_asset_token( + self, + plugin_name: str, + page_name: str, + ) -> str | None: + jwt_secret = self.config.get("dashboard", {}).get("jwt_secret") + if not isinstance(jwt_secret, str) or not jwt_secret.strip(): + return None + + username = getattr(g, "username", None) + if not isinstance(username, str) or not username.strip(): + return None + + now = datetime.now(timezone.utc) + payload = { + "username": username, + "token_type": _PLUGIN_PAGE_ASSET_TOKEN_TYPE, + "plugin_name": plugin_name, + "page_name": page_name, + "iat": now, + "exp": now + timedelta(seconds=_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS), + } + return cast(str, jwt.encode(payload, jwt_secret, algorithm="HS256")) + + def _prepare_plugin_page_query_params( + self, + plugin_name: str, + page_name: str, + ) -> dict[str, str] | None: + asset_token = request.args.get("asset_token", "").strip() + if not asset_token: + asset_token = ( + self._issue_plugin_page_asset_token(plugin_name, page_name) or "" + ) + return {"asset_token": asset_token} if asset_token else None + + @staticmethod + async def _plugin_page_error_response(status_code: int, message: str): + response = await make_response(message, status_code) + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Type"] = "text/plain; charset=utf-8" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + @staticmethod + def _apply_plugin_page_security_headers(response: QuartResponse) -> QuartResponse: + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Cross-Origin-Resource-Policy"] = "cross-origin" + # Sandboxed iframes without allow-same-origin load ES modules with Origin: null. + # CORS read access is allowed here; JWT/asset_token still protects the assets. + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Content-Security-Policy"] = ( + "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" + ) + return response + + async def _serve_plugin_page_html_asset( + self, + file_path: Path, + plugin_name: str, + page_name: str, + asset_path: str, + extra_query_params: dict[str, str] | None, + ): + html_text = await self._read_plugin_page_text(file_path) + rewritten_html = self._rewrite_plugin_page_html( + html_text, + plugin_name, + page_name, + asset_path, + extra_query_params=extra_query_params, + ) + response = cast( + QuartResponse, + await make_response( + rewritten_html, {"Content-Type": "text/html; charset=utf-8"} + ), + ) + return self._apply_plugin_page_security_headers(response) + + async def _serve_plugin_page_css_asset( + self, + file_path: Path, + plugin_name: str, + page_name: str, + asset_path: str, + extra_query_params: dict[str, str] | None, + ): + css_text = await self._read_plugin_page_text(file_path) + rewritten_css = self._rewrite_plugin_page_css( + css_text, + plugin_name, + page_name, + asset_path, + extra_query_params=extra_query_params, + ) + response = cast( + QuartResponse, + await make_response( + rewritten_css, {"Content-Type": "text/css; charset=utf-8"} + ), + ) + return self._apply_plugin_page_security_headers(response) + + async def _serve_plugin_page_js_asset( + self, + file_path: Path, + plugin_name: str, + page_name: str, + asset_path: str, + extra_query_params: dict[str, str] | None, + ): + js_text = await self._read_plugin_page_text(file_path) + rewritten_js = self._rewrite_plugin_page_js( + js_text, + plugin_name, + page_name, + asset_path, + extra_query_params=extra_query_params, + ) + response = cast( + QuartResponse, + await make_response( + rewritten_js, + {"Content-Type": "application/javascript; charset=utf-8"}, + ), + ) + return self._apply_plugin_page_security_headers(response) + + async def _serve_plugin_page_static_asset(self, file_path: Path): + raw_bytes = await self._read_plugin_page_binary(file_path) + response = cast( + QuartResponse, + await make_response( + raw_bytes, + {"Content-Type": self._guess_plugin_page_mime_type(file_path)}, + ), + ) + return self._apply_plugin_page_security_headers(response) + + async def _serve_plugin_page_content( + self, + plugin_name: str, + page_name: str, + asset_path: str, + ): + plugin = self._get_plugin_metadata_by_name(plugin_name) + if not plugin: + return await self._plugin_page_error_response(404, "Plugin not found") + if not plugin.activated: + return await self._plugin_page_error_response(403, "Plugin is disabled") + + try: + page = await self._get_plugin_page(plugin, page_name) + file_path = await self._resolve_plugin_page_file( + plugin, + page.name, + asset_path, + ) + except (FileNotFoundError, ValueError): + return await self._plugin_page_error_response( + 404, "Plugin Page asset not found" + ) + + extra_query_params = self._prepare_plugin_page_query_params( + plugin_name, + page.name, + ) + served_asset_path = asset_path or page.entry_file + suffix = file_path.suffix.lower() + handlers = { + ".html": self._serve_plugin_page_html_asset, + ".css": self._serve_plugin_page_css_asset, + ".js": self._serve_plugin_page_js_asset, + ".mjs": self._serve_plugin_page_js_asset, + } + handler = handlers.get(suffix) + if handler: + return await handler( + file_path, + plugin_name, + page.name, + served_asset_path, + extra_query_params, + ) + return await self._serve_plugin_page_static_asset(file_path) + async def _sync_skills_after_plugin_change(self) -> None: try: await sync_skills_to_active_sandboxes() except Exception: logger.warning("Failed to sync plugin-provided skills to active sandboxes.") + async def check_plugin_compatibility(self): + try: + data = await request.get_json() + version_spec = data.get("astrbot_version", "") + is_valid, message = self.plugin_manager._validate_astrbot_version_specifier( + version_spec + ) + return ( + Response() + .ok( + { + "compatible": is_valid, + "message": message, + "astrbot_version": version_spec, + } + ) + .__dict__ + ) + except Exception as e: + return Response().error(str(e)).__dict__ + + async def get_plugin_page_entry_config(self): + plugin_name = request.args.get("name") + if not plugin_name: + return Response().error("缺少插件名").__dict__ + page_name = request.args.get("page") + if not page_name: + return Response().error("缺少 Page 名称").__dict__ + + for plugin in self.plugin_manager.context.get_all_stars(): + if plugin.name != plugin_name: + continue + if not plugin.activated: + return Response().error("插件未启用").__dict__ + + page = await self._serialize_plugin_page( + plugin, + page_name, + include_content_path=True, + ) + if not page: + return Response().error("插件 Page 不存在").__dict__ + return Response().ok(page).__dict__ + + return Response().error("插件不存在").__dict__ + async def reload_failed_plugins(self): if DEMO_MODE: return ( @@ -469,10 +1263,12 @@ class PluginRoute(Route): async def get_plugin_components_info(self, plugin): """Build plugin components for the dashboard.""" + page_components = await self.get_plugin_page_components(plugin) handler_components = await self.get_plugin_handler_components( plugin.star_handler_full_names, ) components = [ + *page_components, *self.get_plugin_skill_components(plugin), *handler_components, ] @@ -481,6 +1277,20 @@ class PluginRoute(Route): key=lambda item: PLUGIN_COMPONENT_TYPE_ORDER.get(item["type"], 99), ) + async def get_plugin_page_components(self, plugin) -> list[dict]: + pages = await self._serialize_plugin_pages(plugin) + return [ + { + "type": "page", + "name": page["title"], + "title": page["title"], + "page_name": page["name"], + "description": "Plugin Page entry", + "plugin_name": plugin.name, + } + for page in pages + ] + async def get_plugin_handler_components(self, handler_full_names: list[str]): """Build behavior components from registered handlers.""" components = [] diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 280c83fe5..d4f739318 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -23,8 +23,10 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.datetime_utils import to_utc_isoformat from astrbot.core.utils.io import get_local_ip_addresses +from .plugin_page_auth import PluginPageAuth from .routes import * from .routes.api_key import ALL_OPEN_API_SCOPES +from .routes.auth import DASHBOARD_JWT_COOKIE_NAME from .routes.backup import BackupRoute from .routes.live_chat import LiveChatRoute from .routes.platform import PlatformRoute @@ -198,6 +200,7 @@ class AstrBotDashboard: allowed_endpoints = [ "/api/auth/login", + "/api/auth/logout", "/api/file", "/api/platform/webhook", "/api/stat/start-time", @@ -205,16 +208,30 @@ class AstrBotDashboard: ] if any(request.path.startswith(prefix) for prefix in allowed_endpoints): return None - # 声明 JWT - token = request.headers.get("Authorization") + is_plugin_page_path = PluginPageAuth.is_protected_path(request.path) + token = self._extract_dashboard_jwt() + if not token and is_plugin_page_path: + token = PluginPageAuth.extract_asset_token() if not token: r = jsonify(Response().error("未授权").__dict__) r.status_code = 401 return r - token = token.removeprefix("Bearer ") try: payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"]) - g.username = payload["username"] + if PluginPageAuth.is_asset_token( + payload + ) and not PluginPageAuth.is_scope_valid( + payload, + request.path, + ): + r = jsonify(Response().error("Token 无效").__dict__) + r.status_code = 401 + return r + + username = payload.get("username") + if not isinstance(username, str) or not username.strip(): + raise jwt.InvalidTokenError("missing username in token payload") + g.username = username except jwt.ExpiredSignatureError: r = jsonify(Response().error("Token 过期").__dict__) r.status_code = 401 @@ -224,6 +241,19 @@ class AstrBotDashboard: r.status_code = 401 return r + @staticmethod + def _extract_dashboard_jwt() -> str | None: + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + token = auth_header.removeprefix("Bearer ").strip() + if token: + return token + + cookie_token = request.cookies.get(DASHBOARD_JWT_COOKIE_NAME, "").strip() + if cookie_token: + return cookie_token + return None + @staticmethod def _extract_raw_api_key() -> str | None: if key := request.args.get("api_key"): diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 896f67b68..187348bca 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 259 icons */ +/* Auto-generated MDI subset – 260 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -708,6 +708,10 @@ content: "\F0375"; } +.mdi-monitor-dashboard::before { + content: "\F0A07"; +} + .mdi-music-note-outline::before { content: "\F0F74"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index a9d483ab6e9937a103da9060f4da4420240f7462..e8c5b375dd4cc3ed4bcf7a1e227531f3baaf88e2 100644 GIT binary patch delta 17988 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 L7~=uNliNpyiBuY! delta 17932 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

oMT{QU|;~^i%mT{*C^#rrD0(QX zDA_3%DK{yEDatASDnBY@Dw!(5D)cKKD{?E`EFUaMES)ULEdDJlEm$q7E&(oFE`lzn zF7GcEFH|q5FWxXJFmW)oF$ytAF?BJtG4e7mGCwkjGNLm8GdVMuGu1RxG|n|9HNZAB zHk&rybdMmSbDkvOCP0C=2ZU}RumWMQmi&}9GtCLrblLI#HaU_Ju?7{~#` HlOsrlV+qfh diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 9fbe813f1cc5a6a7a7100ed8f2615f84076d0770..2ebc5cc5115e36a31318b490a0d2824c8a1af746 100644 GIT binary patch literal 14984 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 S928VpEWIw3UWYuroH;fcy(i%S literal 14936 zcmV-eI;X{VPew8T0RR9106JIz3jhEB0E37C06GN#0RR9100000000000000000000 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

O$Yo1v3d4yuoG4&_>tSUJRuFw^l#ef`4 zxsSD#c94TC=B)Evyr^TqbfosH z7uV9QoJd5k+3mMGav*ZQ8%s(i2$KL~Z22=lyuat}yPtpg{_tDHZ<# zbVubSVoOoYvL^Evp-Y+ZrI729lN`Pg+hN08S3o{Byrp-W6j)u4L%K7CYK%%GRV3Aq zUx;T+zx%Sa^`*8Eg$u(Yx8}C@Uh9)2E{<0bqAh%h4{Mhq&C6}+*AbDOnw+kf>0n+@ z4Gjoh0v><34h74$lmf~qBhrX~%ZZYJl~6`nuQ^9pb$}M7bcovb(s+=@1lI+-E(a6# z?yeFb)Hy}I{ixb8kaBK;DM-6}1vZsHchoXW&XPudnA(BCdg;wve>J1GdWB#jb3|fS z-yiMWPpf{P7Hw2@>-~{i>JY$FDURpv>=V?Xz+Q()(yBDJCb;&*V4(89o$4+K4XK

ny-PM1|R?cp#W_}*e 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

Avnc@=)6>nYLz^WmU&ef import('@/views/ExtensionPage.vue') }, + { + name: 'PluginPage', + path: '/plugin-page/:pluginName/:pageName', + component: () => import('@/views/PluginPagePage.vue') + }, { name: EXTENSION_DETAILS_ROUTE_NAME, path: '/extension/:pluginId', diff --git a/dashboard/src/stores/auth.ts b/dashboard/src/stores/auth.ts index 340e97729..c04a05b31 100644 --- a/dashboard/src/stores/auth.ts +++ b/dashboard/src/stores/auth.ts @@ -69,6 +69,7 @@ export const useAuthStore = defineStore("auth", { this.username = ''; localStorage.removeItem('user'); localStorage.removeItem('token'); + void axios.post('/api/auth/logout').catch(() => undefined); router.push('/auth/login'); }, has_token(): boolean { diff --git a/dashboard/src/views/PluginPagePage.vue b/dashboard/src/views/PluginPagePage.vue new file mode 100644 index 000000000..369909c71 --- /dev/null +++ b/dashboard/src/views/PluginPagePage.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index 65e551212..cbea3281c 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -321,7 +321,7 @@ const togglePinnedExtension = (extension) => { diff --git a/dashboard/src/views/extension/PluginDetailPage.vue b/dashboard/src/views/extension/PluginDetailPage.vue index 92e0f741d..195ea44c8 100644 --- a/dashboard/src/views/extension/PluginDetailPage.vue +++ b/dashboard/src/views/extension/PluginDetailPage.vue @@ -308,6 +308,7 @@ const normalizeHandlerList = (source) => { }; const componentGroupOrder = [ + "page", "skill", "command", "llm_tool", @@ -316,6 +317,7 @@ const componentGroupOrder = [ ]; const componentGroupIcons = { + page: "mdi-monitor-dashboard", skill: "mdi-lightning-bolt", command: "mdi-console-line", llm_tool: "mdi-tools", @@ -449,6 +451,19 @@ const getComponentDescription = (component) => component?.description || component?.desc || tm("status.unknown"), ).trim(); +const openComponentPage = (component) => { + const targetPluginName = component?.plugin_name || pluginData.value?.name; + const targetPageName = component?.page_name || component?.name; + if (!targetPluginName || !targetPageName) return; + router.push({ + name: "PluginPage", + params: { + pluginName: targetPluginName, + pageName: targetPageName, + }, + }); +}; + const getCommandRowKey = (component, path) => component?.handler_full_name || component?.path || path.join(" "); @@ -786,6 +801,17 @@ onBeforeUnmount(() => { {{ getHandlerTiming(component) }} {{ getComponentDescription(component) }} + + {{ tm("buttons.openPage") }} + diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 160296237..cad68ec06 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -191,6 +191,7 @@ export default defineConfig({ { text: "接收消息事件", link: "/guides/listen-message-event" }, { text: "发送消息", link: "/guides/send-message" }, { text: "插件配置", link: "/guides/plugin-config" }, + { text: "插件 Pages", link: "/guides/plugin-pages" }, { text: "插件国际化", link: "/guides/plugin-i18n" }, { text: "调用 AI", link: "/guides/ai" }, { text: "存储", link: "/guides/storage" }, @@ -434,6 +435,7 @@ export default defineConfig({ { text: "Listen to Message Events", link: "/guides/listen-message-event" }, { text: "Send Messages", link: "/guides/send-message" }, { text: "Plugin Configuration", link: "/guides/plugin-config" }, + { text: "Plugin Pages", link: "/guides/plugin-pages" }, { text: "Plugin Internationalization", link: "/guides/plugin-i18n" }, { text: "AI", link: "/guides/ai" }, { text: "Storage", link: "/guides/storage" }, diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md new file mode 100644 index 000000000..21682c2d2 --- /dev/null +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -0,0 +1,151 @@ +# Plugin Pages + +AstrBot lets a plugin expose Dashboard pages by placing static assets under `pages/`. Each direct child directory is one Page: + +```text +astrbot_plugin_page_demo/ +├─ main.py +└─ pages/ + ├─ bridge-demo/ + │ ├─ index.html + │ ├─ app.js + │ ├─ style.css + │ └─ assets/ + │ └─ logo.svg + └─ settings/ + └─ index.html +``` + +AstrBot scans `pages//index.html`; directories without `index.html` are ignored. + +If you only need a few editable settings, prefer [`_conf_schema.json`](./plugin-config.md). Plugin Pages are more suitable for complex forms, dashboards, logs, file transfer, SSE, and custom interaction flows. + +## Minimal Frontend Example + +`pages/bridge-demo/index.html` + +```html + + + + + Plugin Page Demo + + + + +


+    
+  
+
+```
+
+`pages/bridge-demo/app.js`
+
+```js
+const bridge = window.AstrBotPluginPage;
+const output = document.getElementById("output");
+
+const context = await bridge.ready();
+output.textContent = JSON.stringify(context, null, 2);
+
+document.getElementById("ping").addEventListener("click", async () => {
+  const result = await bridge.apiGet("ping");
+  output.textContent = JSON.stringify(result, null, 2);
+});
+```
+
+You do not need to import the bridge SDK manually. AstrBot injects `/api/plugin/page/bridge-sdk.js` into returned HTML.
+
+## Register Backend APIs
+
+When the frontend calls `bridge.apiGet("ping")`, the Dashboard forwards it to:
+
+```text
+/api/plug//ping
+```
+
+The registered Web API route must include the plugin name as a prefix:
+
+```python
+from quart import jsonify
+from astrbot.api.star import Context, Star
+
+PLUGIN_NAME = "astrbot_plugin_page_demo"
+
+
+class MyPlugin(Star):
+    def __init__(self, context: Context):
+        super().__init__(context)
+        context.register_web_api(
+            f"/{PLUGIN_NAME}/ping",
+            self.page_ping,
+            ["GET"],
+            "Page ping",
+        )
+
+    async def page_ping(self):
+        return jsonify({"message": "pong"})
+```
+
+## Bridge API
+
+Inside a plugin Page, use `window.AstrBotPluginPage` directly:
+
+- `ready()`: Wait until the bridge is ready and return the context
+- `getContext()`: Read the current context
+- `apiGet(endpoint, params)`: Send a GET request
+- `apiPost(endpoint, body)`: Send a POST request
+- `upload(endpoint, file)`: Upload one file as `multipart/form-data`
+- `download(endpoint, params, filename)`: Download a backend response
+- `subscribeSSE(endpoint, handlers, params)`: Subscribe to SSE
+- `unsubscribeSSE(subscriptionId)`: Cancel an SSE subscription
+
+The current `ready()` context looks like this:
+
+```json
+{
+  "pluginName": "astrbot_plugin_page_demo",
+  "displayName": "Plugin Page Demo"
+}
+```
+
+`endpoint` must be a plugin-local path. It must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain `.` / `..` path segments.
+
+## Asset Path Rules
+
+AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Write normal relative paths and do not hardcode `/api/plugin/page/content/...` yourself.
+
+AstrBot rewrites:
+
+- HTML `src` and `href`
+- CSS `url(...)`
+- JavaScript `import`
+- JavaScript `export ... from`
+- JavaScript dynamic `import()`
+
+Keep static assets on relative paths such as `./style.css` and `./assets/logo.svg`. Do not manually append `asset_token`, and do not rely on `..` to escape the Page root directory.
+
+If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires an actual file to exist at that path.
+
+## Security Constraints
+
+Plugin Pages run inside a restricted iframe:
+
+```text
+allow-scripts allow-forms allow-downloads
+```
+
+The page cannot directly access Dashboard cookies, LocalStorage, or same-origin DOM, and it cannot bypass the bridge to reuse Dashboard auth directly.
+
+AstrBot also adds security headers to asset responses, including:
+
+- `X-Frame-Options: SAMEORIGIN`
+- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'`
+- `Cache-Control: no-store`
+
+## Debugging Tips
+
+- Reload the plugin after adding or removing a Page directory
+- For most edits under `pages//`, refreshing the Page is enough
+- If a Page does not appear, check that `pages//index.html` exists and the plugin is enabled
diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md
new file mode 100644
index 000000000..c39bb5c6a
--- /dev/null
+++ b/docs/zh/dev/star/guides/plugin-pages.md
@@ -0,0 +1,151 @@
+# 插件 Pages
+
+AstrBot 支持插件通过 `pages/` 目录暴露 Dashboard 页面。`pages/` 下的每个一级子目录都是一个独立 Page:
+
+```text
+astrbot_plugin_page_demo/
+├─ main.py
+└─ pages/
+   ├─ bridge-demo/
+   │  ├─ index.html
+   │  ├─ app.js
+   │  ├─ style.css
+   │  └─ assets/
+   │     └─ logo.svg
+   └─ settings/
+      └─ index.html
+```
+
+AstrBot 会扫描 `pages//index.html`;没有 `index.html` 的目录会被忽略。
+
+如果只是让用户填写几个配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。
+
+## 最小前端示例
+
+`pages/bridge-demo/index.html`
+
+```html
+
+
+  
+    
+    Plugin Page Demo
+    
+  
+  
+    
+    

+    
+  
+
+```
+
+`pages/bridge-demo/app.js`
+
+```js
+const bridge = window.AstrBotPluginPage;
+const output = document.getElementById("output");
+
+const context = await bridge.ready();
+output.textContent = JSON.stringify(context, null, 2);
+
+document.getElementById("ping").addEventListener("click", async () => {
+  const result = await bridge.apiGet("ping");
+  output.textContent = JSON.stringify(result, null, 2);
+});
+```
+
+这里不需要手动引入 bridge SDK。AstrBot 会在返回的 HTML 里自动插入 `/api/plugin/page/bridge-sdk.js`。
+
+## 注册后端 API
+
+前端调用 `bridge.apiGet("ping")` 时,Dashboard 会转发到:
+
+```text
+/api/plug//ping
+```
+
+因此注册 Web API 时,路由必须带上插件名作为前缀:
+
+```python
+from quart import jsonify
+from astrbot.api.star import Context, Star
+
+PLUGIN_NAME = "astrbot_plugin_page_demo"
+
+
+class MyPlugin(Star):
+    def __init__(self, context: Context):
+        super().__init__(context)
+        context.register_web_api(
+            f"/{PLUGIN_NAME}/ping",
+            self.page_ping,
+            ["GET"],
+            "Page ping",
+        )
+
+    async def page_ping(self):
+        return jsonify({"message": "pong"})
+```
+
+## Bridge API
+
+插件 Page 中可直接使用 `window.AstrBotPluginPage`:
+
+- `ready()`: 等待 bridge 就绪并返回上下文
+- `getContext()`: 读取当前上下文
+- `apiGet(endpoint, params)`: 发送 GET 请求
+- `apiPost(endpoint, body)`: 发送 POST 请求
+- `upload(endpoint, file)`: 以 `multipart/form-data` 上传单个文件
+- `download(endpoint, params, filename)`: 下载后端响应
+- `subscribeSSE(endpoint, handlers, params)`: 订阅 SSE
+- `unsubscribeSSE(subscriptionId)`: 取消 SSE 订阅
+
+当前 `ready()` 上下文类似:
+
+```json
+{
+  "pluginName": "astrbot_plugin_page_demo",
+  "displayName": "Plugin Page Demo"
+}
+```
+
+`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash,也不能包含 `.` 或 `..` 路径片段。
+
+## 静态资源路径规则
+
+AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`。
+
+AstrBot 会重写:
+
+- HTML `src` 和 `href`
+- CSS `url(...)`
+- JavaScript `import`
+- JavaScript `export ... from`
+- JavaScript 动态 `import()`
+
+建议把静态资源写成 `./style.css`、`./assets/logo.svg` 这类相对路径。不要手动追加 `asset_token`,也不要依赖 `..` 逃逸 Page 根目录。
+
+如果你构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。
+
+## 安全约束
+
+插件 Pages 运行在受限 iframe 中:
+
+```text
+allow-scripts allow-forms allow-downloads
+```
+
+Page 不能直接访问 Dashboard cookies、LocalStorage 或同源 DOM,也不能绕过 bridge 复用 Dashboard auth。
+
+AstrBot 还会给资源响应添加安全头,包括:
+
+- `X-Frame-Options: SAMEORIGIN`
+- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'`
+- `Cache-Control: no-store`
+
+## 调试建议
+
+- 新增或删除 Page 目录后重载插件
+- 修改 `pages//` 下的大多数静态资源后,刷新 Page 即可
+- 如果 Page 没出现,检查 `pages//index.html` 是否存在,以及插件是否启用
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index c49788701..22049d867 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -2,11 +2,15 @@ import asyncio
 import copy
 import io
 import os
+import re
+import shutil
 import sys
 import uuid
 import zipfile
 from datetime import datetime
+from pathlib import Path
 from types import SimpleNamespace
+from urllib.parse import parse_qs, urlsplit, urlunsplit
 
 import pytest
 import pytest_asyncio
@@ -16,9 +20,10 @@ from werkzeug.datastructures import FileStorage
 from astrbot.core import LogBroker
 from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
 from astrbot.core.db.sqlite import SQLiteDatabase
-from astrbot.core.star.star import star_registry
+from astrbot.core.star.star import StarMetadata, star_registry
 from astrbot.core.star.star_handler import star_handlers_registry
 from astrbot.core.utils.pip_installer import PipInstallError
+from astrbot.dashboard.routes.auth import DASHBOARD_JWT_COOKIE_NAME
 from astrbot.dashboard.routes.plugin import PluginRoute
 from astrbot.dashboard.server import AstrBotDashboard
 from tests.fixtures.helpers import (
@@ -27,6 +32,91 @@ from tests.fixtures.helpers import (
     create_mock_updater_update,
 )
 
+PLUGIN_PAGE_DEMO_NAME = "astrbot_plugin_page_demo"
+PLUGIN_PAGE_DEMO_PAGE_NAME = "bridge-demo"
+
+
+def _strip_query(url: str) -> str:
+    parsed = urlsplit(url)
+    return urlunsplit(("", "", parsed.path, "", parsed.fragment))
+
+
+@pytest.fixture
+def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch):
+    plugin_root = (
+        Path(core_lifecycle_td.plugin_manager.plugin_store_path)
+        / PLUGIN_PAGE_DEMO_NAME
+    )
+    page_root = plugin_root / "pages" / PLUGIN_PAGE_DEMO_PAGE_NAME
+    shared_root = page_root / "shared"
+    images_root = page_root / "images"
+    shared_root.mkdir(parents=True, exist_ok=True)
+    images_root.mkdir(parents=True, exist_ok=True)
+
+    (page_root / "index.html").write_text(
+        """
+
+
+  
+    
+    Plugin Page Demo
+    
+  
+  
+    

Single plugin Page with internal navigation

+
+ + + +""".strip(), + encoding="utf-8", + ) + (page_root / "app.js").write_text( + """ +import React from "react"; +import "./shared/common.js"; + +function renderTabs() { + return ["dashboard", "settings"]; +} + +window.renderTabs = renderTabs; +""".strip(), + encoding="utf-8", + ) + (shared_root / "common.js").write_text( + "window.__pluginCommonLoaded = true;\n", encoding="utf-8" + ) + (shared_root / "base.css").write_text( + 'body { background-image: url("../images/logo.svg"); }\n', + encoding="utf-8", + ) + (images_root / "logo.svg").write_text( + '\n', + encoding="utf-8", + ) + + plugin = StarMetadata( + name=PLUGIN_PAGE_DEMO_NAME, + author="AstrBot Test", + desc="Plugin Page demo", + version="1.0.0", + display_name="Plugin Page Demo", + root_dir_name=PLUGIN_PAGE_DEMO_NAME, + activated=True, + ) + + monkeypatch.setattr( + core_lifecycle_td.plugin_manager.context, + "get_all_stars", + lambda: [plugin], + ) + + try: + yield plugin + finally: + shutil.rmtree(plugin_root, ignore_errors=True) + @pytest_asyncio.fixture(scope="module") async def core_lifecycle_td(tmp_path_factory): @@ -76,8 +166,14 @@ async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecyc @pytest.mark.asyncio -async def test_auth_login(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle): +async def test_auth_login( + app: Quart, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch: pytest.MonkeyPatch, +): """Tests the login functionality with both wrong and correct credentials.""" + monkeypatch.setitem(app.config, "DASHBOARD_JWT_COOKIE_SECURE", False) + test_client = app.test_client() response = await test_client.post( "/api/auth/login", @@ -95,6 +191,355 @@ async def test_auth_login(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle): ) data = await response.get_json() assert data["status"] == "ok" and "token" in data["data"] + set_cookie_headers = response.headers.getlist("Set-Cookie") + jwt_cookie_header = next( + (value for value in set_cookie_headers if DASHBOARD_JWT_COOKIE_NAME in value), + "", + ) + assert jwt_cookie_header + assert "HttpOnly" in jwt_cookie_header + assert "SameSite=Strict" in jwt_cookie_header + assert "Secure" not in jwt_cookie_header + + +@pytest.mark.asyncio +async def test_auth_login_secure_cookie_override( + app: Quart, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setitem(app.config, "DASHBOARD_JWT_COOKIE_SECURE", True) + + test_client = app.test_client() + response = await test_client.post( + "/api/auth/login", + json={ + "username": core_lifecycle_td.astrbot_config["dashboard"]["username"], + "password": core_lifecycle_td.astrbot_config["dashboard"]["password"], + }, + ) + assert response.status_code == 200 + + set_cookie_headers = response.headers.getlist("Set-Cookie") + jwt_cookie_header = next( + (value for value in set_cookie_headers if DASHBOARD_JWT_COOKIE_NAME in value), + "", + ) + assert jwt_cookie_header + assert "Secure" in jwt_cookie_header + assert "SameSite=Strict" in jwt_cookie_header + + +def test_plugin_page_content_path_escapes_plugin_name(): + assert ( + PluginRoute._build_plugin_page_content_path("plugin with space", "main page") + == "/api/plugin/page/content/plugin%20with%20space/main%20page/" + ) + assert ( + PluginRoute._build_plugin_page_content_path( + "plugin with space", "main page", "assets/main file.js" + ) + == "/api/plugin/page/content/plugin%20with%20space/main%20page/assets/main%20file.js" + ) + + +@pytest.mark.asyncio +async def test_plugin_get_excludes_scanned_pages( + app: Quart, + authenticated_header: dict, + registered_plugin_page: StarMetadata, +): + test_client = app.test_client() + response = await test_client.get("/api/plugin/get", headers=authenticated_header) + assert response.status_code == 200 + data = await response.get_json() + assert data["status"] == "ok" + + plugin = next( + item for item in data["data"] if item["name"] == PLUGIN_PAGE_DEMO_NAME + ) + assert plugin["activated"] is True + assert "page" not in plugin + assert "pages" not in plugin + + +@pytest.mark.asyncio +async def test_plugin_detail_includes_scanned_page_component( + app: Quart, + authenticated_header: dict, + registered_plugin_page: StarMetadata, +): + test_client = app.test_client() + response = await test_client.get( + f"/api/plugin/detail?name={PLUGIN_PAGE_DEMO_NAME}", + headers=authenticated_header, + ) + assert response.status_code == 200 + data = await response.get_json() + assert data["status"] == "ok" + + page_components = [ + component + for component in data["data"]["components"] + if component["type"] == "page" + ] + assert page_components == [ + { + "type": "page", + "name": PLUGIN_PAGE_DEMO_PAGE_NAME, + "title": PLUGIN_PAGE_DEMO_PAGE_NAME, + "page_name": PLUGIN_PAGE_DEMO_PAGE_NAME, + "description": "Plugin Page entry", + "plugin_name": PLUGIN_PAGE_DEMO_NAME, + } + ] + + +@pytest.mark.asyncio +async def test_plugin_page_entry_returns_signed_content_path( + app: Quart, + authenticated_header: dict, + registered_plugin_page: StarMetadata, +): + test_client = app.test_client() + 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 + data = await response.get_json() + assert data["status"] == "ok" + assert data["data"]["name"] == PLUGIN_PAGE_DEMO_PAGE_NAME + assert data["data"]["title"] == 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}/" + ) + assert "asset_token=" in data["data"]["content_path"] + + +@pytest.mark.asyncio +async def test_plugin_page_content_requires_auth( + app: Quart, + 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}/" + ) + assert response.status_code == 401 + data = await response.get_json() + assert data["status"] == "error" + + +@pytest.mark.asyncio +async def test_plugin_page_content_supports_cookie_auth( + app: Quart, + core_lifecycle_td: AstrBotCoreLifecycle, + registered_plugin_page: StarMetadata, +): + test_client = app.test_client() + login_response = await test_client.post( + "/api/auth/login", + json={ + "username": core_lifecycle_td.astrbot_config["dashboard"]["username"], + "password": core_lifecycle_td.astrbot_config["dashboard"]["password"], + }, + ) + 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}/" + ) + 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, + ) + assert asset_url_match is not None + asset_response = await test_client.get(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 + + bridge_url_match = re.search( + r'src="([^"]+/bridge-sdk\.js[^"]*)"', + content, + ) + assert bridge_url_match is not None + 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") + assert "AstrBotPluginPage" in bridge_content + + +@pytest.mark.asyncio +async def test_plugin_page_content_issues_scoped_asset_token( + app: Quart, + 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, + ) + 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 + css_response = await anonymous_client.get(css_url.group(1)) + assert css_response.status_code == 200 + + 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_assets_require_dashboard_auth( + app: Quart, + 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( + app: Quart, + 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", + headers=authenticated_header, + ) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_logout_clears_cookie_for_plugin_page( + app: Quart, + core_lifecycle_td: AstrBotCoreLifecycle, + registered_plugin_page: StarMetadata, +): + test_client = app.test_client() + response = await test_client.post( + "/api/auth/login", + json={ + "username": core_lifecycle_td.astrbot_config["dashboard"]["username"], + "password": core_lifecycle_td.astrbot_config["dashboard"]["password"], + }, + ) + assert response.status_code == 200 + + response = await test_client.get( + f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/" + ) + 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 + clear_cookie_header = next( + ( + value + for value in logout_response.headers.getlist("Set-Cookie") + if DASHBOARD_JWT_COOKIE_NAME in value + ), + "", + ) + assert clear_cookie_header + assert f"{DASHBOARD_JWT_COOKIE_NAME}=;" in clear_cookie_header + assert "Max-Age=0" in clear_cookie_header + assert "SameSite=Strict" in clear_cookie_header + + response = await test_client.get( + f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/" + ) + assert response.status_code == 401 + asset_response = await test_client.get(_strip_query(asset_url_match.group(1))) + assert asset_response.status_code == 401 @pytest.mark.asyncio