Skip to content

Fix/plugin readme local assets#8360

Open
clown145 wants to merge 4 commits into
AstrBotDevs:masterfrom
clown145:fix/plugin-readme-local-assets
Open

Fix/plugin readme local assets#8360
clown145 wants to merge 4 commits into
AstrBotDevs:masterfrom
clown145:fix/plugin-readme-local-assets

Conversation

@clown145

@clown145 clown145 commented May 26, 2026

Copy link
Copy Markdown
Contributor

插件 README 中使用相对路径引用的图片,之前在 WebUI 中不一定能正常加载。对于把截图或说明文档资源放在插件仓库、插件目录内的插件,这会导致文档展示不完整。

此改动增加了一种更稳妥、也更照顾低上传带宽服务器的 README 图片加载方式:

  • 默认从本地插件目录加载相对路径图片。
  • 用户可以在设置中切换为从插件的 GitHub 仓库加载 README 图片。
  • 如果用户配置了 GitHub 加速代理,GitHub README 图片链接也会复用所选代理。

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。
  • 新增 dashboard API 路由,用于从本地插件目录提供 README 图片资源,并加入路径穿越防护和图片 MIME 类型校验。
  • 在插件 README 元数据中新增 github_raw_base,前端可基于 GitHub 默认分支构造 raw 图片链接,避免硬编码 main 或其他分支名。
  • 更新 README 渲染逻辑,根据用户设置将相对路径图片重写为本地资源链接或 GitHub raw 链接。
  • 在设置页新增独立的 README 图片来源设置,不再混入 GitHub 代理选择组件。
  • 为新增设置补充英文、中文、俄文 i18n 文案。
  • 增加测试覆盖本地 README 图片资源加载、路径校验、MIME 校验以及 GitHub raw base 生成逻辑。

Screenshots or Test Results / 运行截图或测试结果

Screenshot_2026-05-26-23-18-53-47_a252b927494330cdc2c8ba3b3f952e5e Screenshot_2026-05-26-23-17-07-88_a252b927494330cdc2c8ba3b3f952e5e

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Improve plugin README image handling by serving local assets via a new API and allowing configurable GitHub-based loading with proxy support.

New Features:

  • Add a backend API endpoint to serve plugin README image assets directly from the local plugin directory with path traversal protection and image-only MIME checks.
  • Expose a GitHub raw base URL for plugins based on their repository metadata so frontends can construct README image links against the default branch without hardcoding branch names.
  • Introduce a dashboard setting to choose whether README images are loaded from local plugin assets or from the plugin's GitHub repository, integrated with existing GitHub proxy selection.

Enhancements:

  • Update README rendering on the dashboard to rewrite relative image paths to either local asset URLs or GitHub raw URLs according to the user-selected image source.
  • Refactor plugin README loading to use shared helpers for locating plugin roots and reading files asynchronously, and to include the new GitHub raw base in the API response.
  • Allow unauthenticated README image access by accepting a token query parameter in the plugin asset endpoint while still enforcing existing auth checks.

Tests:

  • Add tests covering GitHub raw base generation from plugin repo URLs, successful serving of image assets from plugin roots, and rejection of non-image or path-traversing asset requests.

@auto-assign auto-assign Bot requested review from LIghtJUNction and anka-afk May 26, 2026 15:28
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. labels May 26, 2026

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've found 3 issues, and left some high level feedback:

  • For the /api/plugin/asset endpoint, consider avoiding passing the JWT via a token query parameter (used in ReadmeDialog.vue) and instead reuse the existing auth mechanism (e.g., cookie or Authorization header), since tokens in URLs are more likely to be logged or leaked via referrers and browser history.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- For the `/api/plugin/asset` endpoint, consider avoiding passing the JWT via a `token` query parameter (used in `ReadmeDialog.vue`) and instead reuse the existing auth mechanism (e.g., cookie or `Authorization` header), since tokens in URLs are more likely to be logged or leaked via referrers and browser history.

