mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
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:
co-authored by
Copilot Autofix powered by AI
Soulter
Weilong Liao
parent
6eb8a51c70
commit
fff9c8ee19
@@ -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。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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");
|
||||
})();
|
||||
@@ -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="/",
|
||||
)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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": "从文件安装",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user