The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.
Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
(alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
against the plugin's declared locales (manifest.locales) with English/key
fallback and {placeholder} interpolation.
Lets any sandboxed plugin localize its strings from its manifest.
302 lines
11 KiB
TypeScript
302 lines
11 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 {
|
|
getPluginRegistry,
|
|
savePlugin,
|
|
deletePlugin as removePlugin,
|
|
type ServerPlugin,
|
|
} from '@/lib/admin/plugin-registry';
|
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
|
import {
|
|
sanitizeFrameOrigins,
|
|
sanitizeHttpOrigins,
|
|
sanitizeApiPostPaths,
|
|
invalidateFrameOriginsCache,
|
|
} from '@/lib/admin/csp-frame-origins';
|
|
|
|
// Server-side extraction using the same validation logic
|
|
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
|
import JSZip from 'jszip';
|
|
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
|
|
|
const SUSPICIOUS_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' },
|
|
];
|
|
|
|
/**
|
|
* GET /api/admin/plugins - List all admin-managed plugins
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
const [registry, devEntries] = await Promise.all([
|
|
getPluginRegistry(),
|
|
listDevPlugins(),
|
|
]);
|
|
|
|
// Dev plugins win on id collision so admins see what users actually load.
|
|
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
|
const merged = [
|
|
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
|
|
...registry.plugins
|
|
.filter(p => !devIds.has(p.id))
|
|
.map(p => ({ ...p, dev: false as const })),
|
|
];
|
|
return NextResponse.json(merged, {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
});
|
|
} catch (error) {
|
|
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
const ip = getClientIP(request);
|
|
const formData = await request.formData();
|
|
const file = formData.get('file') as File | null;
|
|
|
|
if (!file) {
|
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
|
}
|
|
|
|
if (file.size > MAX_PLUGIN_SIZE) {
|
|
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
|
|
}
|
|
|
|
// Extract and validate ZIP
|
|
let zip: JSZip;
|
|
try {
|
|
const buffer = await file.arrayBuffer();
|
|
zip = await JSZip.loadAsync(buffer);
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
|
}
|
|
|
|
// Find root
|
|
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: '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' }, { status: 400 });
|
|
}
|
|
|
|
// Validate manifest
|
|
const errors: string[] = [];
|
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
|
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
|
|
|
|
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
|
|
if (!validTypes.includes(manifest.type as string)) {
|
|
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
|
}
|
|
|
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
|
}
|
|
|
|
if (manifest.permissions && Array.isArray(manifest.permissions)) {
|
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
|
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
|
|
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
|
}
|
|
|
|
// Check file extensions
|
|
for (const [filePath, entry] of Object.entries(zip.files)) {
|
|
if (entry.dir) continue;
|
|
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
|
|
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
|
|
errors.push(`Disallowed file type: ${filePath}`);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
|
}
|
|
|
|
// Read entrypoint code
|
|
const entryFile = zip.file(root + (manifest.entrypoint as string));
|
|
if (!entryFile) {
|
|
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
|
|
}
|
|
const code = await entryFile.async('string');
|
|
|
|
// Security: block plugins containing dangerous JS patterns
|
|
const warnings: string[] = [];
|
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
|
pattern.lastIndex = 0;
|
|
}
|
|
if (warnings.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
|
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
|
|
|
const now = new Date().toISOString();
|
|
const plugin: ServerPlugin = {
|
|
id: manifest.id as string,
|
|
name: manifest.name as string,
|
|
version: manifest.version as string,
|
|
author: manifest.author as string,
|
|
description: (manifest.description as string) || '',
|
|
type: manifest.type as string,
|
|
permissions: (manifest.permissions as string[]) || [],
|
|
entrypoint: manifest.entrypoint as string,
|
|
enabled: true,
|
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
|
: {}),
|
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
|
: {}),
|
|
...(manifest.locales && typeof manifest.locales === 'object'
|
|
? { locales: manifest.locales as ServerPlugin['locales'] }
|
|
: {}),
|
|
...(declaredFrameOrigins.length > 0
|
|
? { frameOrigins: declaredFrameOrigins }
|
|
: {}),
|
|
...(declaredHttpOrigins.length > 0
|
|
? { httpOrigins: declaredHttpOrigins }
|
|
: {}),
|
|
...(declaredApiPostPaths.length > 0
|
|
? { apiPostPaths: declaredApiPostPaths }
|
|
: {}),
|
|
installedAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await savePlugin(plugin, code);
|
|
invalidateFrameOriginsCache();
|
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
|
|
|
return NextResponse.json({ plugin });
|
|
} catch (error) {
|
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
|
* Body: { id: string, enabled: boolean }
|
|
*/
|
|
export async function PATCH(request: NextRequest) {
|
|
try {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
const ip = getClientIP(request);
|
|
const { id, enabled, forceEnabled } = await request.json();
|
|
|
|
if (!id || typeof id !== 'string') {
|
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
|
}
|
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
|
}
|
|
|
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
|
|
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
|
let updated = await updatePluginMeta(id, updates);
|
|
if (!updated) {
|
|
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
|
|
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
|
|
// client. Skip the registry write and return the live dev plugin so the
|
|
// policy save path can proceed.
|
|
const devEntries = await listDevPlugins();
|
|
const devEntry = devEntries.find(e => e.plugin.id === id);
|
|
if (!devEntry) {
|
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
|
}
|
|
updated = { ...devEntry.plugin, ...updates };
|
|
}
|
|
|
|
// Enable/disable changes the set of plugins contributing frame origins.
|
|
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
|
invalidateFrameOriginsCache();
|
|
}
|
|
|
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
|
return NextResponse.json({ plugin: updated });
|
|
} catch (error) {
|
|
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/admin/plugins - Remove a plugin
|
|
* Body: { id: string }
|
|
*/
|
|
export async function DELETE(request: NextRequest) {
|
|
try {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
const ip = getClientIP(request);
|
|
const { id } = await request.json();
|
|
|
|
if (!id || typeof id !== 'string') {
|
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
|
}
|
|
|
|
const removed = await removePlugin(id);
|
|
if (!removed) {
|
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
|
}
|
|
|
|
invalidateFrameOriginsCache();
|
|
await auditLog('plugin.delete', { id }, ip);
|
|
return NextResponse.json({ success: true });
|
|
} catch (error) {
|
|
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|