feat(dashboard): add plugin WebUI entries to sidebar with MDI icon support#8569
feat(dashboard): add plugin WebUI entries to sidebar with MDI icon support#8569lxfight wants to merge 15 commits into
Conversation
…bar resize - Use absolute positioning instead of negative margin for full-screen layout - Zero container padding for plugin page route in FullLayout - Disable pointer-events on iframe during sidebar drag to avoid event capture
- Replace polling + events with module-level reactive shared state - useExtensionPage.getExtensions() populates pluginSidebarState - usePluginSidebarItems uses computed() to derive sidebar menu - Zero additional API calls, updates instantly on any plugin change
…font - Plugin icons loaded from https://cdn.jsdelivr.net/npm/@mdi/svg@7/svg/ - Removes subset limitation - plugins can use any MDI icon - Fallback to subset font class for built-in sidebar items - Default icon remains mdi-puzzle when plugin doesn't specify one
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- Inline SVGs for plugin icons are injected via
v-htmlfrom an external CDN; consider adding basic SVG sanitization or tighter CSP controls to reduce the XSS risk from compromised or unexpected SVG content. - The sidebar menu insertion logic relies on the string literal
'core.navigation.groups.more'to find the insertion point; consider centralizing this key (e.g., as a shared constant or enum) to avoid future breakage if the i18n key changes.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Inline SVGs for plugin icons are injected via `v-html` from an external CDN; consider adding basic SVG sanitization or tighter CSP controls to reduce the XSS risk from compromised or unexpected SVG content.
- The sidebar menu insertion logic relies on the string literal `'core.navigation.groups.more'` to find the insertion point; consider centralizing this key (e.g., as a shared constant or enum) to avoid future breakage if the i18n key changes.
## Individual Comments
### Comment 1
<location path="dashboard/src/layouts/full/vertical-sidebar/NavItem.vue" line_range="56" />
<code_context>
:target="item.type === 'external' ? '_blank' : ''" :style="itemStyle">
<template v-slot:prepend>
- <v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
+ <span v-if="item.iconSvg" class="plugin-icon-svg" v-html="item.iconSvg"></span>
+ <v-icon v-else-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
</code_context>
<issue_to_address>
**🚨 issue (security):** Using `v-html` for SVG icons relies on the SVG source always being trusted; consider guarding or constraining the source more explicitly.
Because `v-html` disables Vue’s escaping, `iconSvg` must not be allowed to contain arbitrary markup. Even though it currently comes from the MDI CDN/cache, please either constrain it so it can only be set via that trusted path, or add a simple sanitizer (e.g., require it to start with `<svg` and reject `<script>`/inline event handlers) before assigning it.
</issue_to_address>
### Comment 2
<location path="dashboard/src/composables/usePluginSidebarItems.ts" line_range="44" />
<code_context>
+ }
+}
+
+function buildPluginItems(plugins: PluginEntry[]): (menu & { iconSvg?: string }) | null {
+ const activeWithPages = plugins.filter(
+ (p) => p.activated && Array.isArray(p.pages) && p.pages.length > 0,
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying this composable by keeping menu construction pure and decorating icons separately to avoid unnecessary rebuilds and tighter coupling to the SVG cache.
You can simplify this composable noticeably by separating menu building from icon decoration and avoiding repeated tree rebuilds.
### 1. Keep `buildPluginItems` pure (no `svgCache`)
Right now `buildPluginItems` reads from `svgCache`, which couples it to icon-loading order. You can make it a pure mapper and keep icon enrichment in one place:
```ts
function buildPluginItems(plugins: PluginEntry[]): menu | null {
const activeWithPages = plugins.filter(
(p) => p.activated && Array.isArray(p.pages) && p.pages.length > 0,
);
if (activeWithPages.length === 0) return null;
const children: menu[] = activeWithPages.map((p) => {
const displayName = p.display_name || p.name || "Unknown Plugin";
const firstPage = p.pages[0];
const icon = p.icon || DEFAULT_ICON;
return {
title: displayName,
icon,
// iconSvg is added later
to: `/plugin-page/${encodeURIComponent(p.name)}/${encodeURIComponent(firstPage)}`,
isRawTitle: true,
};
});
return {
title: GROUP_I18N_KEY,
icon: GROUP_ICON,
children,
};
}
```
### 2. Let `loadSvgIcon` fully own the cache
You already cache inside `loadSvgIcon`. Callers don’t need to check `svgCache`:
```ts
async function loadSvgIcon(iconName: string): Promise<string | null> {
if (iconName in svgCache) return svgCache[iconName];
const name = iconName.startsWith("mdi-") ? iconName.slice(4) : iconName;
try {
const res = await fetch(`${MDI_SVG_BASE}/${name}.svg`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const svgText = await res.text();
svgCache[iconName] = svgText;
return svgText;
} catch {
svgCache[iconName] = null;
return null;
}
}
```
### 3. Build once, decorate in place (no repeated rebuilds)
Instead of rebuilding `pluginItems` for each icon, build once and then mutate `iconSvg` on the existing tree. This keeps the menu structure stable and reduces work:
```ts
export function usePluginSidebarItems() {
const pluginItems = shallowRef<(menu & { iconSvg?: string }) | null>(null);
async function refreshItems() {
const items = buildPluginItems(pluginSidebarState.plugins);
pluginItems.value = items;
if (!items?.children) return;
// Load icons in the background and decorate in place
await Promise.all(
items.children.map(async (child) => {
if (!child.icon) return;
const svg = await loadSvgIcon(child.icon);
// mutate existing tree to trigger reactive updates
// (works because `pluginItems` is a ref)
(child as any).iconSvg = svg ?? undefined;
}),
);
}
onMounted(async () => {
await initPluginState();
refreshItems();
});
watch(
() => pluginSidebarState.plugins,
() => {
refreshItems();
},
);
return { pluginItems };
}
```
This preserves all behavior:
- `pluginSidebarState` remains the source of plugin data.
- Icons are still lazy-loaded and cached.
- The sidebar menu is built once per update; icon loading only decorates `iconSvg` on existing items, reducing coupling and complexity.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Code Review
This pull request introduces support for custom plugin icons (MDI icons) in the dashboard sidebar. It updates the plugin metadata structure, backend loaders, and the frontend sidebar layout to dynamically fetch, cache, and render SVG icons from an external CDN. Feedback on the changes highlights a critical security concern regarding potential XSS and path traversal when rendering unsanitized SVGs via v-html, as well as performance optimization opportunities in parallelizing SVG network requests and streamlining the sidebar menu construction logic.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
- Add SVG sanitization to prevent XSS via v-html (reject <script>, event handlers) - Extract MORE_GROUP_KEY shared constant to avoid hardcoded i18n key - Parallel SVG loading with Promise.all instead of serial - Pure buildPluginItems, mutate iconSvg in place to avoid redundant rebuilds - Fallback to default icon SVG when loading fails - Revert accidental pnpm-lock.yaml changes
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The SVG sanitization in
usePluginSidebarItems.tsonly checks for<script>and inlineon*handlers; since the content is injected viav-html, consider hardening this by stripping disallowed elements/attributes (e.g.,foreignObject, externalhref/xlink:href,iframe) or using a more robust sanitizer to reduce XSS risk. - In
usePluginSidebarItems.tsyou’re mixingaxios(for/api/plugin/get) with the nativefetchfor icon loading; for consistency and centralized error handling, consider standardizing on one HTTP client or at least wrappingfetchin a shared utility.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The SVG sanitization in `usePluginSidebarItems.ts` only checks for `<script>` and inline `on*` handlers; since the content is injected via `v-html`, consider hardening this by stripping disallowed elements/attributes (e.g., `foreignObject`, external `href/xlink:href`, `iframe`) or using a more robust sanitizer to reduce XSS risk.
- In `usePluginSidebarItems.ts` you’re mixing `axios` (for `/api/plugin/get`) with the native `fetch` for icon loading; for consistency and centralized error handling, consider standardizing on one HTTP client or at least wrapping `fetch` in a shared utility.
## Individual Comments
### Comment 1
<location path="dashboard/src/layouts/full/vertical-sidebar/NavItem.vue" line_range="56" />
<code_context>
:target="item.type === 'external' ? '_blank' : ''" :style="itemStyle">
<template v-slot:prepend>
- <v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
+ <span v-if="item.iconSvg" class="plugin-icon-svg" v-html="item.iconSvg"></span>
+ <v-icon v-else-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
</code_context>
<issue_to_address>
**🚨 issue (security):** Re-evaluate SVG sanitization used with v-html to ensure it’s robust enough for untrusted plugin data.
Because `item.iconSvg` is rendered with `v-html`, any gap in `sanitizeSvg` could allow XSS via plugin-controlled SVG. Current checks (removing `<script>` and `on*` attributes) won’t catch vectors like `xlink:href="javascript:…"`, `data:` URLs, or elements such as `<foreignObject>`. If plugin-provided icon names/data are untrusted, either strictly constrain them (e.g., only allow known MDI icon IDs via a whitelist and `[a-z0-9-]+` with a `mdi-` prefix) or switch to a more robust SVG sanitizer that covers these cases.
</issue_to_address>
### Comment 2
<location path="dashboard/src/composables/usePluginSidebarItems.ts" line_range="19" />
<code_context>
+}
+
+/** 模块级共享状态,由 useExtensionPage.getExtensions() 更新 */
+export const pluginSidebarState = reactive<{
+ plugins: PluginEntry[];
+}>({
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring this composable to separate state management, data fetching, and SVG/icon concerns into dedicated modules with clearer types and APIs to simplify the data flow and responsibilities.
You can reduce the complexity meaningfully without losing any functionality by tightening the data flow and splitting responsibilities a bit. Here are concrete, incremental changes:
---
### 1. Avoid exporting a mutable global reactive object
Instead of exporting `pluginSidebarState` directly, wrap it in a composable with a controlled API. This keeps the global behavior but removes “any module can mutate anything” and makes dependencies clearer.
```ts
// pluginSidebarState.ts
import { reactive, readonly } from "vue";
interface PluginSidebarState {
plugins: PluginEntry[];
}
const state = reactive<PluginSidebarState>({
plugins: [],
});
export function usePluginSidebarState() {
function setPlugins(plugins: PluginEntry[]) {
state.plugins = plugins;
}
return {
plugins: readonly(state).plugins,
setPlugins,
};
}
```
Then in this file:
```ts
// instead of exported reactive
// export const pluginSidebarState = reactive<{ plugins: PluginEntry[] }>({ plugins: [] });
import { usePluginSidebarState } from "./pluginSidebarState";
export function usePluginSidebarItems() {
const { plugins, setPlugins } = usePluginSidebarState();
// use `plugins` in watch/buildPluginItems
}
```
This keeps the shared state but makes updates explicit through `setPlugins`.
---
### 2. Centralize plugin fetching and make this composable “pure transform”
Move `/api/plugin/get` out of this file, so `usePluginSidebarItems` only transforms existing plugin data into sidebar items.
Create/extend a “plugin data” composable:
```ts
// usePlugins.ts
import { ref, onMounted } from "vue";
import axios from "axios";
export function usePlugins() {
const plugins = ref<PluginEntry[]>([]);
async function fetchPlugins() {
const res = await axios.get("/api/plugin/get");
if (res.data?.status === "ok") {
plugins.value = res.data.data ?? [];
}
}
onMounted(fetchPlugins);
return { plugins, fetchPlugins };
}
```
Then make `usePluginSidebarItems` accept the plugin source instead of fetching itself:
```ts
// usePluginSidebarItems.ts
export function usePluginSidebarItems(plugins: Ref<PluginEntry[]>) {
const pluginItems = shallowRef<PluginMenuGroup | null>(null);
async function refreshItems() {
const items = buildPluginItems(plugins.value);
pluginItems.value = items;
// icon loading...
}
watch(plugins, refreshItems, { immediate: true });
return { pluginItems };
}
```
Caller:
```ts
const { plugins } = usePlugins();
const { pluginItems } = usePluginSidebarItems(plugins);
```
This removes the dual responsibility of fetching here and elsewhere.
---
### 3. Extract SVG loading & caching into a dedicated helper
Move `sanitizeSvg`, `svgCache`, and `loadSvgIcon` into a small, focused module. This keeps `usePluginSidebarItems` focused on menu building.
```ts
// svgIconService.ts
const MDI_SVG_BASE = "https://cdn.jsdelivr.net/npm/@mdi/svg@7/svg";
const svgCache = new Map<string, string | null>();
function sanitizeSvg(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed.startsWith("<svg")) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("<script")) return null;
if (/\bon\w+\s*=/.test(lower)) return null;
return trimmed;
}
export async function loadSvgIcon(iconName: string): Promise<string | null> {
if (svgCache.has(iconName)) return svgCache.get(iconName)!;
const name = iconName.startsWith("mdi-") ? iconName.slice(4) : iconName;
try {
const res = await fetch(`${MDI_SVG_BASE}/${name}.svg`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.text();
const sanitized = sanitizeSvg(raw);
svgCache.set(iconName, sanitized);
return sanitized;
} catch {
svgCache.set(iconName, null);
return null;
}
}
```
Then in `usePluginSidebarItems`:
```ts
import { loadSvgIcon } from "./svgIconService";
```
Now the composable doesn’t own networking and sanitization details.
---
### 4. Make `iconSvg` part of the type to avoid async mutation via `any`
Define a specific menu type that includes `iconSvg` and use it consistently. That removes the need for `(child as any).iconSvg` and makes the “two-phase” population explicit and type-safe.
```ts
interface PluginMenuItem extends menu {
iconSvg?: string;
}
interface PluginMenuGroup extends menu {
children: PluginMenuItem[];
}
function buildPluginItems(plugins: PluginEntry[]): PluginMenuGroup | null {
const activeWithPages = plugins.filter(
(p) => p.activated && Array.isArray(p.pages) && p.pages.length > 0,
);
if (activeWithPages.length === 0) return null;
const children: PluginMenuItem[] = activeWithPages.map((p) => ({
title: p.display_name || p.name || "Unknown Plugin",
icon: p.icon || DEFAULT_ICON,
to: `/plugin-page/${encodeURIComponent(p.name)}/${encodeURIComponent(p.pages[0])}`,
isRawTitle: true,
iconSvg: undefined,
}));
return {
title: GROUP_I18N_KEY,
icon: GROUP_ICON,
children,
};
}
```
And in `refreshItems`:
```ts
async function refreshItems() {
const items = buildPluginItems(plugins.value);
pluginItems.value = items;
if (!items?.children) return;
await Promise.all(
items.children.map(async (child) => {
if (!child.icon) return;
let svg = await loadSvgIcon(child.icon);
if (!svg && child.icon !== DEFAULT_ICON) {
svg = await loadSvgIcon(DEFAULT_ICON);
}
child.iconSvg = svg ?? undefined;
}),
);
}
```
The structure is now stable; only `iconSvg` is filled in asynchronously.
---
### 5. Unify HTTP client usage for consistency
Either use `axios` for SVG as well, or wrap `fetch` so the pattern looks similar. For example, using `axios`:
```ts
// in svgIconService.ts
import axios from "axios";
export async function loadSvgIcon(iconName: string): Promise<string | null> {
// ...same cache logic...
const name = iconName.startsWith("mdi-") ? iconName.slice(4) : iconName;
try {
const res = await axios.get(`${MDI_SVG_BASE}/${name}.svg`, {
responseType: "text",
});
const sanitized = sanitizeSvg(res.data);
svgCache.set(iconName, sanitized);
return sanitized;
} catch {
svgCache.set(iconName, null);
return null;
}
}
```
This removes the “axios here / fetch there” mental branch when reading the file.
---
Applying even 2–3 of these (especially #2 and #3) will noticeably reduce cognitive load and cross-module coupling while preserving all the current behavior.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
合并该PR之前建议先处理 #8390 ,这样插件WebUI可以与AstrBot主题有更强的一致性 |
Plugins with WebUI pages were only accessible from the Extensions page, requiring multiple clicks. This PR adds plugin WebUI entries directly into the sidebar under a collapsible "Plugin Pages" group,
enabling one-click access. Plugin authors can customize the sidebar icon via metadata.yaml using any MDI icon from pictogrammers.com.
Modifications / 改动点
Backend (3 files):
Frontend — Plugin sidebar integration (5 files):
Frontend — Plugin page UX (2 files):
Bug fixes (2 files):
Docs (2 files):
How it works
Plugin metadata.yaml:
icon: mdi-brain
pages:
- name: dashboard
title: 仪表盘
pages/dashboard/index.html → WebUI content
Sidebar result:
🧩 插件页面 ← collapsible group
🧠 动态记忆 ← inline SVG from CDN, themed via currentColor
Screenshots or Test Results / 运行截图或测试结果
Jietu20260604-113840-HD.mp4
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
Add plugin WebUI entries to the dashboard sidebar with configurable MDI icons and full-screen plugin page layout improvements.
New Features:
Bug Fixes:
Enhancements:
Documentation:
iconfield in plugin metadata for configuring sidebar MDI icons in both English and Chinese developer guides.