feat: supports plugin to register custom pages (webui) (#5940)

* 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>
This commit is contained in:
lxfight
2026-05-03 20:41:50 +08:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Soulter Weilong Liao
parent 6eb8a51c70
commit fff9c8ee19
22 changed files with 2442 additions and 34 deletions
+6 -3
View File
@@ -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。
+2 -1
View File
@@ -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",
+66
View File
@@ -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
)
+181
View File
@@ -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");
})();
+53 -12
View File
@@ -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="/",
)
+817 -7
View File
@@ -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"(?P<attr>src|href)=(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)",
re.IGNORECASE,
)
_CSS_URL_RE = re.compile(
r"url\(\s*(?P<quote>[\"\']?)(?P<url>.*?)(?P=quote)\s*\)",
re.IGNORECASE,
)
_JS_DYNAMIC_IMPORT_RE = re.compile(
r"(?P<prefix>\bimport\s*\(\s*)(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)(?P<suffix>\s*\))",
re.IGNORECASE,
)
_JS_MODULE_FROM_RE = re.compile(
r"(?P<prefix>\b(?:import|export)\s+(?:[^;]*?\s+from\s+))(?P<quote>[\"\'])(?P<url>.*?)(?P=quote)",
re.IGNORECASE | re.DOTALL,
)
_JS_SIDE_EFFECT_IMPORT_RE = re.compile(
r"(?P<prefix>\bimport\s+)(?P<quote>[\"\'])(?P<url>[^\"'\r\n]+)(?P=quote)",
re.IGNORECASE,
)
_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/<plugin_name>/<page_name>/",
endpoint="plugin_page_content_entry",
view_func=self.get_plugin_page_entry,
methods=["GET"],
)
self.app.add_url_rule(
"/api/plugin/page/content/<plugin_name>/<page_name>/<path:asset_path>",
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'<script src="{self._get_plugin_page_bridge_sdk_url(extra_query_params)}"></script>'
if "</body>" in rewritten_html:
rewritten_html = rewritten_html.replace(
"</body>", f"{bridge_tag}</body>", 1
)
else:
rewritten_html += bridge_tag
return rewritten_html
def _rewrite_plugin_page_css(
self,
css_text: str,
plugin_name: str,
page_name: str,
css_asset_path: str,
extra_query_params: dict[str, str] | None = None,
) -> str:
def replace_url(match: re.Match[str]) -> str:
raw_url = match.group("url").strip()
quote_char = match.group("quote") or ""
try:
rewritten_url = self._rewrite_relative_asset_url(
raw_url,
css_asset_path,
plugin_name,
page_name,
extra_query_params=extra_query_params,
)
if not rewritten_url:
return match.group(0)
return f"url({quote_char}{rewritten_url}{quote_char})"
except ValueError:
return match.group(0)
return _CSS_URL_RE.sub(replace_url, css_text)
def _rewrite_plugin_page_js(
self,
js_text: str,
plugin_name: str,
page_name: str,
js_asset_path: str,
extra_query_params: dict[str, str] | None = None,
) -> str:
def rewrite_specifier(raw_url: str) -> str:
if not self._is_js_relative_module_specifier(raw_url):
return raw_url
if not self._is_rewritable_asset_url(raw_url):
return raw_url
rewritten = self._rewrite_relative_asset_url(
raw_url,
js_asset_path,
plugin_name,
page_name,
extra_query_params=extra_query_params,
)
return rewritten or raw_url
def replace_dynamic(match: re.Match[str]) -> str:
raw_url = match.group("url")
try:
rewritten = rewrite_specifier(raw_url)
except ValueError:
return match.group(0)
return (
f"{match.group('prefix')}{match.group('quote')}{rewritten}"
f"{match.group('quote')}{match.group('suffix')}"
)
def replace_from(match: re.Match[str]) -> str:
raw_url = match.group("url")
try:
rewritten = rewrite_specifier(raw_url)
except ValueError:
return match.group(0)
return f"{match.group('prefix')}{match.group('quote')}{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 = []
+34 -4
View File
@@ -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"):
@@ -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";
}
@@ -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",
@@ -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-инструменты",
@@ -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": "从文件安装",
+5
View File
@@ -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',
+1
View File
@@ -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 {
+474
View File
@@ -0,0 +1,474 @@
<script setup>
import axios from "axios";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useModuleI18n } from "@/i18n/composables";
const BRIDGE_CHANNEL = "astrbot-plugin-page";
const route = useRoute();
const router = useRouter();
const { tm } = useModuleI18n("features/extension");
const loading = ref(true);
const errorMessage = ref("");
const plugin = ref(null);
const page = ref(null);
const iframeSrc = ref("");
const iframeRef = ref(null);
const sseConnections = new Map();
const BRIDGE_TARGET_ORIGIN = window.location.origin;
let iframeMessageOrigin = null;
const pluginName = computed(() => String(route.params.pluginName || ""));
const pageName = computed(() => String(route.params.pageName || ""));
const getIframeWindow = () => iframeRef.value?.contentWindow || null;
const goBack = () => {
if (window.history.length > 1) {
router.back();
return;
}
router.push("/extension#installed");
};
const cleanupSSEConnections = () => {
for (const eventSource of sseConnections.values()) {
eventSource.close();
}
sseConnections.clear();
};
const postToIframe = (payload) => {
const iframeWindow = getIframeWindow();
if (!iframeWindow) {
return;
}
const targetOrigin =
typeof iframeMessageOrigin === "string" && iframeMessageOrigin !== "null"
? iframeMessageOrigin
: "*";
iframeWindow.postMessage(
{ channel: BRIDGE_CHANNEL, ...payload },
targetOrigin,
);
};
const parseContentDispositionFilename = (headerValue) => {
if (typeof headerValue !== "string") {
return "download.bin";
}
const utf8Match = headerValue.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
const plainMatch = headerValue.match(/filename="?([^";]+)"?/i);
if (plainMatch?.[1]) {
return plainMatch[1];
}
return "download.bin";
};
const normalizePluginEndpoint = (endpoint) => {
if (typeof endpoint !== "string") {
throw new Error("Plugin bridge endpoint must be a string.");
}
const trimmed = endpoint.trim().replace(/^\/+/, "");
if (!trimmed) {
throw new Error("Plugin bridge endpoint cannot be empty.");
}
if (trimmed.includes("\\") || trimmed.includes("://") || trimmed.includes("?") || trimmed.includes("#")) {
throw new Error("Plugin bridge endpoint is invalid.");
}
const segments = trimmed.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Plugin bridge endpoint is invalid.");
}
return segments.map((segment) => encodeURIComponent(segment)).join("/");
};
const buildPluginApiPath = (endpoint) => {
const normalized = normalizePluginEndpoint(endpoint);
return `/api/plug/${encodeURIComponent(pluginName.value)}/${normalized}`;
};
const isBridgeUploadFile = (value) => {
if (!value || typeof value !== "object") {
return false;
}
if (typeof File !== "undefined" && value instanceof File) {
return true;
}
if (typeof Blob !== "undefined" && value instanceof Blob) {
return true;
}
const tag = Object.prototype.toString.call(value);
if (tag === "[object File]" || tag === "[object Blob]") {
return true;
}
return typeof value.arrayBuffer === "function" && typeof value.size === "number";
};
const coerceBridgeUploadFile = async (value, fileName) => {
if (!isBridgeUploadFile(value)) {
throw new Error("Missing uploaded file payload.");
}
if (typeof Blob !== "undefined" && value instanceof Blob) {
return value;
}
const buffer = await value.arrayBuffer();
const fileType =
typeof value.type === "string" && value.type
? value.type
: "application/octet-stream";
if (typeof File !== "undefined") {
return new File([buffer], fileName, {
type: fileType,
lastModified:
typeof value.lastModified === "number" ? value.lastModified : Date.now(),
});
}
return new Blob([buffer], { type: fileType });
};
const sendBridgeResponse = (requestId, ok, payload) => {
postToIframe({
kind: "response",
requestId,
ok,
...(ok ? { data: payload } : { error: payload }),
});
};
const closeSSEConnection = (subscriptionId) => {
const eventSource = sseConnections.get(subscriptionId);
if (eventSource) {
eventSource.close();
sseConnections.delete(subscriptionId);
}
};
const sendIframeContext = () => {
if (!plugin.value || !page.value) {
return;
}
postToIframe({
kind: "context",
context: {
pluginName: plugin.value.name,
displayName: plugin.value.display_name || plugin.value.name,
pageName: page.value.name,
pageTitle: page.value.title,
},
});
};
const handleBridgeRequest = async (message) => {
const { requestId, action } = message;
try {
if (!requestId) {
throw new Error("Missing plugin bridge request id.");
}
if (action === "api:get") {
const response = await axios.get(buildPluginApiPath(message.endpoint), {
params: message.params || {},
});
if (response.data?.status === "error") {
throw new Error(response.data.message || "Plugin GET request failed.");
}
sendBridgeResponse(requestId, true, response.data?.data ?? response.data);
return;
}
if (action === "api:post") {
const response = await axios.post(
buildPluginApiPath(message.endpoint),
message.body || {},
);
if (response.data?.status === "error") {
throw new Error(response.data.message || "Plugin POST request failed.");
}
sendBridgeResponse(requestId, true, response.data?.data ?? response.data);
return;
}
if (action === "files:upload") {
const formData = new FormData();
const uploadFile = await coerceBridgeUploadFile(
message.file,
typeof message.fileName === "string" && message.fileName
? message.fileName
: "upload.bin",
);
formData.append("file", uploadFile);
const response = await axios.post(
buildPluginApiPath(message.endpoint),
formData,
{
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
},
);
if (response.data?.status === "error") {
throw new Error(response.data.message || "Plugin upload request failed.");
}
sendBridgeResponse(requestId, true, response.data?.data ?? response.data);
return;
}
if (action === "files:download") {
const response = await axios.get(buildPluginApiPath(message.endpoint), {
params: message.params || {},
responseType: "blob",
});
const blobUrl = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = blobUrl;
anchor.download =
(typeof message.filename === "string" && message.filename) ||
parseContentDispositionFilename(response.headers["content-disposition"]);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 0);
sendBridgeResponse(requestId, true, { filename: anchor.download });
return;
}
if (action === "sse:subscribe") {
const subscriptionId = String(message.subscriptionId || "");
if (!subscriptionId) {
throw new Error("Missing SSE subscription id.");
}
closeSSEConnection(subscriptionId);
const url = new URL(buildPluginApiPath(message.endpoint), window.location.origin);
Object.entries(message.params || {}).forEach(([key, value]) => {
url.searchParams.set(key, String(value));
});
const eventSource = new EventSource(url.toString(), { withCredentials: true });
sseConnections.set(subscriptionId, eventSource);
eventSource.onopen = () => {
postToIframe({ kind: "sse_state", subscriptionId, state: "open" });
};
eventSource.onmessage = (event) => {
postToIframe({
kind: "sse_message",
subscriptionId,
data: event.data,
lastEventId: event.lastEventId,
});
};
eventSource.onerror = () => {
if (eventSource.readyState === EventSource.CLOSED) {
closeSSEConnection(subscriptionId);
postToIframe({ kind: "sse_state", subscriptionId, state: "closed" });
return;
}
postToIframe({ kind: "sse_state", subscriptionId, state: "error" });
};
sendBridgeResponse(requestId, true, { subscriptionId });
return;
}
if (action === "sse:unsubscribe") {
closeSSEConnection(String(message.subscriptionId || ""));
sendBridgeResponse(requestId, true, { subscriptionId: message.subscriptionId });
return;
}
throw new Error(`Unsupported plugin bridge action: ${action}`);
} catch (error) {
sendBridgeResponse(requestId, false, error?.message || "Plugin bridge request failed.");
}
};
const handleWindowMessage = (event) => {
const iframeWindow = getIframeWindow();
if (!iframeWindow || event.source !== iframeWindow) {
return;
}
if (event.origin !== BRIDGE_TARGET_ORIGIN && event.origin !== "null") {
return;
}
if (iframeMessageOrigin && event.origin !== iframeMessageOrigin) {
return;
}
iframeMessageOrigin = event.origin;
const message = event.data;
if (!message || message.channel !== BRIDGE_CHANNEL) {
return;
}
if (message.kind === "ready") {
sendIframeContext();
return;
}
if (message.kind === "request") {
void handleBridgeRequest(message);
}
};
const handleIframeLoad = () => {
sendIframeContext();
};
const loadPluginPage = async () => {
loading.value = true;
errorMessage.value = "";
plugin.value = null;
page.value = null;
iframeSrc.value = "";
iframeMessageOrigin = null;
cleanupSSEConnections();
try {
const detailResponse = await axios.get("/api/plugin/detail", {
params: {
name: pluginName.value,
},
});
if (detailResponse.data?.status === "error") {
throw new Error(
detailResponse.data.message || tm("messages.pluginPageLoadFailed"),
);
}
const pluginData = detailResponse.data?.data || null;
if (!pluginData) {
errorMessage.value = tm("messages.pluginNotFound");
return;
}
if (!pluginData.activated) {
errorMessage.value = tm("messages.pluginDisabled");
return;
}
const entryResponse = await axios.get("/api/plugin/page/entry", {
params: {
name: pluginName.value,
page: pageName.value,
},
});
if (entryResponse.data?.status === "error") {
throw new Error(
entryResponse.data.message || tm("messages.pluginPageLoadFailed"),
);
}
const pageEntry = entryResponse.data?.data || null;
if (
!pageEntry ||
typeof pageEntry.content_path !== "string" ||
!pageEntry.content_path.length
) {
errorMessage.value = tm("messages.pluginPageNotFound");
return;
}
plugin.value = pluginData;
page.value = pageEntry;
iframeSrc.value = pageEntry.content_path;
} catch (error) {
errorMessage.value =
error?.response?.data?.message || error?.message || tm("messages.pluginPageLoadFailed");
} finally {
loading.value = false;
}
};
onMounted(() => {
window.addEventListener("message", handleWindowMessage);
});
onBeforeUnmount(() => {
window.removeEventListener("message", handleWindowMessage);
cleanupSSEConnections();
});
watch([pluginName, pageName], loadPluginPage, { immediate: true });
</script>
<template>
<div class="plugin-page-page">
<div class="d-flex align-center flex-wrap mb-4" style="gap: 12px">
<v-btn
variant="tonal"
color="primary"
prepend-icon="mdi-arrow-left"
@click="goBack"
>
{{ tm("buttons.back") }}
</v-btn>
<div>
<div class="text-h2 mb-1">
{{ page?.title || pageName || tm("buttons.openPages") }}
</div>
</div>
</div>
<v-card class="plugin-page-card" elevation="0">
<v-card-text class="pa-0">
<div v-if="loading" class="plugin-page-state">
<v-progress-circular indeterminate color="primary" />
<span>{{ tm("status.loading") }}</span>
</div>
<div v-else-if="errorMessage" class="pa-6">
<v-alert type="error" variant="tonal">
{{ errorMessage }}
</v-alert>
</div>
<iframe
v-else
ref="iframeRef"
:src="iframeSrc"
class="plugin-page-frame"
referrerpolicy="no-referrer"
sandbox="allow-scripts allow-forms allow-downloads"
@load="handleIframeLoad"
></iframe>
</v-card-text>
</v-card>
</div>
</template>
<style scoped>
.plugin-page-card {
background-color: rgb(var(--v-theme-surface));
border-radius: 16px;
overflow: hidden;
}
.plugin-page-frame {
width: 100%;
min-height: calc(100vh - 220px);
border: 0;
background: transparent;
}
.plugin-page-state {
min-height: calc(100vh - 220px);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
</style>
@@ -321,7 +321,7 @@ const togglePinnedExtension = (extension) => {
<v-col
cols="12"
md="6"
v-for="extension in filteredPlugins"
v-for="extension in sortedInstalledPlugins"
:key="extension.name"
class="pb-2"
>
@@ -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) }}
</span>
<span>{{ getComponentDescription(component) }}</span>
<v-btn
v-if="group.key === 'page'"
color="primary"
size="small"
variant="tonal"
prepend-icon="mdi-open-in-new"
class="ml-2"
@click="openComponentPage(component)"
>
{{ tm("buttons.openPage") }}
</v-btn>
</div>
</td>
</tr>
+2
View File
@@ -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" },
+151
View File
@@ -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/<page_name>/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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Plugin Page Demo</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<button id="ping">Ping</button>
<pre id="output"></pre>
<script type="module" src="./app.js"></script>
</body>
</html>
```
`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/<plugin_name>/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/<page_name>/`, refreshing the Page is enough
- If a Page does not appear, check that `pages/<page_name>/index.html` exists and the plugin is enabled
+151
View File
@@ -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/<page_name>/index.html`;没有 `index.html` 的目录会被忽略。
如果只是让用户填写几个配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。
## 最小前端示例
`pages/bridge-demo/index.html`
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Plugin Page Demo</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<button id="ping">Ping</button>
<pre id="output"></pre>
<script type="module" src="./app.js"></script>
</body>
</html>
```
`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/<plugin_name>/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_name>/` 下的大多数静态资源后,刷新 Page 即可
- 如果 Page 没出现,检查 `pages/<page_name>/index.html` 是否存在,以及插件是否启用
+447 -2
View File
@@ -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(
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Plugin Page Demo</title>
<link rel="stylesheet" href="shared/base.css" />
</head>
<body>
<h1>Single plugin Page with internal navigation</h1>
<div id="app"></div>
<script type="module" src="app.js"></script>
</body>
</html>
""".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(
'<svg xmlns="http://www.w3.org/2000/svg"></svg>\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