## Individual Comments

### Comment 1
<location path="dashboard/src/components/shared/ReadmeDialog.vue" line_range="259-260" />
<code_context>
+    name: props.pluginName,
+    path: decodedPath,
+  });
+  const token = localStorage.getItem("token");
+  if (token) params.set("token", token);
+  return `/api/plugin/asset?${params.toString()}`;
+}
</code_context>
<issue_to_address>
**🚨 issue (security):** Avoid putting the auth token into image URLs if possible

Putting the dashboard JWT in the `<img>` `src` makes it more likely to leak via browser caches, server logs, referrers, and copy‑pasted URLs. If possible, have `/api/plugin/asset` rely on existing cookie/session auth (headers instead of query params), or use a short‑lived asset-specific token rather than the main dashboard token in the URL.
</issue_to_address>

### Comment 2
<location path="astrbot/dashboard/server.py" line_range="267-268" />
<code_context>
             return None
         is_plugin_page_path = PluginPageAuth.is_protected_path(request.path)
         token = self._extract_dashboard_jwt()
+        if not token and request.path == "/api/plugin/asset":
+            token = request.args.get("token", "").strip()
         if not token and is_plugin_page_path:
             token = PluginPageAuth.extract_asset_token()
</code_context>
<issue_to_address>
**🚨 issue (security):** Using JWT from query string for `/api/plugin/asset` raises security and logging concerns

Passing the dashboard JWT as a `token` query param exposes it to logs, browser history, and referrers, increasing the risk of leakage or accidental sharing. Since this endpoint should be protected like the rest of the dashboard, prefer the existing auth mechanism (cookies/headers) or a dedicated short-lived/scope-limited token instead of reusing the main JWT in the URL.
</issue_to_address>

### Comment 3
<location path="dashboard/src/components/shared/PluginReadmeImageSourceSetting.vue" line_range="8" />
<code_context>
+            class="readme-image-source-switch"
+            color="primary"
+            density="compact"
+            hide-details="true"
+            :label="tm('network.proxySelector.readmeImages.useGitHub')">
+        </v-switch>
</code_context>
<issue_to_address>
**nitpick:** Use a boolean binding for `hide-details` instead of a string literal

`hide-details` is a boolean prop (or can be the special value `"auto"`). Using `hide-details="true"` passes a string, not a boolean. Prefer `:hide-details="true"` or just `hide-details` to keep the typing and behavior correct and explicit.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +259 to +260
const token = localStorage.getItem("token");
if (token) params.set("token", token);

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 (security): Avoid putting the auth token into image URLs if possible

Putting the dashboard JWT in the <img> src makes it more likely to leak via browser caches, server logs, referrers, and copy‑pasted URLs. If possible, have /api/plugin/asset rely on existing cookie/session auth (headers instead of query params), or use a short‑lived asset-specific token rather than the main dashboard token in the URL.

Comment on lines +267 to +268
if not token and request.path == "/api/plugin/asset":
token = request.args.get("token", "").strip()

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 (security): Using JWT from query string for /api/plugin/asset raises security and logging concerns

Passing the dashboard JWT as a token query param exposes it to logs, browser history, and referrers, increasing the risk of leakage or accidental sharing. Since this endpoint should be protected like the rest of the dashboard, prefer the existing auth mechanism (cookies/headers) or a dedicated short-lived/scope-limited token instead of reusing the main JWT in the URL.

class="readme-image-source-switch"
color="primary"
density="compact"
hide-details="true"

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.

nitpick: Use a boolean binding for hide-details instead of a string literal

hide-details is a boolean prop (or can be the special value "auto"). Using hide-details="true" passes a string, not a boolean. Prefer :hide-details="true" or just hide-details to keep the typing and behavior correct and explicit.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces the ability to load plugin README images either locally or from GitHub, including a toggle setting in the dashboard and backend routing to safely serve local assets. The review feedback highlights opportunities to improve security and code quality: specifically, adding stricter validation to GitHub repository URL parsing to prevent potential directory traversal, setting a Content-Security-Policy header on served assets to mitigate XSS risks from SVGs, and refactoring the new Vue component to use the modern <script setup> syntax and Composition API.

