import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { savePlugin, saveTheme, getPluginRegistry, getThemeRegistry, type ServerPlugin, type ServerTheme, } from '@/lib/admin/plugin-registry'; import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001'; /** * GET /api/admin/marketplace - Search/browse the extension directory * Proxies to the extension directory API */ export async function GET(request: NextRequest) { try { const result = await requireAdminAuth(); if ('error' in result) return result.error; const { searchParams } = request.nextUrl; const url = new URL('/api/v1/extensions', DIRECTORY_URL); // Forward all search params for (const [key, value] of searchParams.entries()) { url.searchParams.set(key, value); } const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!res.ok) { return NextResponse.json( { error: 'Failed to fetch from extension directory' }, { status: 502 } ); } const data = await res.json(); // Enrich with install status const [pluginRegistry, themeRegistry] = await Promise.all([ getPluginRegistry(), getThemeRegistry(), ]); const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); if (data.data) { data.data = data.data.map((ext: Record) => ({ ...ext, installed: ext.type === 'theme' ? installedThemes.has(ext.slug as string) : installedPlugins.has(ext.slug as string), })); } return NextResponse.json(data, { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 }); } } /** * POST /api/admin/marketplace - Install an extension from the directory * Body: { slug: string, version: string, type: 'plugin' | 'theme' } */ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(); if ('error' in result) return result.error; const ip = getClientIP(request); const { slug, version, type } = await request.json(); if (!slug || !version || !type) { return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 }); } if (type !== 'plugin' && type !== 'theme') { return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); } // Download the bundle from the directory const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL); const bundleRes = await fetch(bundleUrl.toString(), { signal: AbortSignal.timeout(30000), }); if (!bundleRes.ok) { return NextResponse.json( { error: `Failed to download bundle: ${bundleRes.status}` }, { status: 502 } ); } const buffer = await bundleRes.arrayBuffer(); const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; if (buffer.byteLength > maxSize) { return NextResponse.json( { error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` }, { status: 400 } ); } // Parse the ZIP let zip: JSZip; try { zip = await JSZip.loadAsync(buffer); } catch { return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 }); } // Find root directory const entries = Object.keys(zip.files); const topDirs = new Set(entries.map(e => e.split('/')[0])); let root = ''; if (topDirs.size === 1) { const dir = [...topDirs][0]; if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { root = dir + '/'; } } // Read manifest const manifestFile = zip.file(root + 'manifest.json'); if (!manifestFile) { return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 }); } let manifest: Record; try { manifest = JSON.parse(await manifestFile.async('string')); } catch { return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 }); } const now = new Date().toISOString(); if (type === 'theme') { // Read theme.css const cssFile = zip.file(root + 'theme.css'); if (!cssFile) { return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); } let css = await cssFile.async('string'); // Validate and sanitize CSS const warnings: string[] = []; const safety = validateThemeCSSSafety(css); if (!safety.valid) { const sanitized = sanitizeThemeCSS(css); css = sanitized.css; warnings.push(...sanitized.warnings); } const theme: ServerTheme = { id: (manifest.id as string) || slug, name: (manifest.name as string) || slug, version: (manifest.version as string) || version, author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', variants: (manifest.variants as string[]) || ['light', 'dark'], enabled: true, installedAt: now, updatedAt: now, }; await saveTheme(theme, css); await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip); return NextResponse.json({ success: true, theme, warnings }); } else { // Plugin installation // Read entrypoint JS const entrypoint = (manifest.entrypoint as string) || 'index.js'; const jsFile = zip.file(root + entrypoint); if (!jsFile) { return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 }); } const code = await jsFile.async('string'); // Block plugins with dangerous JS patterns const DANGEROUS_JS_PATTERNS = [ { pattern: /\beval\s*\(/g, label: 'eval()' }, { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, { pattern: /document\.cookie/g, label: 'document.cookie' }, { pattern: /document\.write/g, label: 'document.write' }, { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, ]; const dangerousFindings: string[] = []; for (const { pattern, label } of DANGEROUS_JS_PATTERNS) { if (pattern.test(code)) dangerousFindings.push(label); pattern.lastIndex = 0; } if (dangerousFindings.length > 0) { return NextResponse.json( { error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` }, { status: 400 }, ); } // Validate permissions const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); const unknownPerms = permissions.filter(p => !validPerms.has(p)); const warnings: string[] = []; if (unknownPerms.length > 0) { warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`); } const plugin: ServerPlugin = { id: (manifest.id as string) || slug, name: (manifest.name as string) || slug, version: (manifest.version as string) || version, author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', type: (manifest.type as string) || 'hook', permissions, entrypoint, enabled: true, installedAt: now, updatedAt: now, }; await savePlugin(plugin, code); await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip); return NextResponse.json({ success: true, plugin, warnings }); } } catch (error) { logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Installation failed' }, { status: 500 }); } }