From 7efd40729b755285caaa6afffc397da219162e64 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:40:36 +0700 Subject: [PATCH] feat: custom plugin marketplace support (#3656) feat: custom plugin marketplace (GET /api/plugins/marketplace + SSRF-guarded registry). Integrated into release/v3.8.24. --- .../(dashboard)/dashboard/plugins/page.tsx | 209 ++++++++++++++---- src/app/api/plugins/marketplace/route.ts | 30 +++ src/i18n/messages/en.json | 10 + src/lib/plugins/marketplace.ts | 91 +++++++- tests/unit/plugins-marketplace.test.ts | 69 ++++++ 5 files changed, 358 insertions(+), 51 deletions(-) create mode 100644 src/app/api/plugins/marketplace/route.ts create mode 100644 tests/unit/plugins-marketplace.test.ts diff --git a/src/app/(dashboard)/dashboard/plugins/page.tsx b/src/app/(dashboard)/dashboard/plugins/page.tsx index 57f615e0c8..def30a2153 100644 --- a/src/app/(dashboard)/dashboard/plugins/page.tsx +++ b/src/app/(dashboard)/dashboard/plugins/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { Card, Button, EmptyState } from "@/shared/components"; +import { Card, Button, EmptyState, Badge } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; @@ -19,6 +19,10 @@ export default function PluginsPage() { const { addNotification } = useNotificationStore(); const t = useTranslations("plugins"); const [plugins, setPlugins] = useState([]); + const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed"); + const [marketplacePlugins, setMarketplacePlugins] = useState([]); + const [marketplaceUrl, setMarketplaceUrl] = useState(""); + const [savingUrl, setSavingUrl] = useState(false); const [loading, setLoading] = useState(true); const [scanning, setScanning] = useState(false); @@ -38,7 +42,51 @@ export default function PluginsPage() { useEffect(() => { fetchPlugins(); + fetch("/api/settings") + .then((res) => res.ok ? res.json() : null) + .then((data) => { + if (data?.pluginMarketplaceUrl) setMarketplaceUrl(data.pluginMarketplaceUrl); + }) + .catch(() => {}); }, [fetchPlugins]); + + const fetchMarketplace = useCallback(async () => { + try { + const res = await fetch("/api/plugins/marketplace"); + if (res.ok) { + const data = await res.json(); + setMarketplacePlugins(data.plugins || []); + } + } catch {} + }, []); + + useEffect(() => { + if (activeTab === "marketplace") { + fetchMarketplace(); + } + }, [activeTab, fetchMarketplace]); + + const handleSaveUrl = async () => { + setSavingUrl(true); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pluginMarketplaceUrl: marketplaceUrl || null }), + }); + if (!res.ok) { + const errData = await res.json().catch(() => null); + addNotification({ type: "error", message: errData?.error || "Failed to save" }); + return; + } + addNotification({ type: "success", message: t("marketplaceUrlSaved") }); + await fetchMarketplace(); + } catch { + addNotification({ type: "error", message: t("saveConfigurationFailed") }); + } finally { + setSavingUrl(false); + } + }; const handleScan = async () => { setScanning(true); @@ -89,56 +137,127 @@ export default function PluginsPage() {

{t("title")}

- +
+ + +
- {plugins.length === 0 ? ( - + {activeTab === "marketplace" && ( + +
+ + setMarketplaceUrl(e.target.value)} + /> +
+ +
+ )} + + {activeTab === "installed" ? ( + <> +
+ +
+ {plugins.length === 0 ? ( + + ) : ( +
+ {plugins.map((plugin) => ( + +
+
+

{plugin.name}

+

+ v{plugin.version} + {plugin.author ? ` by ${plugin.author}` : ""} + {plugin.description ? ` — ${plugin.description}` : ""} +

+
+ {plugin.hooks.map((hook) => ( + + {hook} + + ))} +
+
+
+ + +
+
+
+ ))} +
+ )} + ) : (
- {plugins.map((plugin) => ( - -
-
-

{plugin.name}

-

- v{plugin.version} - {plugin.author ? ` by ${plugin.author}` : ""} - {plugin.description ? ` — ${plugin.description}` : ""} -

-
- {plugin.hooks.map((hook) => ( - - {hook} - - ))} + {marketplacePlugins.length === 0 ? ( +
{t("marketplaceEmpty")}
+ ) : ( + marketplacePlugins.map((plugin) => ( + +
+
+

+ {plugin.name} + {plugin.verified && {t("verified")}} +

+

+ v{plugin.version} by {plugin.author} — {plugin.description} +

+
+ {plugin.tags?.map((tag: string) => ( + + {tag} + + ))} +
+
+
+
-
- - -
-
- - ))} + + )) + )}
)}
diff --git a/src/app/api/plugins/marketplace/route.ts b/src/app/api/plugins/marketplace/route.ts new file mode 100644 index 0000000000..f2f0d1e523 --- /dev/null +++ b/src/app/api/plugins/marketplace/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { listMarketplacePlugins } from "@/lib/plugins/marketplace"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins/marketplace — List marketplace plugins + */ +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const plugins = await listMarketplacePlugins(); + return NextResponse.json( + { plugins }, + { headers: CORS_HEADERS } + ); + } catch (err: unknown) { + console.error("[plugins/marketplace] Failed to list marketplace plugins:", err); + return NextResponse.json(buildErrorBody(500, "Failed to list marketplace plugins"), { + status: 500, + headers: CORS_HEADERS, + }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 94cabf491e..3e77d4eb57 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -8187,6 +8187,16 @@ "status": "Status", "enabled": "Enabled", "disabled": "Disabled", + "installedTab": "Installed", + "marketplaceTab": "Marketplace", + "marketplaceUrlLabel": "Custom Marketplace URL", + "marketplaceUrlPlaceholder": "Leave empty for official Omniroute registry", + "saveMarketplaceUrl": "Save & Reload", + "marketplaceUrlSaved": "Marketplace URL updated", + "marketplaceEmpty": "No plugins found in marketplace.", + "verified": "Verified", + "install": "Install", + "installedFromMarketplace": "Plugin {name} installed!", "hooks": "Hooks" }, "quotaPlans": { diff --git a/src/lib/plugins/marketplace.ts b/src/lib/plugins/marketplace.ts index 0ff8f71adc..0248ae9788 100644 --- a/src/lib/plugins/marketplace.ts +++ b/src/lib/plugins/marketplace.ts @@ -1,3 +1,6 @@ +import { getSettings } from "../db/settings"; +import dns from "node:dns/promises"; +import net from "node:net"; /** * Plugin Marketplace — browse, search, install plugins from a registry. * @@ -7,6 +10,51 @@ * @module plugins/marketplace */ +/** + * Validate a URL for SSRF safety: must be http/https and must not resolve + * to a private or loopback IP address. + */ +async function isSafeMarketplaceUrl(urlStr: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + return false; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + // Resolve hostname to IPs and check none are private/loopback + try { + const addresses = await dns.resolve4(parsed.hostname); + for (const ip of addresses) { + if (net.isIPv4(ip)) { + const parts = ip.split(".").map(Number); + // 127.0.0.0/8 (loopback) + if (parts[0] === 127) return false; + // 10.0.0.0/8 (private) + if (parts[0] === 10) return false; + // 172.16.0.0/12 (private) + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; + // 192.168.0.0/16 (private) + if (parts[0] === 192 && parts[1] === 168) return false; + // 0.0.0.0/8 (current network) + if (parts[0] === 0) return false; + // 100.64.0.0/10 (CGNAT) + if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return false; + // 169.254.0.0/16 (link-local) + if (parts[0] === 169 && parts[1] === 254) return false; + // 198.18.0.0/15 (benchmarking) + if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return false; + } + } + } catch { + // DNS resolution failure — reject to be safe + return false; + } + return true; +} + // Marketplace — local seed registry. Remote registry in Phase 2. // ── Types ── @@ -88,16 +136,46 @@ const SEED_REGISTRY: MarketplaceEntry[] = [ /** * List all available plugins in the marketplace. */ -export function listMarketplacePlugins(): MarketplaceEntry[] { +export async function listMarketplacePlugins(): Promise { + try { + const settings = await getSettings(); + const url = typeof settings.pluginMarketplaceUrl === "string" ? settings.pluginMarketplaceUrl : null; + if (url) { + if (!(await isSafeMarketplaceUrl(url))) { + console.warn("Custom marketplace URL rejected (SSRF guard):", url); + return [...SEED_REGISTRY]; + } + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) { + console.warn("Custom marketplace returned non-OK status:", res.status); + return [...SEED_REGISTRY]; + } + const data = await res.json(); + if (Array.isArray(data)) { + return data.filter((entry: unknown) => + entry && typeof entry === "object" && typeof (entry as Record).name === "string" + ) as MarketplaceEntry[]; + } + if (data && typeof data === "object" && Array.isArray((data as Record).plugins)) { + return ((data as Record).plugins as unknown[]).filter((entry: unknown) => + entry && typeof entry === "object" && typeof (entry as Record).name === "string" + ) as MarketplaceEntry[]; + } + console.warn("Custom marketplace returned unrecognized format"); + } + } catch (err) { + console.error("Failed to fetch from custom plugin marketplace:", err); + } return [...SEED_REGISTRY]; } /** * Search marketplace plugins by query. */ -export function searchMarketplace(query: string): MarketplaceEntry[] { +export async function searchMarketplace(query: string): Promise { + const plugins = await listMarketplacePlugins(); const q = query.toLowerCase(); - return SEED_REGISTRY.filter( + return plugins.filter( (p) => p.name.includes(q) || p.description.toLowerCase().includes(q) || @@ -108,13 +186,14 @@ export function searchMarketplace(query: string): MarketplaceEntry[] { /** * Get a specific marketplace entry. */ -export function getMarketplaceEntry(name: string): MarketplaceEntry | undefined { - return SEED_REGISTRY.find((p) => p.name === name); +export async function getMarketplaceEntry(name: string): Promise { + const plugins = await listMarketplacePlugins(); + return plugins.find((p) => p.name === name); } /** * Check if marketplace is available. */ export function isMarketplaceAvailable(): boolean { - return true; // Local seed always available + return true; // Always available (falls back to seed) } diff --git a/tests/unit/plugins-marketplace.test.ts b/tests/unit/plugins-marketplace.test.ts new file mode 100644 index 0000000000..886c9db7f0 --- /dev/null +++ b/tests/unit/plugins-marketplace.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Test marketplace route module existence and type contracts. +// Full HTTP integration tests require the Next.js test harness. + +describe("plugin marketplace", () => { + describe("route module exists", () => { + it("marketplace route exports GET and OPTIONS", async () => { + const mod = await import("../../src/app/api/plugins/marketplace/route.ts"); + assert.equal(typeof mod.GET, "function"); + assert.equal(typeof mod.OPTIONS, "function"); + }); + }); + + describe("listMarketplacePlugins", () => { + it("returns an array of plugins with seed data", async () => { + const { listMarketplacePlugins } = await import("../../src/lib/plugins/marketplace.ts"); + const plugins = await listMarketplacePlugins(); + assert.ok(Array.isArray(plugins)); + assert.ok(plugins.length > 0); + // Each entry should have a name + for (const p of plugins) { + assert.equal(typeof p.name, "string"); + assert.ok(p.name.length > 0); + } + }); + }); + + describe("isMarketplaceAvailable", () => { + it("returns true (always available, falls back to seed)", async () => { + const { isMarketplaceAvailable } = await import("../../src/lib/plugins/marketplace.ts"); + const result = isMarketplaceAvailable(); + assert.equal(result, true); + }); + }); + + describe("searchMarketplace", () => { + it("filters plugins by name", async () => { + const { searchMarketplace } = await import("../../src/lib/plugins/marketplace.ts"); + const results = await searchMarketplace("logger"); + assert.ok(Array.isArray(results)); + assert.ok(results.length > 0); + assert.ok(results.every((p: { name: string }) => p.name.includes("logger"))); + }); + + it("returns empty array for non-matching query", async () => { + const { searchMarketplace } = await import("../../src/lib/plugins/marketplace.ts"); + const results = await searchMarketplace("zzz_nonexistent_zzz"); + assert.ok(Array.isArray(results)); + assert.equal(results.length, 0); + }); + }); + + describe("getMarketplaceEntry", () => { + it("finds a plugin by name", async () => { + const { getMarketplaceEntry } = await import("../../src/lib/plugins/marketplace.ts"); + const entry = await getMarketplaceEntry("request-logger"); + assert.ok(entry); + assert.equal(entry.name, "request-logger"); + }); + + it("returns undefined for unknown name", async () => { + const { getMarketplaceEntry } = await import("../../src/lib/plugins/marketplace.ts"); + const entry = await getMarketplaceEntry("nonexistent-plugin"); + assert.equal(entry, undefined); + }); + }); +});