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
124 changes: 102 additions & 22 deletions astrbot/dashboard/routes/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
PLUGIN_UPDATE_CONCURRENCY = (
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_.-]+$")
Comment on lines +49 to +51

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

在 Windows 系统上,Python 的 mimetypes 模块依赖于系统注册表。如果注册表中没有注册 .svg.webp 等格式,mimetypes.guess_type 可能会返回 None,导致这些格式的图片在 Windows 上无法加载(返回 404)。

建议在模块初始化时显式注册这些常见的图片 MIME 类型,以确保跨平台兼容性。

PLUGIN_ASSET_MIME_PREFIX = "image/"
GITHUB_DEFAULT_BRANCH_REF = "HEAD"
GITHUB_REPO_PART_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")

# 显式注册常见图片类型,防止 Windows 系统注册表缺失导致 guess_type 失败
mimetypes.add_type("image/svg+xml", ".svg")
mimetypes.add_type("image/webp", ".webp")

_PLUGIN_PAGE_BRIDGE_FILE = (
Path(__file__).resolve().parent.parent / "plugin_page_bridge.js"
)
Expand Down Expand Up @@ -74,6 +77,9 @@
_PLUGIN_PAGE_ROOT_DIR_NAME = "pages"
_PLUGIN_PAGE_ENTRY_FILE_NAME = "index.html"

mimetypes.add_type("image/svg+xml", ".svg")
mimetypes.add_type("image/webp", ".webp")


def _normalize_plugin_page_asset_path(asset_path: str) -> str:
return PluginRoute._normalize_plugin_page_path(asset_path, allow_empty=True)
Expand Down Expand Up @@ -128,6 +134,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),
Expand Down Expand Up @@ -210,6 +217,45 @@ def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
return plugin
return None

def _parse_github_repo_url(self, repo_url: str | None) -> tuple[str, str] | None:
if not isinstance(repo_url, str):
return None

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() not in {"github.com", "www.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 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):
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}"

@staticmethod
def _get_by_path(source: dict | None, key: str):
if not isinstance(source, dict) or not key:
Expand Down Expand Up @@ -390,6 +436,45 @@ def _get_plugin_root_dir(self, plugin: StarMetadata) -> Path:
plugin_root.relative_to(base_dir)
return plugin_root

async def _resolve_plugin_asset_file(
self,
plugin: StarMetadata,
asset_path: str,
) -> Path:
plugin_root = self._get_plugin_root_dir(plugin)
normalized_path = self._normalize_plugin_page_path(asset_path)
target_path = (plugin_root / normalized_path).resolve(strict=False)
target_path.relative_to(plugin_root)
if not await aio_ospath.isfile(str(target_path)):
raise FileNotFoundError("Plugin asset not found")
return target_path

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 await self._plugin_page_error_response(404, "Plugin asset not found")

plugin = self._get_plugin_metadata_by_name(plugin_name)
if not plugin:
return await self._plugin_page_error_response(404, "Plugin not found")

try:
file_path = await self._resolve_plugin_asset_file(plugin, asset_path)
except (FileNotFoundError, ValueError, OSError):
logger.info(f"插件资源访问失败: {plugin_name}/{asset_path}")
return await self._plugin_page_error_response(404, "Plugin asset not found")
Comment on lines +463 to +467

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

如果插件的 README 中包含失效的图片链接(这在第三方插件中很常见),每次打开 README 都会触发 logger.warning,这可能会导致日志中充斥大量的警告信息(日志污染)。

建议将此处的日志级别降低为 logger.infologger.debug,或者仅在调试模式下输出。

Suggested change
try:
file_path = await self._resolve_plugin_asset_file(plugin, asset_path)
except (FileNotFoundError, ValueError, OSError):
logger.warning(f"插件资源访问失败: {plugin_name}/{asset_path}")
return await self._plugin_page_error_response(404, "Plugin asset not found")
try:
file_path = await self._resolve_plugin_asset_file(plugin, asset_path)
except (FileNotFoundError, ValueError, OSError):
logger.info(f"插件资源访问失败: {plugin_name}/{asset_path}")
return await self._plugin_page_error_response(404, "Plugin asset not found")


mimetype, _ = mimetypes.guess_type(file_path.name)
if not mimetype or not mimetype.startswith(PLUGIN_ASSET_MIME_PREFIX):
return await self._plugin_page_error_response(404, "Plugin asset not found")

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,
plugin: StarMetadata,
Expand Down Expand Up @@ -2001,12 +2086,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._get_plugin_metadata_by_name(plugin_name)
if not plugin_obj:
logger.warning(f"插件 {plugin_name} 不存在")
return Response().error(f"插件 {plugin_name} 不存在").__dict__
Expand All @@ -2015,34 +2095,34 @@ 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,
)
try:
plugin_dir = self._get_plugin_root_dir(plugin_obj)
except (FileNotFoundError, ValueError):
logger.warning(f"插件 {plugin_name} 目录不存在")
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__

if not os.path.isdir(plugin_dir):
if not await aio_ospath.isdir(str(plugin_dir)):
logger.warning(f"无法找到插件目录: {plugin_dir}")
return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__

readme_path = os.path.join(plugin_dir, "README.md")
readme_path = plugin_dir / "README.md"

if not os.path.isfile(readme_path):
if not await aio_ospath.isfile(str(readme_path)):
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 = await self._read_plugin_page_text(readme_path)

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:
Expand Down
70 changes: 70 additions & 0 deletions dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<template>
<div class="readme-image-source">
<v-switch
v-model="readmeImageUseGitHub"
class="readme-image-source-switch"
color="primary"
density="compact"
hide-details
:label="tm('network.proxySelector.readmeImages.useGitHub')">
</v-switch>
<div class="text-caption text-medium-emphasis mt-1">
{{ tm('network.proxySelector.readmeImages.hint') }}
</div>
</div>
</template>

<script setup>
import { computed, ref, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import {
PLUGIN_README_IMAGE_SOURCE,
getPluginReadmeImageSource,
setPluginReadmeImageSource
} from '@/utils/githubProxy';

const { tm } = useModuleI18n('features/settings');

const readmeImageSource = ref(getPluginReadmeImageSource());

const readmeImageUseGitHub = computed({
get() {
return readmeImageSource.value === PLUGIN_README_IMAGE_SOURCE.GITHUB;
},
set(value) {
readmeImageSource.value = value
? PLUGIN_README_IMAGE_SOURCE.GITHUB
: PLUGIN_README_IMAGE_SOURCE.LOCAL;
}
});

watch(readmeImageSource, (newVal) => {
setPluginReadmeImageSource(newVal);
});
</script>

<style scoped>
.readme-image-source {
max-width: 100%;
overflow: visible;
padding-left: 10px;
}

.readme-image-source-switch {
overflow: visible;
}

.readme-image-source-switch :deep(.v-selection-control) {
min-height: 32px;
overflow: visible;
}

.readme-image-source-switch :deep(.v-selection-control__wrapper) {
overflow: visible;
}

.readme-image-source-switch :deep(.v-label) {
line-height: 1.35;
white-space: normal;
}
</style>
Loading
Loading