Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion astrbot/dashboard/routes/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,8 +737,26 @@ async def on_plugin(self):

async def get_plugin_readme(self):
plugin_name = request.args.get("name")
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
repo_url = request.args.get("repo")
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容, repo: {repo_url}")

# 如果提供了 repo_url,优先从远程获取
if repo_url:
try:
readme_content = await self._fetch_remote_readme(repo_url)
if readme_content:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

if readme_content: 在 README 文件存在但内容为空(空字符串)时会评估为 False,从而导致返回错误响应。建议使用 if readme_content is not None: 来准确判断是否成功获取到了内容,以支持空 README 文件的显示。

Suggested change
if readme_content:
if readme_content is not None:

return (
Response()
.ok({"content": readme_content}, "成功获取README内容")
.__dict__
)
else:
return Response().error("无法从远程仓库获取README文件").__dict__
except Exception as e:
logger.error(f"从远程获取README失败: {traceback.format_exc()}")
return Response().error(f"获取README失败: {e!s}").__dict__
Comment on lines +756 to +758
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): The API response exposes raw exception messages to clients, which can leak internal details.

In the remote README error path, Response().error(f"获取README失败: {e!s}") returns the raw exception message to clients. Instead, keep detailed info (including e and stack traces) in logs only, and return a generic, static error message and/or error code in the API response.

Suggested change
except Exception as e:
logger.error(f"从远程获取README失败: {traceback.format_exc()}")
return Response().error(f"获取README失败: {e!s}").__dict__
except Exception as e:
logger.error(f"从远程获取README失败: {traceback.format_exc()}")
# 不将具体异常信息暴露给客户端,仅返回通用错误提示
return Response().error("获取README失败,请稍后重试").__dict__


# 否则从本地获取
if not plugin_name:
logger.warning("插件名称为空")
return Response().error("插件名称不能为空").__dict__
Expand Down Expand Up @@ -791,6 +809,57 @@ async def get_plugin_readme(self):
logger.error(f"/api/plugin/readme: {traceback.format_exc()}")
return Response().error(f"读取README文件失败: {e!s}").__dict__

async def _fetch_remote_readme(self, repo_url: str) -> str | None:
"""从远程GitHub仓库获取README内容"""
# 解析GitHub仓库URL
# 支持格式: https://github.com/owner/repo 或 https://github.com/owner/repo.git
repo_url = repo_url.rstrip("/").replace(".git", "")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

使用 .replace(".git", "") 会替换 URL 中所有出现的 .git 字符串,这在仓库名本身包含 .git 时(例如 git-tools)会导致解析出的仓库名错误。建议使用 .removesuffix(".git"),它仅移除末尾的后缀,且在 Python 3.9+ 中可用。

Suggested change
repo_url = repo_url.rstrip("/").replace(".git", "")
repo_url = repo_url.rstrip("/").removesuffix(".git")


# 提取 owner 和 repo
parts = repo_url.split("/")
if len(parts) < 2:
return None

owner = parts[-2]
repo = parts[-1]

# 尝试多种README文件名
readme_names = ["README.md", "readme.md", "README.MD", "Readme.md"]

ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)

async with aiohttp.ClientSession(
trust_env=True,
connector=connector,
) as session:
for readme_name in readme_names:
# 使用GitHub raw content URL
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{readme_name}"
try:
async with session.get(raw_url) as response:
if response.status == 200:
content = await response.text()
logger.debug(f"成功从 {raw_url} 获取README")
return content
except Exception as e:
logger.debug(f"从 {raw_url} 获取失败: {e}")
continue

# 尝试 master 分支
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/master/{readme_name}"
try:
async with session.get(raw_url) as response:
if response.status == 200:
content = await response.text()
logger.debug(f"成功从 {raw_url} 获取README")
return content
except Exception as e:
logger.debug(f"从 {raw_url} 获取失败: {e}")
continue

return None

async def get_plugin_changelog(self):
"""获取插件更新日志

Expand Down
18 changes: 17 additions & 1 deletion dashboard/src/components/extension/MarketPluginCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const props = defineProps({
},
});

const emit = defineEmits(["install"]);
const emit = defineEmits(["install", "viewReadme"]);

const normalizePlatformList = (platforms) => {
if (!Array.isArray(platforms)) return [];
Comment on lines +23 to 26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The emitted event name viewReadme does not match the parent listener @view-readme, which will prevent the handler from firing.

In Vue 3, custom event names are case-sensitive and are not auto-converted between camelCase and kebab-case. Here you emit "viewReadme", but the parent listens with @view-readme, so the handler will never run.

Use a single naming style for the event in both places, e.g.:

  • Kebab-case: defineEmits(["view-readme"]) and emit("view-readme", plugin)
  • CamelCase: keep emit("viewReadme", plugin) and change the parent to @viewReadme="viewReadme".

Expand All @@ -35,6 +35,10 @@ const handleInstall = (plugin) => {
emit("install", plugin);
};

const handleViewReadme = (plugin) => {
emit("viewReadme", plugin);
};

</script>

<template>
Expand Down Expand Up @@ -198,6 +202,18 @@ const handleInstall = (plugin) => {
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn
v-if="plugin?.repo"
color="info"
size="small"
variant="tonal"
class="market-action-btn"
@click="handleViewReadme(plugin)"
style="height: 32px"
>
<v-icon icon="mdi-file-document-outline" start size="small"></v-icon>
{{ tm("buttons.viewDocs") }}
</v-btn>
<v-btn
v-if="plugin?.repo"
color="secondary"
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/components/shared/ExtensionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ const viewChangelog = () => {
}}</v-list-item-title>
</v-list-item>

<v-list-item class="styled-menu-item" prepend-icon="mdi-file-document-edit-outline" @click="viewChangelog">
<v-list-item-title>{{ tm("buttons.viewChangelog") }}</v-list-item-title>
</v-list-item>

<v-list-item class="styled-menu-item" prepend-icon="mdi-delete" @click="uninstallExtension">
<v-list-item-title class="text-error">{{ tm("card.actions.uninstallPlugin") }}</v-list-item-title>
</v-list-item>
Expand Down
10 changes: 7 additions & 3 deletions dashboard/src/components/shared/ReadmeDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ const requiresPluginName = computed(
);

async function fetchContent() {
if (requiresPluginName.value && !props.pluginName) return;
if (requiresPluginName.value && !props.pluginName && !props.repoUrl) return;
const requestId = ++lastRequestId.value;
loading.value = true;
content.value = null;
Expand All @@ -250,6 +250,10 @@ async function fetchContent() {
let params;
if (requiresPluginName.value) {
params = { name: props.pluginName };
// 如果提供了 repoUrl,优先使用远程获取
if (props.repoUrl) {
params.repo = props.repoUrl;
}
} else if (props.mode === "first-notice") {
params = { locale: locale.value };
}
Expand All @@ -270,10 +274,10 @@ async function fetchContent() {
}

watch(
[() => props.show, () => props.pluginName, () => props.mode],
[() => props.show, () => props.pluginName, () => props.mode, () => props.repoUrl],
([show, name]) => {
if (!show) return;
if (requiresPluginName.value && !name) return;
if (requiresPluginName.value && !name && !props.repoUrl) return;
fetchContent();
},
{ immediate: true },
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/i18n/locales/en-US/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"viewInfo": "Handlers",
"viewDocs": "Documentation",
"viewRepo": "Repository",
"viewChangelog": "View Changelog",
"close": "Close",
"save": "Save",
"saveAndClose": "Save and Close",
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/i18n/locales/ru-RU/features/extension.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
Expand Down Expand Up @@ -43,6 +43,7 @@
"viewInfo": "Детали",
"viewDocs": "Документация",
"viewRepo": "Репозиторий",
"viewChangelog": "Смотреть журнал изменений",
"close": "Закрыть",
"save": "Сохранить",
"saveAndClose": "Сохранить и закрыть",
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/i18n/locales/zh-CN/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"viewInfo": "行为",
"viewDocs": "文档",
"viewRepo": "仓库",
"viewChangelog": "查看更新日志",
"close": "关闭",
"save": "保存",
"saveAndClose": "保存并关闭",
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/views/extension/InstalledPluginsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,14 @@ const pinnedPlugins = computed(() => {
<v-list-item-title>{{ tm("buttons.update") }}</v-list-item-title>
</v-list-item>

<v-list-item
class="styled-menu-item"
prepend-icon="mdi-file-document-edit-outline"
@click="viewChangelog(item)"
>
<v-list-item-title>{{ tm("buttons.viewChangelog") }}</v-list-item-title>
</v-list-item>

<v-list-item
class="styled-menu-item"
prepend-icon="mdi-delete"
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/views/extension/MarketPluginsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ const marketCategorySelectItems = computed(() =>
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
@view-readme="viewReadme"
/>
</v-col>
</v-row>
Expand Down Expand Up @@ -397,6 +398,7 @@ const marketCategorySelectItems = computed(() =>
:default-plugin-icon="defaultPluginIcon"
:show-plugin-full-name="showPluginFullName"
@install="handleInstallPlugin"
@view-readme="viewReadme"
/>
</v-col>
</v-row>
Expand Down
Loading