Comment on lines +217 to +240
def _parse_github_repo_url(self, repo_url: str | None) -> tuple[str, str] | None:
if not repo_url:
return None

parsed = urlsplit(repo_url.strip())
if parsed.scheme not in ("http", "https"):
return None
if parsed.netloc.lower() != "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 not GITHUB_REPO_PART_PATTERN.fullmatch(owner):
return None
if not GITHUB_REPO_PART_PATTERN.fullmatch(repo):
return None

return owner, repo

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.

security-medium medium

为了提高代码的健壮性与安全性,建议在此处进行以下改进:

  1. 增加对 repo_url 的类型检查,确保其为 str 类型,避免在传入非字符串类型时调用 .strip() 导致 AttributeError
  2. 显式地将 ... 排除在合法的 ownerrepo 之外,防止潜在的目录穿越或不合规的 URL 解析问题。
    def _parse_github_repo_url(self, repo_url: str | None) -> tuple[str, str] | None:
        if not isinstance(repo_url, str):
            return None

        parsed = urlsplit(repo_url.strip())
        if parsed.scheme not in ("http", "https"):
            return None
        if parsed.netloc.lower() != "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

Comment on lines +391 to +412
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.warning(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")

return await self._serve_plugin_page_static_asset(file_path)

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.

security-medium medium

为了防止潜在的跨站脚本攻击(XSS),特别是当服务中允许加载并直接在浏览器中打开 SVG 格式的图片时,建议为该接口的响应头部添加严格的 Content-Security-Policy

通过设置 Content-Security-Policy: default-src 'none',可以确保即使恶意的 SVG 图片中包含 <script> 标签,在浏览器中被直接打开时也无法执行任何脚本。

    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.warning(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)
        response.headers["Content-Security-Policy"] = "default-src 'none'"
        return response

Comment on lines +17 to +61
<script>
import { useModuleI18n } from '@/i18n/composables';
import {
PLUGIN_README_IMAGE_SOURCE,
getPluginReadmeImageSource,
setPluginReadmeImageSource
} from '@/utils/githubProxy';

export default {
setup() {
const { tm } = useModuleI18n('features/settings');
return { tm };
},
data() {
return {
readmeImageSource: PLUGIN_README_IMAGE_SOURCE.LOCAL,
initializing: true,
}
},
computed: {
readmeImageUseGitHub: {
get() {
return this.readmeImageSource === PLUGIN_README_IMAGE_SOURCE.GITHUB;
},
set(value) {
this.readmeImageSource = value
? PLUGIN_README_IMAGE_SOURCE.GITHUB
: PLUGIN_README_IMAGE_SOURCE.LOCAL;
}
}
},
mounted() {
this.readmeImageSource = getPluginReadmeImageSource();
this.initializing = false;
},
watch: {
readmeImageSource: function (newVal) {
if (this.initializing) {
return;
}
setPluginReadmeImageSource(newVal);
}
}
}
</script>

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

为了与项目中的其他组件(如 ReadmeDialog.vueSettings.vue)保持一致,建议将此组件重构为 Vue 3 推荐的 <script setup> 语法和 Composition API。

这不仅能使代码更加简洁、易读,还能利用 watch 的默认行为(在初始化时不触发),从而优雅地省去 initializing 状态和 mounted 生命周期的手动控制逻辑。

<script setup>
import { ref, watch, computed } 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>

@Soulter Soulter force-pushed the master branch 3 times, most recently from a4c4a7d to 9bd38ca Compare May 28, 2026 16:55

@Sjshi763 Sjshi763 left a comment

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.

目前看来不是硬编码的分支了。终于LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants