Skip to content

feat(dashboard): add plugin WebUI entries to sidebar with MDI icon support#8569

Open
lxfight wants to merge 15 commits into
AstrBotDevs:masterfrom
lxfight:feature/plugin-webui-sidebar
Open

feat(dashboard): add plugin WebUI entries to sidebar with MDI icon support#8569
lxfight wants to merge 15 commits into
AstrBotDevs:masterfrom
lxfight:feature/plugin-webui-sidebar

Conversation

@lxfight
Copy link
Copy Markdown
Member

@lxfight lxfight commented Jun 4, 2026

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):

  • astrbot/core/star/star.py — Added icon field to StarMetadata
  • astrbot/core/star/star_manager.py — Parse icon from metadata.yaml and merge into plugin metadata
  • astrbot/dashboard/routes/plugin.py — Return icon in GET /api/plugin/get response

Frontend — Plugin sidebar integration (5 files):

  • dashboard/src/composables/usePluginSidebarItems.ts — New. Shared reactive state + computed sidebar items from plugin list. Renders plugin icons as inline SVG via MDI CDN with currentColor theming.
  • dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts — Extended menu interface with isRawTitle and iconSvg fields
  • dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue — Inject plugin WebUI group into sidebar before "More" section
  • dashboard/src/layouts/full/vertical-sidebar/NavItem.vue — Support non-i18n titles (isRawTitle) and inline SVG icon rendering
  • dashboard/src/views/extension/useExtensionPage.js — Sync plugin data to shared sidebar state on every getExtensions() call

Frontend — Plugin page UX (2 files):

  • dashboard/src/views/PluginPagePage.vue — Removed back button and title header; iframe now fills the entire content area
  • dashboard/src/layouts/full/FullLayout.vue — Apply zero-padding container for plugin page routes (same as chat page)

Bug fixes (2 files):

  • Fixed sidebar resize getting stuck on plugin pages due to iframe capturing mousemove events (disable pointer-events during drag)
  • Fixed margin: -16px hack replaced with proper position: absolute layout

Docs (2 files):

  • docs/zh/dev/star/plugin-new.md — Documented icon field
  • docs/en/dev/star/plugin-new.md — Documented icon field

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

  • Plugin icons are loaded on-demand from https://cdn.jsdelivr.net/npm/@mdi/svg@7/svg/{name}.svg and cached
  • Inline SVG rendering with fill: currentColor ensures consistent theming with built-in sidebar icons
  • Plugin install/uninstall/activate/deactivate instantly update the sidebar via shared reactive state (no polling)
  • This is NOT a breaking change. / 这不是一个破坏性变更。

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.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.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:

  • Expose plugin icon metadata from backend to frontend and use it to render plugin WebUI entries in a new collapsible "Plugin Pages" sidebar group with MDI SVG icons.
  • Automatically build and update sidebar items for active plugins with WebUI pages, keeping them in sync with plugin management state.

Bug Fixes:

  • Prevent sidebar resize from getting stuck on plugin pages by disabling plugin iframe pointer events during drag.

Enhancements:

  • Make plugin WebUI pages render as full-screen iframe content without header card chrome, sharing the same zero-padding layout behavior as chat routes.
  • Support raw (non-i18n) titles and inline SVG icons in sidebar navigation items for plugin entries.
  • Refactor sidebar item grouping logic to use a shared constant for the "More" group key and to inject plugin groups ahead of the More section.

Documentation:

  • Document the optional icon field in plugin metadata for configuring sidebar MDI icons in both English and Chinese developer guides.

lxfight added 13 commits June 3, 2026 22:45
…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
@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 Jun 4, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • 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.
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>

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 thread dashboard/src/layouts/full/vertical-sidebar/NavItem.vue
Comment thread dashboard/src/composables/usePluginSidebarItems.ts
Copy link
Copy Markdown
Contributor

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

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 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.

Comment thread dashboard/src/composables/usePluginSidebarItems.ts
Comment thread dashboard/src/composables/usePluginSidebarItems.ts
Comment thread dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue
lxfight added 2 commits June 4, 2026 11:47
- 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
@lxfight
Copy link
Copy Markdown
Member Author

lxfight commented Jun 4, 2026

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • 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.
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>

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 thread dashboard/src/layouts/full/vertical-sidebar/NavItem.vue
Comment thread dashboard/src/composables/usePluginSidebarItems.ts
@lxfight
Copy link
Copy Markdown
Member Author

lxfight commented Jun 4, 2026

合并该PR之前建议先处理 #8390 ,这样插件WebUI可以与AstrBot主题有更强的一致性

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.

1 participant