300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
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 {
|
|
sanitizeFrameOrigins,
|
|
sanitizeHttpOrigins,
|
|
invalidateFrameOriginsCache,
|
|
} from '@/lib/admin/csp-frame-origins';
|
|
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 || 'https://extensions.bulwarkmail.org';
|
|
|
|
/**
|
|
* 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));
|
|
|
|
const fileUrl = (path: unknown): string | null =>
|
|
typeof path === 'string' && path
|
|
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
|
: null;
|
|
|
|
if (data.data) {
|
|
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
|
...ext,
|
|
iconUrl: fileUrl(ext.iconPath),
|
|
bannerUrl: fileUrl(ext.bannerPath),
|
|
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<string, unknown>;
|
|
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(', ')}`);
|
|
}
|
|
|
|
// Plugins may declare iframe origins they need for embedded content.
|
|
// Anything that doesn't pass strict origin validation is silently
|
|
// dropped - the plugin still installs, but those origins are not
|
|
// added to the host CSP.
|
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
|
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
|
? (manifest.frameOrigins as unknown[]).filter(
|
|
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
|
)
|
|
: [];
|
|
if (droppedFrameOrigins.length > 0) {
|
|
warnings.push(
|
|
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
|
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
|
|
? (manifest.httpOrigins as unknown[]).filter(
|
|
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
|
|
)
|
|
: [];
|
|
if (droppedHttpOrigins.length > 0) {
|
|
warnings.push(
|
|
`Ignored invalid httpOrigins: ${droppedHttpOrigins.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,
|
|
...(declaredFrameOrigins.length > 0
|
|
? { frameOrigins: declaredFrameOrigins }
|
|
: {}),
|
|
...(declaredHttpOrigins.length > 0
|
|
? { httpOrigins: declaredHttpOrigins }
|
|
: {}),
|
|
};
|
|
|
|
await savePlugin(plugin, code);
|
|
invalidateFrameOriginsCache();
|
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, 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 });
|
|
}
|
|
}
|