feat: add extension preview page and API for detailed extension information
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||
|
||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace/[slug]
|
||||
* Returns full preview info for an extension: directory metadata,
|
||||
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
|
||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||
const detailRes = await fetch(detailUrl.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!detailRes.ok) {
|
||||
const status = detailRes.status === 404 ? 404 : 502;
|
||||
return NextResponse.json(
|
||||
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
const detailJson = await detailRes.json();
|
||||
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||
if (!extension) {
|
||||
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const type = extension.type as 'plugin' | 'theme';
|
||||
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||
?? null;
|
||||
|
||||
// 2. Pull the bundle so we can show what's actually inside.
|
||||
let manifest: Record<string, unknown> | null = null;
|
||||
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||
let bundleError: string | null = null;
|
||||
let bundleSize = 0;
|
||||
|
||||
if (latestVersion) {
|
||||
try {
|
||||
const bundleUrl = new URL(
|
||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||
DIRECTORY_URL,
|
||||
);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!bundleRes.ok) {
|
||||
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||
} else {
|
||||
const buffer = await bundleRes.arrayBuffer();
|
||||
bundleSize = buffer.byteLength;
|
||||
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||
if (buffer.byteLength > maxSize) {
|
||||
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||
} else {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
// Detect optional root directory inside the ZIP.
|
||||
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 + '/';
|
||||
}
|
||||
}
|
||||
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
bundleError = 'Bundle missing manifest.json';
|
||||
} else {
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
bundleError = 'Invalid manifest.json in bundle';
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
if (type === 'theme') {
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (cssFile) {
|
||||
const css = await cssFile.async('string');
|
||||
sourcePreview = {
|
||||
name: 'theme.css',
|
||||
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: css,
|
||||
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||
const jsFile = zip.file(root + entrypoint);
|
||||
if (jsFile) {
|
||||
const code = await jsFile.async('string');
|
||||
sourcePreview = {
|
||||
name: entrypoint,
|
||||
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: code,
|
||||
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
|
||||
}
|
||||
} else {
|
||||
bundleError = 'Extension has no published version';
|
||||
}
|
||||
|
||||
// 3. Install status (slug is used as the registry id at install time)
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
const installed = type === 'theme'
|
||||
? themeRegistry.themes.some((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||
|
||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||
const screenshots = Array.isArray(extension.screenshots)
|
||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||
altText: s.altText ?? null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||
const versions = Array.isArray(extension.versions)
|
||||
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||
version: v.version,
|
||||
changelog: v.changelog,
|
||||
bundleSize: v.bundleSize,
|
||||
minAppVersion: v.minAppVersion,
|
||||
publishedAt: v.publishedAt,
|
||||
permissions: v.permissions,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
extension: {
|
||||
slug: extension.slug,
|
||||
name: extension.name,
|
||||
type: extension.type,
|
||||
pluginType: extension.pluginType ?? null,
|
||||
description: extension.description,
|
||||
longDescription: extension.longDescription ?? null,
|
||||
tags: extension.tags ?? [],
|
||||
permissions: extension.permissions ?? [],
|
||||
totalDownloads: extension.totalDownloads ?? 0,
|
||||
featured: extension.featured ?? false,
|
||||
githubRepo: extension.githubRepo ?? null,
|
||||
license: extension.license ?? null,
|
||||
minAppVersion: extension.minAppVersion ?? null,
|
||||
author: extension.author ?? null,
|
||||
latestVersion,
|
||||
versions,
|
||||
screenshots,
|
||||
themePreviews: extension.themePreviews ?? [],
|
||||
createdAt: extension.createdAt ?? null,
|
||||
updatedAt: extension.updatedAt ?? null,
|
||||
},
|
||||
bundle: {
|
||||
manifest,
|
||||
source: sourcePreview,
|
||||
size: bundleSize,
|
||||
error: bundleError,
|
||||
},
|
||||
installed,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Marketplace preview error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load preview' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ 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';
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
|
||||
Reference in New Issue
Block a user