feat: custom plugin marketplace support (#3656)

feat: custom plugin marketplace (GET /api/plugins/marketplace + SSRF-guarded registry). Integrated into release/v3.8.24.
This commit is contained in:
Paijo
2026-06-13 20:40:36 +07:00
committed by GitHub
parent f842ebaf24
commit 7efd40729b
5 changed files with 358 additions and 51 deletions

View File

@@ -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<PluginInfo[]>([]);
const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed");
const [marketplacePlugins, setMarketplacePlugins] = useState<any[]>([]);
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() {
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
<div className="flex gap-2">
<Button variant={activeTab === "installed" ? "primary" : "secondary"} onClick={() => setActiveTab("installed")}>
{t("installedTab")}
</Button>
<Button variant={activeTab === "marketplace" ? "primary" : "secondary"} onClick={() => setActiveTab("marketplace")}>
{t("marketplaceTab")}
</Button>
</div>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
{activeTab === "marketplace" && (
<Card className="p-4 flex gap-4 items-end bg-gray-50">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">{t("marketplaceUrlLabel")}</label>
<input
type="text"
className="w-full rounded border-gray-300 p-2"
placeholder={t("marketplaceUrlPlaceholder")}
value={marketplaceUrl}
onChange={(e) => setMarketplaceUrl(e.target.value)}
/>
</div>
<Button onClick={handleSaveUrl} disabled={savingUrl}>
{t("saveMarketplaceUrl")}
</Button>
</Card>
)}
{activeTab === "installed" ? (
<>
<div className="flex items-center justify-end">
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</>
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
{marketplacePlugins.length === 0 ? (
<div className="text-gray-500 py-4">{t("marketplaceEmpty")}</div>
) : (
marketplacePlugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold flex items-center gap-2">
{plugin.name}
{plugin.verified && <Badge variant="success">{t("verified")}</Badge>}
</h3>
<p className="text-sm text-gray-500">
v{plugin.version} by {plugin.author} {plugin.description}
</p>
<div className="mt-1 flex gap-1">
{plugin.tags?.map((tag: string) => (
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{tag}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => {
addNotification({ type: "info", message: t("marketplaceInstallComingSoon") });
}}
>
{t("install")}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</Card>
))
)}
</div>
)}
</div>

View File

@@ -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,
});
}
}

View File

@@ -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": {

View File

@@ -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<boolean> {
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<MarketplaceEntry[]> {
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<string, unknown>).name === "string"
) as MarketplaceEntry[];
}
if (data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).plugins)) {
return ((data as Record<string, unknown>).plugins as unknown[]).filter((entry: unknown) =>
entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).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<MarketplaceEntry[]> {
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<MarketplaceEntry | undefined> {
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)
}

View File

@@ -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);
});
});
});