mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Merge-train validated (tip 6ce4effef8). Vitest failures confirmed as base-red (#9679).
This commit is contained in:
committed by
GitHub
parent
348102a114
commit
2f5df569b5
@@ -0,0 +1 @@
|
||||
- **feat(api):** add plugins marketplace install endpoint with checksum verification ([#6752](https://github.com/diegosouzapw/OmniRoute/issues/6752))
|
||||
36
src/app/api/plugins/marketplace/install/route.ts
Normal file
36
src/app/api/plugins/marketplace/install/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import { installMarketplacePlugin } from "@/lib/plugins/marketplace";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plugins/marketplace/install — Install a plugin from marketplace by name
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name } = body as { name?: string };
|
||||
if (!name || typeof name !== "string") {
|
||||
return NextResponse.json(buildErrorBody(400, "Missing or invalid 'name' field"), {
|
||||
status: 400,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
const result = await installMarketplacePlugin(name);
|
||||
return NextResponse.json(result, { status: 201, headers: CORS_HEADERS });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to install marketplace plugin";
|
||||
console.error("[plugins/marketplace] Install error:", msg);
|
||||
return NextResponse.json(buildErrorBody(400, msg), {
|
||||
status: 400,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { getSettings } from "../db/settings";
|
||||
import dns from "node:dns/promises";
|
||||
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { pluginManager } from "./manager";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
/**
|
||||
* Plugin Marketplace — browse, search, install plugins from a registry.
|
||||
*
|
||||
@@ -69,6 +74,7 @@ export interface MarketplaceEntry {
|
||||
author: string;
|
||||
license: string;
|
||||
downloadUrl: string;
|
||||
checksum?: string; // SHA-256 hex (optional — verified when present)
|
||||
repository?: string;
|
||||
tags: string[];
|
||||
downloads: number;
|
||||
@@ -200,3 +206,48 @@ export async function getMarketplaceEntry(name: string): Promise<MarketplaceEntr
|
||||
export function isMarketplaceAvailable(): boolean {
|
||||
return true; // Always available (falls back to seed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a plugin from the marketplace by name.
|
||||
* Downloads the plugin archive, verifies checksum if present, and installs via PluginManager.
|
||||
*/
|
||||
export async function installMarketplacePlugin(name: string): Promise<{ name: string; version: string }> {
|
||||
const plugins = await listMarketplacePlugins();
|
||||
const entry = plugins.find((p) => p.name === name);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin '${name}' not found in marketplace`);
|
||||
}
|
||||
|
||||
// Create temp dir for download
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "plugin-mp-"));
|
||||
const tmpFile = join(tmpDir, "plugin.tar.gz");
|
||||
|
||||
try {
|
||||
// Download the plugin archive
|
||||
const response = await safeOutboundFetch(entry.downloadUrl, { guard: "public-only" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download plugin '${name}': ${response.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
// Verify SHA-256 checksum if provided
|
||||
if (entry.checksum) {
|
||||
const actual = createHash("sha256").update(buffer).digest("hex");
|
||||
if (actual !== entry.checksum) {
|
||||
throw new Error(
|
||||
`Checksum mismatch for plugin '${name}': expected ${entry.checksum}, got ${actual}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
await writeFile(tmpFile, buffer);
|
||||
|
||||
// Delegate to pluginManager.install
|
||||
const result = await pluginManager.install(tmpDir);
|
||||
return { name: result.name, version: result.version };
|
||||
} finally {
|
||||
// Cleanup temp dir
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
38
tests/unit/plugins-marketplace-install.test.ts
Normal file
38
tests/unit/plugins-marketplace-install.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { ok, equal } from "node:assert/strict";
|
||||
|
||||
describe("Plugins marketplace install (#6752)", () => {
|
||||
it("MarketplaceEntry supports optional checksum field", async () => {
|
||||
const { searchMarketplace } = await import("@/lib/plugins/marketplace");
|
||||
const results = await searchMarketplace("prompt");
|
||||
ok(Array.isArray(results));
|
||||
for (const entry of results) {
|
||||
ok(typeof entry.name === "string");
|
||||
ok(typeof entry.downloadUrl === "string");
|
||||
// The checksum field exists in the type (may be undefined)
|
||||
if (entry.checksum !== undefined) {
|
||||
ok(typeof entry.checksum === "string");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("installMarketplacePlugin throws for unknown plugin", async () => {
|
||||
const { installMarketplacePlugin } = await import("@/lib/plugins/marketplace");
|
||||
try {
|
||||
await installMarketplacePlugin("nonexistent-plugin");
|
||||
ok(false, "should have thrown");
|
||||
} catch (e: unknown) {
|
||||
ok((e as Error).message.includes("not found"));
|
||||
}
|
||||
});
|
||||
|
||||
it("checksum verification logic works", async () => {
|
||||
const crypto = await import("node:crypto");
|
||||
const data = Buffer.from("test-plugin-data");
|
||||
const hash = crypto.createHash("sha256").update(data).digest("hex");
|
||||
const hash2 = crypto.createHash("sha256").update(data).digest("hex");
|
||||
equal(hash, hash2, "same data should produce same hash");
|
||||
const hash3 = crypto.createHash("sha256").update(Buffer.from("different-data")).digest("hex");
|
||||
ok(hash !== hash3, "different data should produce different hash");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user