-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Fix/plugin readme local assets #8360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
clown145
wants to merge
8
commits into
AstrBotDevs:master
Choose a base branch
from
clown145:fix/plugin-readme-local-assets
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
9383c3c
fix: load plugin readme local image assets
clown145 fe91f88
feat: support github plugin readme image source
clown145 1561251
fix: align plugin readme image source switch
clown145 88278b1
chore: merge master into plugin readme asset branch
clown145 c72650a
fix: secure plugin readme asset loading
clown145 bd33f01
chore: merge master into plugin readme asset branch
clown145 11c59d4
fix: harden plugin readme asset handling
clown145 d8b8994
fix: improve plugin readme asset compatibility
clown145 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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_.-]+$") | ||||||||||||||||||||||
| _PLUGIN_PAGE_BRIDGE_FILE = ( | ||||||||||||||||||||||
| Path(__file__).resolve().parent.parent / "plugin_page_bridge.js" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||
|
|
@@ -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), | ||||||||||||||||||||||
|
|
@@ -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: | ||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 如果插件的 README 中包含失效的图片链接(这在第三方插件中很常见),每次打开 README 都会触发 建议将此处的日志级别降低为
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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, | ||||||||||||||||||||||
|
|
@@ -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__ | ||||||||||||||||||||||
|
|
@@ -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: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
70 changes: 70 additions & 0 deletions
70
dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在 Windows 系统上,Python 的
mimetypes模块依赖于系统注册表。如果注册表中没有注册.svg或.webp等格式,mimetypes.guess_type可能会返回None,导致这些格式的图片在 Windows 上无法加载(返回 404)。建议在模块初始化时显式注册这些常见的图片 MIME 类型,以确保跨平台兼容性。