From 0f3b506604047d129e208f168d09f7888fb6ef3d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:55:23 +0200 Subject: [PATCH] feat: add extension preview page and API for detailed extension information --- .env.example | 3 +- app/admin/marketplace/[slug]/page.tsx | 542 ++++++++++++++++++++++ app/admin/marketplace/page.tsx | 61 ++- app/api/admin/marketplace/[slug]/route.ts | 214 +++++++++ app/api/admin/marketplace/route.ts | 2 +- 5 files changed, 795 insertions(+), 27 deletions(-) create mode 100644 app/admin/marketplace/[slug]/page.tsx create mode 100644 app/api/admin/marketplace/[slug]/route.ts diff --git a/.env.example b/.env.example index ec06ddf1..b9ee55d3 100644 --- a/.env.example +++ b/.env.example @@ -191,7 +191,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # ============================================================================= # URL of the BulwarkMail extension directory for the admin marketplace. -# Set this to enable browsing and installing plugins/themes from the directory. +# Defaults to https://extensions.bulwarkmail.org. Override only if you run +# your own directory (e.g. http://localhost:3001 for local development). # EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org # ============================================================================= diff --git a/app/admin/marketplace/[slug]/page.tsx b/app/admin/marketplace/[slug]/page.tsx new file mode 100644 index 00000000..753e434c --- /dev/null +++ b/app/admin/marketplace/[slug]/page.tsx @@ -0,0 +1,542 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { + ArrowLeft, + Download, + Loader2, + Puzzle, + SwatchBook, + Star, + Trash2, + Check, + Settings as SettingsIcon, + ExternalLink, + Shield, + AlertTriangle, + FileCode, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface PreviewData { + extension: { + slug: string; + name: string; + type: 'plugin' | 'theme'; + pluginType: string | null; + description: string; + longDescription: string | null; + tags: string[]; + permissions: string[]; + totalDownloads: number; + featured: boolean; + githubRepo: string | null; + license: string | null; + minAppVersion: string | null; + author: { + displayName: string; + githubLogin: string; + avatarUrl: string | null; + verified?: boolean; + } | null; + latestVersion: string | null; + versions: Array<{ + version: string; + changelog: string | null; + bundleSize: number; + minAppVersion: string | null; + publishedAt: string | null; + permissions: string[]; + }>; + screenshots: Array<{ url: string; altText: string | null }>; + themePreviews: Array<{ + variant: 'light' | 'dark'; + previewPath: string; + colors: Record | null; + }>; + createdAt: string | null; + updatedAt: string | null; + }; + bundle: { + manifest: Record | null; + source: { name: string; content: string; truncated: boolean } | null; + size: number; + error: string | null; + }; + installed: boolean; +} + +const RISKY_PERMISSIONS = new Set([ + 'mail:write', + 'mail:delete', + 'storage:write', + 'network', + 'admin', +]); + +export default function MarketplacePreviewPage() { + const params = useParams(); + const slug = params.slug as string; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [installing, setInstalling] = useState(false); + const [uninstalling, setUninstalling] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [showSource, setShowSource] = useState(false); + const [showManifest, setShowManifest] = useState(false); + + const fetchPreview = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error || 'Failed to load preview'); + return; + } + setData(await res.json()); + } catch { + setError('Failed to connect to extension directory'); + } finally { + setLoading(false); + } + }, [slug]); + + useEffect(() => { fetchPreview(); }, [fetchPreview]); + + async function handleInstall() { + if (!data) return; + setInstalling(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/marketplace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug: data.extension.slug, + version: data.extension.latestVersion || '1.0.0', + type: data.extension.type, + }), + }); + const body = await res.json(); + if (res.ok) { + const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; + setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` }); + setData(prev => prev ? { ...prev, installed: true } : prev); + } else { + setMessage({ type: 'error', text: body.error || 'Installation failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Installation failed - network error' }); + } finally { + setInstalling(false); + } + } + + async function handleUninstall() { + if (!data) return; + if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return; + + setUninstalling(true); + setMessage(null); + try { + const endpoint = data.extension.type === 'theme' + ? '/api/admin/themes' + : '/api/admin/plugins'; + const res = await apiFetch(endpoint, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: data.extension.slug }), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) { + setMessage({ type: 'success', text: `"${data.extension.name}" removed` }); + setData(prev => prev ? { ...prev, installed: false } : prev); + } else { + setMessage({ type: 'error', text: body.error || 'Uninstall failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Uninstall failed - network error' }); + } finally { + setUninstalling(false); + } + } + + if (loading) { + return ( +
+ + Loading... +
+ ); + } + + if (error || !data) { + return ( +
+ + Back to Marketplace + +

{error || 'Extension not found'}

+
+ ); + } + + const ext = data.extension; + const bundle = data.bundle; + const isPlugin = ext.type === 'plugin'; + const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || []; + const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; + const settingsSchema = bundle.manifest?.settingsSchema as Record | undefined; + + return ( +
+ {/* Back link */} + + Back to Marketplace + + + {/* Header */} +
+
+ {isPlugin ? ( + + ) : ( + + )} +
+
+
+

{ext.name}

+ {ext.featured && } + {data.installed && ( + + Installed + + )} +
+
+ + {isPlugin ? (ext.pluginType || 'plugin') : 'theme'} + + {ext.author && ( + by {ext.author.displayName} + )} + {ext.latestVersion && v{ext.latestVersion}} + {ext.license && {ext.license}} + + + {ext.totalDownloads.toLocaleString()} + +
+
+ + {/* Action buttons */} +
+ {data.installed ? ( + <> + + + Manage + + + + ) : ( + + )} +
+
+ + {message && ( +
+ {message.text} +
+ )} + + {bundle.error && ( +
+ +
+

Could not preview bundle

+

{bundle.error}

+
+
+ )} + + {/* Description */} +
+

About

+

{ext.description}

+ {ext.longDescription && ext.longDescription !== ext.description && ( +

{ext.longDescription}

+ )} + {ext.tags.length > 0 && ( +
+ {ext.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ {ext.minAppVersion && Requires app v{ext.minAppVersion}+} + {bundle.size > 0 && Bundle: {(bundle.size / 1024).toFixed(1)} KB} + {ext.githubRepo && ( + + + {ext.githubRepo} + + )} +
+
+ + {/* Screenshots */} + {ext.screenshots.length > 0 && ( +
+

Screenshots

+
+ {ext.screenshots.map((s, i) => ( + {s.altText + ))} +
+
+ )} + + {/* Theme color preview */} + {!isPlugin && ext.themePreviews.length > 0 && ( +
+

Theme preview

+
+ {ext.themePreviews.map(preview => ( + + ))} +
+
+ )} + + {/* Permissions */} + {isPlugin && ( +
+
+ +

Permissions

+
+ {manifestPerms.length === 0 ? ( +

This plugin requests no permissions.

+ ) : ( +
    + {manifestPerms.map(perm => { + const risky = RISKY_PERMISSIONS.has(perm); + return ( +
  • + {risky && } + {perm} +
  • + ); + })} +
+ )} + {frameOrigins.length > 0 && ( +
+

Iframe origins

+

+ The plugin will be allowed to embed content from these origins. +

+
    + {frameOrigins.map(origin => ( +
  • + {origin} +
  • + ))} +
+
+ )} +
+ )} + + {/* Settings schema preview */} + {isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && ( +
+

User settings

+

Settings users will be able to configure after install.

+
    + {Object.entries(settingsSchema).map(([key, field]) => ( +
  • +
    + {key} + {field.type} +
    +
    {field.label}
    + {field.description && ( +
    {field.description}
    + )} +
  • + ))} +
+
+ )} + + {/* Source / manifest disclosure */} + {bundle.manifest && ( +
+ + {showManifest && ( +
+              {JSON.stringify(bundle.manifest, null, 2)}
+            
+ )} +
+ )} + + {bundle.source && ( +
+ + {showSource && ( +
+              {bundle.source.content}
+            
+ )} +
+ )} + + {/* Version history */} + {ext.versions.length > 0 && ( +
+

Version history

+
    + {ext.versions.slice(0, 5).map(v => ( +
  • +
    +
    + v{v.version} + {v.publishedAt && ( + + {new Date(v.publishedAt).toLocaleDateString()} + + )} +
    + {v.changelog && ( +

    {v.changelog}

    + )} +
    + + {(v.bundleSize / 1024).toFixed(1)} KB + +
  • + ))} +
+
+ )} +
+ ); +} + +function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record | null } }) { + const colors = preview.colors || {}; + const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff'); + const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a'); + const accent = colors.primary || colors.accent || '#7c5cff'; + const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5'); + const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5'); + + return ( +
+
+ {preview.variant} +
+
+
+ + Sample text +
+
+ Card surface +
+
+ {Object.entries(colors).slice(0, 6).map(([key, value]) => ( + + ))} +
+
+
+ ); +} diff --git a/app/admin/marketplace/page.tsx b/app/admin/marketplace/page.tsx index 5c1cc481..51080abd 100644 --- a/app/admin/marketplace/page.tsx +++ b/app/admin/marketplace/page.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; -import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react'; +import Link from 'next/link'; +import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; interface Extension { @@ -262,10 +263,11 @@ function ExtensionCard({ onInstall: () => void; }) { const isPlugin = extension.type === 'plugin'; + const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; return ( -
-
+
+ {/* Header */}
@@ -277,7 +279,9 @@ function ExtensionCard({
- {extension.name} + + {extension.name} + {extension.featured && ( )} @@ -315,7 +319,7 @@ function ExtensionCard({
)} - {/* Footer */} + {/* Footer (download count + permissions) */}
@@ -328,27 +332,34 @@ function ExtensionCard({ )}
- - {extension.installed ? ( - - - Installed - - ) : ( - - )} + + + Preview +
+ + + {/* Quick install button (sits over the link, stops navigation) */} +
+ {extension.installed ? ( + + + Installed + + ) : ( + + )}
); diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts new file mode 100644 index 00000000..d747c632 --- /dev/null +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -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 | 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 | 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>).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 }, + ); + } +} diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 021996f6..98445226 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -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