feat: supports to download plugins via astrbot official plugin storage#7930
feat: supports to download plugins via astrbot official plugin storage#7930
Conversation
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
dashboard/routes/plugin.py,download_url = str(post_data.get("download_url") or "").trim()will fail because Python strings don’t havetrim(); this should be changed to.strip(). - The new exception and log messages in
astrbot/core/star/updator.pywere switched from Chinese to English; consider aligning language/style with the rest of the project’s logging and error messages for consistency. annotateMarketCompatibilitymutates plugin objects by attachingastrbot_compat_*fields; if these are view-only concerns, consider storing this compatibility data separately (e.g., a side map keyed by plugin ID) to avoid mixing view state into the core plugin model.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `dashboard/routes/plugin.py`, `download_url = str(post_data.get("download_url") or "").trim()` will fail because Python strings don’t have `trim()`; this should be changed to `.strip()`.
- The new exception and log messages in `astrbot/core/star/updator.py` were switched from Chinese to English; consider aligning language/style with the rest of the project’s logging and error messages for consistency.
- `annotateMarketCompatibility` mutates plugin objects by attaching `astrbot_compat_*` fields; if these are view-only concerns, consider storing this compatibility data separately (e.g., a side map keyed by plugin ID) to avoid mixing view state into the core plugin model.
## Individual Comments
### Comment 1
<location path="astrbot/dashboard/routes/plugin.py" line_range="507-510" />
<code_context>
post_data = await request.get_json()
repo_url = post_data["url"]
+ download_url = str(post_data.get("download_url") or "").strip()
ignore_version_check = bool(post_data.get("ignore_version_check", False))
</code_context>
<issue_to_address>
**🚨 issue (security):** Validate and restrict `download_url` before passing it to the installer to avoid arbitrary external downloads.
`download_url` is currently taken directly from the request and passed through to `install_plugin` / `_download_file`, which enables arbitrary URL downloads and potential SSRF.
Please validate and normalize `download_url` before use, for example by:
- Requiring HTTPS
- Restricting to an allowlist of trusted domains (and possibly paths)
- Optionally rejecting/stripping query parameters if unnecessary
If validation fails, consider rejecting the request or falling back to the repo-based download path.
</issue_to_address>
### Comment 2
<location path="astrbot/core/star/updator.py" line_range="21-27" />
<code_context>
return self.plugin_store_path
- async def install(self, repo_url: str, proxy="") -> str:
+ async def install(self, repo_url: str, proxy="", download_url: str = "") -> str:
_, repo_name, _ = self.parse_github_url(repo_url)
repo_name = self.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
- await self.download_from_repo_url(plugin_path, repo_url, proxy)
+ if download_url:
+ logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
+ await self._download_file(download_url, plugin_path + ".zip")
+ else:
+ await self.download_from_repo_url(plugin_path, repo_url, proxy)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider reusing proxy/validation logic for `download_url` or documenting that proxy is intentionally bypassed.
When `download_url` is set, `install` bypasses `download_from_repo_url` and calls `_download_file` directly, so the `proxy` argument is never used. In proxy-dependent environments this may break downloads if `download_url` still points to GitHub (or similar). Either make the proxy bypass explicit and documented (e.g., pass `proxy=None` and restrict `download_url` to safe hosts), or thread the proxy through `_download_file` so behavior matches `download_from_repo_url`.
Suggested implementation:
```python
async def install(self, repo_url: str, proxy: str = "", download_url: str = "") -> str:
"""
Install a plugin from a GitHub repository URL.
If ``download_url`` is provided, it will be used as the direct archive source,
and the same proxy settings used for ``download_from_repo_url`` will be
threaded through to the underlying download implementation.
"""
_, repo_name, _ = self.parse_github_url(repo_url)
repo_name = self.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
if download_url:
logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
# Keep proxy behavior consistent with download_from_repo_url
await self._download_file(download_url, plugin_path + ".zip", proxy=proxy)
else:
await self.download_from_repo_url(plugin_path, repo_url, proxy)
self.unzip_file(plugin_path + ".zip", plugin_path)
return plugin_path
```
To fully implement proxy reuse for `download_url`, you will also need to:
1. Update the `_download_file` method signature in this file (or its base class) to accept the `proxy` argument, for example:
- From: `async def _download_file(self, url: str, destination: str) -> None:`
- To: `async def _download_file(self, url: str, destination: str, proxy: str = "") -> None:`
2. Propagate the `proxy` argument inside `_download_file` to the HTTP client you are using (e.g., `aiohttp`, `httpx`, or `requests`), so that downloads respect the proxy configuration.
3. Update all other call sites of `_download_file` (if any) to either:
- Pass the appropriate `proxy` value, or
- Explicitly pass `proxy=""` (or rely on the default) where proxying is not desired, documenting that behavior if relevant.
</issue_to_address>
### Comment 3
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="43" />
<code_context>
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
+ const marketCompatibilityCache = new Map();
const getSelectedGitHubProxy = () => {
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the new install URL handling and compatibility-checking logic into separate composables/services so `useExtensionPage` only orchestrates them instead of implementing all details inline.
You can keep all new behavior while reducing the complexity you’ve added by extracting the two new “responsibilities” (install URL logic and compatibility logic) into small composables/services and letting `useExtensionPage` orchestrate them.
### 1. Extract compatibility logic + cache into a composable
Right now `marketCompatibilityCache`, `normalizeAstrBotVersionSpec`, `checkAstrBotVersionCompatibility`, and `annotateMarketCompatibility` all live inside `useExtensionPage`. You can move them into a dedicated composable and keep the hook focused on orchestration:
```ts
// usePluginCompatibility.ts
import axios from "axios";
export const usePluginCompatibility = () => {
const marketCompatibilityCache = new Map<string, {
checked: boolean;
compatible: boolean;
message: string;
}>();
const normalizeAstrBotVersionSpec = (value: unknown) =>
String(value || "").trim();
const checkAstrBotVersionCompatibility = async (versionSpec: unknown) => {
const normalizedSpec = normalizeAstrBotVersionSpec(versionSpec);
if (!normalizedSpec) {
return { checked: false, compatible: true, message: "" };
}
if (marketCompatibilityCache.has(normalizedSpec)) {
return marketCompatibilityCache.get(normalizedSpec)!;
}
try {
const res = await axios.post("/api/plugin/check-compat", {
astrbot_version: normalizedSpec,
});
const result = {
checked: res.data.status === "ok",
compatible:
res.data.status === "ok" ? !!res.data.data?.compatible : true,
message: res.data.data?.message || "",
};
marketCompatibilityCache.set(normalizedSpec, result);
return result;
} catch (err) {
console.debug("Failed to check plugin compatibility:", err);
const result = { checked: false, compatible: true, message: "" };
marketCompatibilityCache.set(normalizedSpec, result);
return result;
}
};
const annotateMarketCompatibility = async (plugins: any[]) => {
const specs = [
...new Set(
plugins
.map((plugin) => normalizeAstrBotVersionSpec(plugin?.astrbot_version))
.filter(Boolean),
),
];
await Promise.all(specs.map((spec) => checkAstrBotVersionCompatibility(spec)));
plugins.forEach((plugin) => {
const spec = normalizeAstrBotVersionSpec(plugin?.astrbot_version);
const result = spec ? marketCompatibilityCache.get(spec) : null;
plugin.astrbot_compat_checked = !!result?.checked;
plugin.astrbot_compatible = result ? result.compatible : true;
plugin.astrbot_compat_message = result?.message || "";
});
};
return {
annotateMarketCompatibility,
};
};
```
Then `useExtensionPage` just uses it:
```ts
// inside useExtensionPage
const { annotateMarketCompatibility } = usePluginCompatibility();
// ...
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketCompatibility(pluginMarketData.value);
checkUpdate();
refreshRandomPlugins();
```
This removes the cache and the compatibility helpers from the hook while preserving all behavior.
### 2. Extract install URL/source helpers into a small helper/composable
The URL-related helpers are also self-contained and can be grouped:
```ts
// useInstallSource.ts
import { computed, Ref } from "vue";
const normalizeInstallUrl = (value: unknown) =>
String(value || "").trim().replace(/\/+$/, "");
const isGithubRepoUrl = (value: unknown) =>
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
normalizeInstallUrl(value),
);
export const useInstallSource = (
selectedInstallPlugin: Ref<any | null>,
extensionUrl: Ref<string>,
) => {
const selectedInstallDownloadUrl = computed(() => {
const plugin = selectedInstallPlugin.value;
const downloadUrl = String(plugin?.download_url || "").trim();
if (!downloadUrl) return "";
if (normalizeInstallUrl(plugin?.repo) !== normalizeInstallUrl(extensionUrl.value)) {
return "";
}
return downloadUrl;
});
const selectedInstallSourceUrl = computed(
() => selectedInstallDownloadUrl.value || String(extensionUrl.value || "").trim(),
);
const installUsesGithubSource = computed(
() => !selectedInstallDownloadUrl.value && isGithubRepoUrl(extensionUrl.value),
);
return {
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
};
};
```
Then in `useExtensionPage`:
```ts
// inside useExtensionPage
const {
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
} = useInstallSource(selectedInstallPlugin, extension_url);
const installPlugin = () => {
// ...
return axios.post("/api/plugin/install", {
url: extension_url.value,
download_url: selectedInstallDownloadUrl.value,
proxy: selectedInstallDownloadUrl.value ? "" : getSelectedGitHubProxy(),
ignore_version_check: ignoreVersionCheck,
});
};
```
This keeps the new behavior (`download_url` handling, GitHub source detection) but removes the low-level URL helpers and computed definitions from the already-large hook.
These two extractions should significantly reduce the cognitive load in `useExtensionPage` without changing any functionality.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| download_url = str(post_data.get("download_url") or "").strip() | ||
| ignore_version_check = bool(post_data.get("ignore_version_check", False)) | ||
|
|
||
| proxy: str = post_data.get("proxy", None) |
There was a problem hiding this comment.
🚨 issue (security): Validate and restrict download_url before passing it to the installer to avoid arbitrary external downloads.
download_url is currently taken directly from the request and passed through to install_plugin / _download_file, which enables arbitrary URL downloads and potential SSRF.
Please validate and normalize download_url before use, for example by:
- Requiring HTTPS
- Restricting to an allowlist of trusted domains (and possibly paths)
- Optionally rejecting/stripping query parameters if unnecessary
If validation fails, consider rejecting the request or falling back to the repo-based download path.
| async def install(self, repo_url: str, proxy="", download_url: str = "") -> str: | ||
| _, repo_name, _ = self.parse_github_url(repo_url) | ||
| repo_name = self.format_name(repo_name) | ||
| plugin_path = os.path.join(self.plugin_store_path, repo_name) | ||
| await self.download_from_repo_url(plugin_path, repo_url, proxy) | ||
| if download_url: | ||
| logger.info(f"Downloading plugin archive for {repo_name}: {download_url}") | ||
| await self._download_file(download_url, plugin_path + ".zip") |
There was a problem hiding this comment.
suggestion (bug_risk): Consider reusing proxy/validation logic for download_url or documenting that proxy is intentionally bypassed.
When download_url is set, install bypasses download_from_repo_url and calls _download_file directly, so the proxy argument is never used. In proxy-dependent environments this may break downloads if download_url still points to GitHub (or similar). Either make the proxy bypass explicit and documented (e.g., pass proxy=None and restrict download_url to safe hosts), or thread the proxy through _download_file so behavior matches download_from_repo_url.
Suggested implementation:
async def install(self, repo_url: str, proxy: str = "", download_url: str = "") -> str:
"""
Install a plugin from a GitHub repository URL.
If ``download_url`` is provided, it will be used as the direct archive source,
and the same proxy settings used for ``download_from_repo_url`` will be
threaded through to the underlying download implementation.
"""
_, repo_name, _ = self.parse_github_url(repo_url)
repo_name = self.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
if download_url:
logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
# Keep proxy behavior consistent with download_from_repo_url
await self._download_file(download_url, plugin_path + ".zip", proxy=proxy)
else:
await self.download_from_repo_url(plugin_path, repo_url, proxy)
self.unzip_file(plugin_path + ".zip", plugin_path)
return plugin_pathTo fully implement proxy reuse for download_url, you will also need to:
- Update the
_download_filemethod signature in this file (or its base class) to accept theproxyargument, for example:- From:
async def _download_file(self, url: str, destination: str) -> None: - To:
async def _download_file(self, url: str, destination: str, proxy: str = "") -> None:
- From:
- Propagate the
proxyargument inside_download_fileto the HTTP client you are using (e.g.,aiohttp,httpx, orrequests), so that downloads respect the proxy configuration. - Update all other call sites of
_download_file(if any) to either:- Pass the appropriate
proxyvalue, or - Explicitly pass
proxy=""(or rely on the default) where proxying is not desired, documenting that behavior if relevant.
- Pass the appropriate
| const { tm } = useModuleI18n("features/extension"); | ||
| const router = useRouter(); | ||
| const route = useRoute(); | ||
| const marketCompatibilityCache = new Map(); |
There was a problem hiding this comment.
issue (complexity): Consider extracting the new install URL handling and compatibility-checking logic into separate composables/services so useExtensionPage only orchestrates them instead of implementing all details inline.
You can keep all new behavior while reducing the complexity you’ve added by extracting the two new “responsibilities” (install URL logic and compatibility logic) into small composables/services and letting useExtensionPage orchestrate them.
1. Extract compatibility logic + cache into a composable
Right now marketCompatibilityCache, normalizeAstrBotVersionSpec, checkAstrBotVersionCompatibility, and annotateMarketCompatibility all live inside useExtensionPage. You can move them into a dedicated composable and keep the hook focused on orchestration:
// usePluginCompatibility.ts
import axios from "axios";
export const usePluginCompatibility = () => {
const marketCompatibilityCache = new Map<string, {
checked: boolean;
compatible: boolean;
message: string;
}>();
const normalizeAstrBotVersionSpec = (value: unknown) =>
String(value || "").trim();
const checkAstrBotVersionCompatibility = async (versionSpec: unknown) => {
const normalizedSpec = normalizeAstrBotVersionSpec(versionSpec);
if (!normalizedSpec) {
return { checked: false, compatible: true, message: "" };
}
if (marketCompatibilityCache.has(normalizedSpec)) {
return marketCompatibilityCache.get(normalizedSpec)!;
}
try {
const res = await axios.post("/api/plugin/check-compat", {
astrbot_version: normalizedSpec,
});
const result = {
checked: res.data.status === "ok",
compatible:
res.data.status === "ok" ? !!res.data.data?.compatible : true,
message: res.data.data?.message || "",
};
marketCompatibilityCache.set(normalizedSpec, result);
return result;
} catch (err) {
console.debug("Failed to check plugin compatibility:", err);
const result = { checked: false, compatible: true, message: "" };
marketCompatibilityCache.set(normalizedSpec, result);
return result;
}
};
const annotateMarketCompatibility = async (plugins: any[]) => {
const specs = [
...new Set(
plugins
.map((plugin) => normalizeAstrBotVersionSpec(plugin?.astrbot_version))
.filter(Boolean),
),
];
await Promise.all(specs.map((spec) => checkAstrBotVersionCompatibility(spec)));
plugins.forEach((plugin) => {
const spec = normalizeAstrBotVersionSpec(plugin?.astrbot_version);
const result = spec ? marketCompatibilityCache.get(spec) : null;
plugin.astrbot_compat_checked = !!result?.checked;
plugin.astrbot_compatible = result ? result.compatible : true;
plugin.astrbot_compat_message = result?.message || "";
});
};
return {
annotateMarketCompatibility,
};
};Then useExtensionPage just uses it:
// inside useExtensionPage
const { annotateMarketCompatibility } = usePluginCompatibility();
// ...
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketCompatibility(pluginMarketData.value);
checkUpdate();
refreshRandomPlugins();This removes the cache and the compatibility helpers from the hook while preserving all behavior.
2. Extract install URL/source helpers into a small helper/composable
The URL-related helpers are also self-contained and can be grouped:
// useInstallSource.ts
import { computed, Ref } from "vue";
const normalizeInstallUrl = (value: unknown) =>
String(value || "").trim().replace(/\/+$/, "");
const isGithubRepoUrl = (value: unknown) =>
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
normalizeInstallUrl(value),
);
export const useInstallSource = (
selectedInstallPlugin: Ref<any | null>,
extensionUrl: Ref<string>,
) => {
const selectedInstallDownloadUrl = computed(() => {
const plugin = selectedInstallPlugin.value;
const downloadUrl = String(plugin?.download_url || "").trim();
if (!downloadUrl) return "";
if (normalizeInstallUrl(plugin?.repo) !== normalizeInstallUrl(extensionUrl.value)) {
return "";
}
return downloadUrl;
});
const selectedInstallSourceUrl = computed(
() => selectedInstallDownloadUrl.value || String(extensionUrl.value || "").trim(),
);
const installUsesGithubSource = computed(
() => !selectedInstallDownloadUrl.value && isGithubRepoUrl(extensionUrl.value),
);
return {
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
};
};Then in useExtensionPage:
// inside useExtensionPage
const {
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
} = useInstallSource(selectedInstallPlugin, extension_url);
const installPlugin = () => {
// ...
return axios.post("/api/plugin/install", {
url: extension_url.value,
download_url: selectedInstallDownloadUrl.value,
proxy: selectedInstallDownloadUrl.value ? "" : getSelectedGitHubProxy(),
ignore_version_check: ignoreVersionCheck,
});
};This keeps the new behavior (download_url handling, GitHub source detection) but removes the low-level URL helpers and computed definitions from the already-large hook.
These two extractions should significantly reduce the cognitive load in useExtensionPage without changing any functionality.
There was a problem hiding this comment.
Code Review
This pull request introduces the ability to install plugins via a direct download URL, bypassing standard repository downloads when provided. It also implements a compatibility check for plugins against the current AstrBot version, with corresponding UI updates to display compatibility status, supported platforms, and installation source warnings. Backend components, including the plugin manager and updator, were modified to support these features, and several log messages were translated to English. Feedback suggests simplifying the logic in star_manager.py by removing a redundant conditional check when calling the installation method.
Modifications / 改动点
Screenshots or Test Results / 运行截图或测试结果
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.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Support installing plugins from official plugin storage with explicit download URLs while surfacing compatibility and source details in the UI.
New Features:
Enhancements:
Tests: