Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
68 changes: 67 additions & 1 deletion astrbot/dashboard/routes/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse

import aiohttp
import certifi
Expand Down Expand Up @@ -737,8 +738,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 +810,53 @@ 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("/").removesuffix(".git")

# 使用 urlparse 严格解析 URL,校验域名和路径
parsed = urlparse(repo_url)

# 仅支持 GitHub 仓库链接
if parsed.netloc.lower() != "github.com":
return None

# 提取路径中的 owner 和 repo,要求至少有两个段
path_parts = [part for part in parsed.path.strip("/").split("/") if part]
if len(path_parts) < 2:
return None

owner, repo = path_parts[0], path_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, timeout=aiohttp.ClientTimeout(total=10)
) as session:
# 尝试从不同分支获取
branches = ["main", "master"]
for branch in branches:
for readme_name in readme_names:
# 使用GitHub raw content URL
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{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
54 changes: 53 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,30 @@ const handleInstall = (plugin) => {
emit("install", plugin);
};

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

// 从 repo URL 提取作者主页链接
const authorHomepageUrl = computed(() => {
const repoUrl = props.plugin?.repo;
if (!repoUrl) return null;

try {
// 解析 GitHub URL,提取 owner
const url = new URL(repoUrl);
if (url.hostname.toLowerCase() !== 'github.com') return null;

const pathParts = url.pathname.split('/').filter(p => p);
if (pathParts.length < 1) return null;

const owner = pathParts[0];
return `https://github.com/${owner}`;
} catch {
return null;
}
});

</script>

<template>
Expand Down Expand Up @@ -98,6 +122,22 @@ const handleInstall = (plugin) => {
>
{{ plugin.author }}
</a>
<a
v-else-if="authorHomepageUrl"
:href="authorHomepageUrl"
target="_blank"
@click.stop
class="text-subtitle-2 font-weight-medium"
style="
text-decoration: none;
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</a>
<span
v-else
class="text-subtitle-2 font-weight-medium"
Expand Down Expand Up @@ -198,6 +238,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
38 changes: 37 additions & 1 deletion dashboard/src/components/shared/ExtensionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ const authorDisplay = computed(() => {
return '';
});

// 从 repo URL 提取作者主页链接
const authorHomepageUrl = computed(() => {
const repoUrl = props.extension?.repo;
if (!repoUrl) return null;

try {
// 解析 GitHub URL,提取 owner
const url = new URL(repoUrl);
if (url.hostname.toLowerCase() !== 'github.com') return null;

const pathParts = url.pathname.split('/').filter(p => p);
if (pathParts.length < 1) return null;

const owner = pathParts[0];
return `https://github.com/${owner}`;
} catch {
return null;
}
});

const logoLoadFailed = ref(false);

const logoSrc = computed(() => {
Expand Down Expand Up @@ -356,7 +376,19 @@ const viewChangelog = () => {
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<PluginPlatformChip :platforms="supportPlatforms" />
<v-chip v-if="authorDisplay" color="info" label size="small">
<a
v-if="authorDisplay && authorHomepageUrl"
:href="authorHomepageUrl"
target="_blank"
@click.stop
style="text-decoration: none"
>
<v-chip color="info" label size="small" style="cursor: pointer">
<v-icon icon="mdi-account" start></v-icon>
{{ authorDisplay }}
</v-chip>
</a>
<v-chip v-else-if="authorDisplay" color="info" label size="small">
<v-icon icon="mdi-account" start></v-icon>
{{ authorDisplay }}
</v-chip>
Expand Down Expand Up @@ -462,6 +494,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
39 changes: 38 additions & 1 deletion dashboard/src/views/extension/InstalledPluginsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,25 @@ const handlePinnedImgError = (e) => {
e.target.src = defaultPluginIcon;
};

// 从 repo URL 提取作者主页链接
const getAuthorHomepageUrl = (repoUrl) => {
if (!repoUrl) return null;

try {
// 解析 GitHub URL,提取 owner
const url = new URL(repoUrl);
if (url.hostname.toLowerCase() !== 'github.com') return null;

const pathParts = url.pathname.split('/').filter(p => p);
if (pathParts.length < 1) return null;

const owner = pathParts[0];
return `https://github.com/${owner}`;
} catch {
return null;
}
};

// --- 拖拽功能实现 ---
const draggedIndex = ref(-1);
let lastSwapTime = 0;
Expand Down Expand Up @@ -603,7 +622,17 @@ const pinnedPlugins = computed(() => {
</template>

<template v-slot:item.author="{ item }">
<div class="text-body-2">{{ item.author }}</div>
<a
v-if="getAuthorHomepageUrl(item.repo)"
:href="getAuthorHomepageUrl(item.repo)"
target="_blank"
@click.stop
class="text-body-2"
style="text-decoration: none; color: rgb(var(--v-theme-primary))"
>
{{ item.author }}
</a>
<div v-else class="text-body-2">{{ item.author }}</div>
</template>

<template v-slot:item.actions="{ item }">
Expand Down Expand Up @@ -705,6 +734,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