mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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>
|
||||
|
||||
30
src/app/api/plugins/marketplace/route.ts
Normal file
30
src/app/api/plugins/marketplace/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
69
tests/unit/plugins-marketplace.test.ts
Normal file
69
tests/unit/plugins-marketplace.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user