From 9383c3cbf9f24a0b166a2fef3713aff99e466d9c Mon Sep 17 00:00:00 2001 From: clown145 Date: Tue, 26 May 2026 17:24:35 +0800 Subject: [PATCH 1/6] fix: load plugin readme local image assets --- astrbot/dashboard/routes/plugin.py | 55 +++++++++- astrbot/dashboard/server.py | 6 +- .../src/components/shared/ReadmeDialog.vue | 46 ++++++++ tests/test_dashboard.py | 100 +++++++++++++++++- 4 files changed, 200 insertions(+), 7 deletions(-) diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index bb7769926a..b97c56b316 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -1,15 +1,17 @@ import asyncio import hashlib import json +import mimetypes import os import ssl import traceback from dataclasses import dataclass from datetime import datetime +from pathlib import Path import aiohttp import certifi -from quart import request +from quart import abort, request, send_file from astrbot.api import sp from astrbot.core import DEMO_MODE, file_token_service, logger @@ -33,6 +35,7 @@ PLUGIN_UPDATE_CONCURRENCY = ( 3 # limit concurrent updates to avoid overwhelming plugin sources ) +PLUGIN_ASSET_MIME_PREFIX = "image/" @dataclass @@ -65,6 +68,7 @@ def __init__( "/plugin/reload-failed": ("POST", self.reload_failed_plugins), "/plugin/reload": ("POST", self.reload_plugins), "/plugin/readme": ("GET", self.get_plugin_readme), + "/plugin/asset": ("GET", self.get_plugin_asset), "/plugin/changelog": ("GET", self.get_plugin_changelog), "/plugin/source/get": ("GET", self.get_custom_source), "/plugin/source/save": ("POST", self.save_custom_source), @@ -86,6 +90,55 @@ def __init__( self._logo_cache = {} + def _get_plugin_dir(self, plugin_name: str) -> Path | None: + plugin_obj = self.plugin_manager.context.get_registered_star(plugin_name) + if not plugin_obj or not plugin_obj.root_dir_name: + return None + + if plugin_obj.reserved: + root_path = self.plugin_manager.reserved_plugin_path + else: + root_path = self.plugin_manager.plugin_store_path + return Path(root_path) / plugin_obj.root_dir_name + + def _resolve_plugin_asset_path( + self, + plugin_name: str, + asset_path: str, + ) -> Path | None: + plugin_dir = self._get_plugin_dir(plugin_name) + if not plugin_dir: + return None + + root = plugin_dir.resolve(strict=False) + target = (root / asset_path).resolve(strict=False) + try: + target.relative_to(root) + except ValueError: + return None + return target + + async def get_plugin_asset(self): + plugin_name = request.args.get("name") + asset_path = request.args.get("path") + + if not plugin_name or not asset_path: + return abort(404) + + try: + target = self._resolve_plugin_asset_path(plugin_name, asset_path) + if not target or not target.is_file(): + return abort(404) + + mimetype, _ = mimetypes.guess_type(target.name) + if not mimetype or not mimetype.startswith(PLUGIN_ASSET_MIME_PREFIX): + return abort(404) + + return await send_file(str(target), mimetype=mimetype) + except (OSError, RuntimeError): + logger.warning(f"插件资源访问失败: {plugin_name}/{asset_path}") + return abort(404) + async def check_plugin_compatibility(self): try: data = await request.get_json() diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index a4742aa672..0913d36ed3 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -205,8 +205,12 @@ async def auth_middleware(self): ] if any(request.path.startswith(prefix) for prefix in allowed_endpoints): return None + # 声明 JWT - token = request.headers.get("Authorization") + if request.path == "/api/plugin/asset": + token = request.args.get("token") or request.headers.get("Authorization") + else: + token = request.headers.get("Authorization") if not token: r = jsonify(Response().error("未授权").__dict__) r.status_code = 401 diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue index ddc27cd900..20cd89257a 100644 --- a/dashboard/src/components/shared/ReadmeDialog.vue +++ b/dashboard/src/components/shared/ReadmeDialog.vue @@ -53,6 +53,47 @@ onUnmounted(() => { if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value); }); +function isRemoteImageSrc(src) { + const value = (src || "").trim().toLowerCase(); + return ( + value.startsWith("http://") || + value.startsWith("https://") || + value.startsWith("//") || + value.startsWith("data:") || + value.startsWith("blob:") + ); +} + +function getPluginAssetSrc(src) { + if (!props.pluginName) return src; + + const value = (src || "").trim(); + if (!value || value.startsWith("#") || isRemoteImageSrc(value)) return src; + if (value.startsWith("/api/")) return src; + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value)) return src; + + const normalizedValue = value.startsWith("/") ? value.slice(1) : value; + const pathEnd = normalizedValue.search(/[?#]/); + const rawPath = + pathEnd === -1 ? normalizedValue : normalizedValue.slice(0, pathEnd); + if (!rawPath) return src; + + let decodedPath = rawPath; + try { + decodedPath = decodeURI(rawPath); + } catch (err) { + decodedPath = rawPath; + } + + const params = new URLSearchParams({ + name: props.pluginName, + path: decodedPath, + }); + const token = localStorage.getItem("token"); + if (token) params.set("token", token); + return `/api/plugin/asset?${params.toString()}`; +} + // 渲染后的 HTML const renderedHtml = computed(() => { // 强制依赖 locale,确保语言切换时重新渲染 @@ -161,6 +202,11 @@ const renderedHtml = computed(() => { link.setAttribute("rel", "noopener noreferrer"); } }); + tempDiv.querySelectorAll("img").forEach((img) => { + const src = img.getAttribute("src"); + const assetSrc = getPluginAssetSrc(src); + if (assetSrc && assetSrc !== src) img.setAttribute("src", assetSrc); + }); return tempDiv.innerHTML; }); diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index cbaec66c7c..836a2133b3 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -15,7 +15,6 @@ from astrbot.dashboard.server import AstrBotDashboard from tests.fixtures.helpers import ( MockPluginBuilder, - MockPluginConfig, create_mock_updater_install, create_mock_updater_update, ) @@ -145,9 +144,7 @@ async def test_plugins( monkeypatch.setattr( core_lifecycle_td.plugin_manager.updator, "install", mock_install ) - monkeypatch.setattr( - core_lifecycle_td.plugin_manager.updator, "update", mock_update - ) + monkeypatch.setattr(core_lifecycle_td.plugin_manager.updator, "update", mock_update) try: # 插件安装 @@ -158,7 +155,9 @@ async def test_plugins( ) assert response.status_code == 200 data = await response.get_json() - assert data["status"] == "ok", f"安装失败: {data.get('message', 'unknown error')}" + assert data["status"] == "ok", ( + f"安装失败: {data.get('message', 'unknown error')}" + ) # 验证插件已注册 exists = any(md.name == test_plugin_name for md in star_registry) @@ -201,6 +200,97 @@ async def test_plugins( builder.cleanup(test_plugin_name) +@pytest.mark.asyncio +async def test_plugin_asset_api( + app: Quart, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, +): + test_client = app.test_client() + plugin_store_path = core_lifecycle_td.plugin_manager.plugin_store_path + builder = MockPluginBuilder(plugin_store_path) + + test_plugin_name = "test_plugin_asset" + plugin_dir = builder.create(test_plugin_name) + try: + success, error = await core_lifecycle_td.plugin_manager.load( + specified_dir_name=test_plugin_name + ) + assert success, error + + assets_dir = plugin_dir / "assets" + assets_dir.mkdir(exist_ok=True) + image_content = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + ) + (assets_dir / "demo.png").write_bytes(image_content) + (assets_dir / "demo.jpg").write_bytes(b"\xff\xd8\xff\xd9") + (assets_dir / "demo.webp").write_bytes(b"RIFF\x00\x00\x00\x00WEBP") + (assets_dir / "demo.svg").write_text( + '', + encoding="utf-8", + ) + (assets_dir / "note.txt").write_text("not an image", encoding="utf-8") + + token = authenticated_header["Authorization"].removeprefix("Bearer ") + response = await test_client.get( + "/api/plugin/asset", + query_string={ + "name": test_plugin_name, + "path": "assets/demo.png", + "token": token, + }, + ) + assert response.status_code == 200 + assert response.content_type.startswith("image/png") + assert await response.get_data() == image_content + + for image_name in ("demo.jpg", "demo.webp", "demo.svg"): + response = await test_client.get( + "/api/plugin/asset", + query_string={ + "name": test_plugin_name, + "path": f"assets/{image_name}", + "token": token, + }, + ) + assert response.status_code == 200 + assert response.content_type.startswith("image/") + + response = await test_client.get( + "/api/plugin/asset", + query_string={"name": test_plugin_name, "path": "assets/demo.png"}, + ) + assert response.status_code == 401 + + response = await test_client.get( + "/api/plugin/asset", + query_string={ + "name": test_plugin_name, + "path": "../metadata.yaml", + "token": token, + }, + ) + assert response.status_code == 404 + + response = await test_client.get( + "/api/plugin/asset", + query_string={ + "name": test_plugin_name, + "path": "assets/note.txt", + "token": token, + }, + ) + assert response.status_code == 404 + finally: + try: + await core_lifecycle_td.plugin_manager.uninstall_plugin(test_plugin_name) + except Exception: + builder.cleanup(test_plugin_name) + + @pytest.mark.asyncio async def test_commands_api(app: Quart, authenticated_header: dict): """Tests the command management API endpoints.""" From fe91f88a05f99d22359bb1b7900bf22df00072df Mon Sep 17 00:00:00 2001 From: clown145 Date: Tue, 26 May 2026 19:01:20 +0800 Subject: [PATCH 2/6] feat: support github plugin readme image source --- astrbot/dashboard/routes/plugin.py | 74 +++++++++++++------ .../src/components/shared/ProxySelector.vue | 43 +++++++++++ .../src/components/shared/ReadmeDialog.vue | 55 +++++++++++++- .../i18n/locales/en-US/features/settings.json | 6 +- .../i18n/locales/zh-CN/features/settings.json | 6 +- dashboard/src/utils/githubProxy.js | 51 +++++++++++++ dashboard/src/views/ExtensionPage.vue | 2 +- .../src/views/extension/useExtensionPage.js | 8 +- tests/test_dashboard.py | 72 ++++++++++++++++++ 9 files changed, 280 insertions(+), 37 deletions(-) create mode 100644 dashboard/src/utils/githubProxy.js diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index b97c56b316..41da3dc56e 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -3,11 +3,13 @@ import json import mimetypes import os +import re import ssl import traceback from dataclasses import dataclass from datetime import datetime from pathlib import Path +from urllib.parse import urlparse import aiohttp import certifi @@ -36,6 +38,8 @@ 3 # limit concurrent updates to avoid overwhelming plugin sources ) PLUGIN_ASSET_MIME_PREFIX = "image/" +GITHUB_DEFAULT_BRANCH_REF = "HEAD" +GITHUB_REPO_PART_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$") @dataclass @@ -90,6 +94,39 @@ def __init__( self._logo_cache = {} + def _parse_github_repo_url(self, repo_url: str | None) -> tuple[str, str] | None: + if not repo_url: + return None + + parsed = urlparse(repo_url.strip()) + if parsed.scheme not in ("http", "https"): + return None + if parsed.netloc.lower() != "github.com": + return None + + parts = [part for part in parsed.path.strip("/").split("/") if part] + if len(parts) < 2: + return None + + owner = parts[0] + repo = parts[1].removesuffix(".git") + if not owner or not repo: + return None + if not GITHUB_REPO_PART_PATTERN.fullmatch(owner): + return None + if not GITHUB_REPO_PART_PATTERN.fullmatch(repo): + return None + + return owner, repo + + def _build_github_raw_base(self, repo_url: str | None) -> str | None: + repo_info = self._parse_github_repo_url(repo_url) + if not repo_info: + return None + + owner, repo = repo_info + return f"https://github.com/{owner}/{repo}/raw/{GITHUB_DEFAULT_BRANCH_REF}" + def _get_plugin_dir(self, plugin_name: str) -> Path | None: plugin_obj = self.plugin_manager.context.get_registered_star(plugin_name) if not plugin_obj or not plugin_obj.root_dir_name: @@ -766,12 +803,7 @@ async def get_plugin_readme(self): logger.warning("插件名称为空") return Response().error("插件名称不能为空").__dict__ - plugin_obj = None - for plugin in self.plugin_manager.context.get_all_stars(): - if plugin.name == plugin_name: - plugin_obj = plugin - break - + plugin_obj = self.plugin_manager.context.get_registered_star(plugin_name) if not plugin_obj: logger.warning(f"插件 {plugin_name} 不存在") return Response().error(f"插件 {plugin_name} 不存在").__dict__ @@ -780,34 +812,28 @@ async def get_plugin_readme(self): logger.warning(f"插件 {plugin_name} 目录不存在") return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - if plugin_obj.reserved: - plugin_dir = os.path.join( - self.plugin_manager.reserved_plugin_path, - plugin_obj.root_dir_name, - ) - else: - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name, - ) - - if not os.path.isdir(plugin_dir): + plugin_dir = self._get_plugin_dir(plugin_name) + if not plugin_dir or not plugin_dir.is_dir(): logger.warning(f"无法找到插件目录: {plugin_dir}") return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ - readme_path = os.path.join(plugin_dir, "README.md") - - if not os.path.isfile(readme_path): + readme_path = plugin_dir / "README.md" + if not readme_path.is_file(): logger.warning(f"插件 {plugin_name} 没有README文件") return Response().error(f"插件 {plugin_name} 没有README文件").__dict__ try: - with open(readme_path, encoding="utf-8") as f: - readme_content = f.read() + readme_content = readme_path.read_text(encoding="utf-8") return ( Response() - .ok({"content": readme_content}, "成功获取README内容") + .ok( + { + "content": readme_content, + "github_raw_base": self._build_github_raw_base(plugin_obj.repo), + }, + "成功获取README内容", + ) .__dict__ ) except Exception as e: diff --git a/dashboard/src/components/shared/ProxySelector.vue b/dashboard/src/components/shared/ProxySelector.vue index 8cf69a542e..a993196026 100644 --- a/dashboard/src/components/shared/ProxySelector.vue +++ b/dashboard/src/components/shared/ProxySelector.vue @@ -46,14 +46,37 @@ +
+ + +
+ {{ tm('network.proxySelector.readmeImages.hint') }} +
+
- From c72650ac01ad83faf3485303d395b6dbda27a594 Mon Sep 17 00:00:00 2001 From: clown145 Date: Thu, 11 Jun 2026 20:11:14 +0800 Subject: [PATCH 4/6] fix: secure plugin readme asset loading --- astrbot/dashboard/routes/plugin.py | 15 ++++- astrbot/dashboard/server.py | 2 - .../shared/PluginReadmeImageSourceSetting.vue | 55 +++++++------------ .../src/components/shared/ReadmeDialog.vue | 2 - tests/test_dashboard.py | 39 +++++++++++++ 5 files changed, 70 insertions(+), 43 deletions(-) diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 4c73d45d4d..aa0073d27d 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -215,10 +215,14 @@ def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: return None def _parse_github_repo_url(self, repo_url: str | None) -> tuple[str, str] | None: - if not repo_url: + if not isinstance(repo_url, str): return None - parsed = urlsplit(repo_url.strip()) + normalized_repo_url = repo_url.strip() + if not normalized_repo_url: + return None + + parsed = urlsplit(normalized_repo_url) if parsed.scheme not in ("http", "https"): return None if parsed.netloc.lower() != "github.com": @@ -232,6 +236,8 @@ def _parse_github_repo_url(self, repo_url: str | None) -> tuple[str, str] | None repo = parts[1].removesuffix(".git") if not owner or not repo: return None + if owner in {".", ".."} or repo in {".", ".."}: + return None if not GITHUB_REPO_PART_PATTERN.fullmatch(owner): return None if not GITHUB_REPO_PART_PATTERN.fullmatch(repo): @@ -409,7 +415,10 @@ async def get_plugin_asset(self): if not mimetype or not mimetype.startswith(PLUGIN_ASSET_MIME_PREFIX): return await self._plugin_page_error_response(404, "Plugin asset not found") - return await self._serve_plugin_page_static_asset(file_path) + response = await self._serve_plugin_page_static_asset(file_path) + if mimetype == "image/svg+xml": + response.headers["Content-Security-Policy"] = "default-src 'none'" + return response async def _resolve_plugin_pages_root( self, diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 5cc3f3100a..a39337b5ff 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -264,8 +264,6 @@ async def auth_middleware(self): return None is_plugin_page_path = PluginPageAuth.is_protected_path(request.path) token = self._extract_dashboard_jwt() - if not token and request.path == "/api/plugin/asset": - token = request.args.get("token", "").strip() if not token and is_plugin_page_path: token = PluginPageAuth.extract_asset_token() if not token: diff --git a/dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue b/dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue index 4128838961..08945f4076 100644 --- a/dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue +++ b/dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue @@ -5,7 +5,7 @@ class="readme-image-source-switch" color="primary" density="compact" - hide-details="true" + hide-details :label="tm('network.proxySelector.readmeImages.useGitHub')">
@@ -14,7 +14,8 @@
-