diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index 319110a02d..562076ea87 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -813,11 +813,12 @@ async def delete_plugin_config_file_by_id( @router.get("/plugins/readme") async def get_plugin_readme_by_id( plugin_id: str = Query(...), + file: str | None = Query(None), _auth: AuthContext = Depends(require_plugin_scope), service: PluginService = Depends(get_service), ): return await _run_service( - lambda: service.get_plugin_readme(plugin_id), + lambda: service.get_plugin_readme(plugin_id, file) if file else service.get_plugin_readme(plugin_id), log_label="/api/plugin/readme", ) @@ -1080,11 +1081,12 @@ async def delete_plugin_config_file( @router.get("/plugins/{plugin_id}/readme") async def get_plugin_readme( plugin_id: str, + file: str | None = None, _auth: AuthContext = Depends(require_plugin_scope), service: PluginService = Depends(get_service), ): return await _run_service( - lambda: service.get_plugin_readme(plugin_id), + lambda: service.get_plugin_readme(plugin_id, file) if file else service.get_plugin_readme(plugin_id), log_label="/api/plugin/readme", ) diff --git a/astrbot/dashboard/services/plugin_service.py b/astrbot/dashboard/services/plugin_service.py index 353fca2b55..0b4a18b631 100644 --- a/astrbot/dashboard/services/plugin_service.py +++ b/astrbot/dashboard/services/plugin_service.py @@ -1921,14 +1921,31 @@ def resolve_plugin_dir(self, plugin_name: str) -> Path: raise PluginServiceError(f"无法找到插件 {plugin_name} 的目录") return plugin_dir - def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]: + def get_plugin_readme( + self, plugin_name: str | None, file: str = "README.md" + ) -> tuple[dict, str]: if not plugin_name: logger.warning("The plugin name is empty.") raise PluginServiceError("插件名称不能为空") plugin_dir = self.resolve_plugin_dir(plugin_name) - readme_path = plugin_dir / "README.md" + # 语言切换:仅允许读取插件目录下的 README*.md,且禁止路径穿越 + if ( + "/" in file + or "\\" in file + or ".." in file + or not (file.lower().startswith("readme") and file.lower().endswith(".md")) + ): + raise PluginServiceError("非法的 README 文件名") + # 解析符号链接,防止 README*.md 链接指向插件目录之外 + plugin_dir = plugin_dir.resolve() + readme_path = (plugin_dir / file).resolve() + if plugin_dir not in readme_path.parents: + raise PluginServiceError("非法的 README 文件名") + if not readme_path.is_file(): + # 指定语言文件不存在时回退默认 README.md,行为与旧版一致 + readme_path = plugin_dir / "README.md" if not readme_path.is_file(): logger.warning(f"Plugin {plugin_name} has no README file.") raise PluginServiceError(f"插件 {plugin_name} 没有README文件") diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 2a87bab96a..a9c8d14737 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -1797,6 +1797,7 @@ export type DeletePluginConfigFileByIdError = unknown; export type GetPluginReadmeByIdData = { query: { plugin_id: string; + file?: string; }; }; @@ -1991,6 +1992,7 @@ export type DeletePluginConfigFileError = unknown; export type GetPluginReadmeData = { path: { plugin_id: string; + file?: string; }; }; diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index f136e08c20..604fb67f8c 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -1321,9 +1321,9 @@ export const pluginApi = { }), ); }, - readme(pluginId: string) { + readme(pluginId: string, file?: string) { return typed( - openApiV1.getPluginReadmeById({ query: { plugin_id: pluginId } }), + openApiV1.getPluginReadmeById({ query: { plugin_id: pluginId, file } }), ); }, changelog(pluginId: string) { diff --git a/dashboard/src/views/extension/PluginDetailPage.vue b/dashboard/src/views/extension/PluginDetailPage.vue index 97dc169561..0f97eb517a 100644 --- a/dashboard/src/views/extension/PluginDetailPage.vue +++ b/dashboard/src/views/extension/PluginDetailPage.vue @@ -92,6 +92,8 @@ const readmeLoading = ref(false); const readmeError = ref(""); const readmeEmpty = ref(false); const renderedReadme = ref(""); +const readmeFile = ref("README.md"); +let readmeRequestId = 0; const changelogLoading = ref(false); const changelogError = ref(""); const changelogEmpty = ref(false); @@ -582,6 +584,15 @@ const renderMarkdown = (source) => { if (href.startsWith("http") || href.startsWith("//")) { link.setAttribute("target", "_blank"); link.setAttribute("rel", "noopener noreferrer"); + return; + } + // README 语言切换链接(相对路径 README*.md):预览内本地切换,不跳转 + const fileName = href.split("/").pop() || ""; + if (!isMarketDetail.value && /^README[^/]*\.md$/i.test(fileName)) { + link.addEventListener("click", (e) => { + e.preventDefault(); + void switchReadmeFile(fileName); + }); } }); @@ -639,6 +650,7 @@ const fetchReadme = async () => { const plugin = pluginData.value || {}; if (!plugin?.name) return; + readmeFile.value = "README.md"; readmeLoading.value = true; readmeError.value = ""; readmeEmpty.value = false; @@ -706,6 +718,36 @@ const fetchReadme = async () => { } }; +const switchReadmeFile = async (fileName) => { + if (!fileName || fileName === readmeFile.value) return; + const plugin = pluginData.value || {}; + if (!plugin?.name || isMarketDetail.value) return; // 市场来源用远端 URL,无法本地切换 + const requestId = ++readmeRequestId; + readmeLoading.value = true; + readmeError.value = ""; + readmeEmpty.value = false; + try { + const res = await pluginApi.readme(plugin.name, fileName); + if (requestId !== readmeRequestId) return; // 丢弃过期响应,防止旧结果覆盖新选择 + if (res.data.status !== "ok") { + readmeError.value = res.data.message || tm("messages.operationFailed"); + return; + } + const content = res.data.data?.content || ""; + if (!content) { + readmeError.value = `${fileName} 内容为空`; + return; + } + readmeFile.value = fileName; + renderedReadme.value = renderMarkdown(content); + } catch (err) { + if (requestId !== readmeRequestId) return; + readmeError.value = err?.message || String(err); + } finally { + readmeLoading.value = false; + } +}; + const fetchChangelog = async () => { const plugin = pluginData.value || {}; if (!plugin?.name) return;