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 a9d483ab6..e8c5b375d 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 9fbe813f1..2ebc5cc51 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 56822be22..1f193ceb9 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -43,6 +43,8 @@ "viewInfo": "Handlers", "viewDocs": "Documentation", "viewRepo": "Repository", + "openPages": "Pages", + "openPage": "Open", "close": "Close", "save": "Save", "saveAndClose": "Save and Close", @@ -103,6 +105,7 @@ "docsTitle": "Documentation", "docsEmpty": "No documentation", "handlerGroups": { + "page": "Pages", "skill": "Skills", "command": "Commands / Command Groups", "llm_tool": "LLM Tools", @@ -259,7 +262,11 @@ "noUpdatesAvailable": "No extensions have updates available", "fillSourceNameAndUrl": "Please fill in the complete source name and URL", "invalidUrl": "Please enter a valid URL", - "enterJsonUrl": "Please enter a URL that returns plugin list JSON data" + "enterJsonUrl": "Please enter a URL that returns plugin list JSON data", + "pluginNotFound": "Plugin not found", + "pluginDisabled": "This plugin is disabled. Enable it before opening its page.", + "pluginPageNotFound": "Plugin page not found", + "pluginPageLoadFailed": "Failed to load the plugin page" }, "upload": { "fromFile": "Install from File", diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index 826c601ef..01a19efa6 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -43,6 +43,8 @@ "viewInfo": "Детали", "viewDocs": "Документация", "viewRepo": "Репозиторий", + "openPages": "Pages", + "openPage": "Открыть", "close": "Закрыть", "save": "Сохранить", "saveAndClose": "Сохранить и закрыть", @@ -103,6 +105,7 @@ "docsTitle": "Документация", "docsEmpty": "Документация отсутствует", "handlerGroups": { + "page": "Pages", "skill": "Skills", "command": "Команды / группы команд", "llm_tool": "LLM-инструменты", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 5468dda92..1b7e0caaf 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -43,6 +43,8 @@ "viewInfo": "行为", "viewDocs": "文档", "viewRepo": "仓库", + "openPages": "Pages", + "openPage": "打开", "close": "关闭", "save": "保存", "saveAndClose": "保存并关闭", @@ -103,7 +105,8 @@ "docsTitle": "文档", "docsEmpty": "暂无文档", "handlerGroups": { - "skill": "Skills", + "page": "页面", + "skill": "技能", "command": "指令/指令组", "llm_tool": "LLM 工具", "listener": "事件监听器", @@ -259,7 +262,11 @@ "noUpdatesAvailable": "当前没有可更新的插件", "fillSourceNameAndUrl": "请填写完整的插件源名称和地址", "invalidUrl": "请输入有效的URL地址", - "enterJsonUrl": "请输入返回插件列表JSON数据的URL地址" + "enterJsonUrl": "请输入返回插件列表JSON数据的URL地址", + "pluginNotFound": "未找到该插件", + "pluginDisabled": "该插件当前已禁用,请先启用后再访问页面", + "pluginPageNotFound": "未找到该插件页面", + "pluginPageLoadFailed": "加载插件页面失败" }, "upload": { "fromFile": "从文件安装", diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 88e1c35de..acb80d720 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -26,6 +26,11 @@ const MainRoutes = { path: '/extension', component: () => 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