Files
clube67.com/backend/src/core/plugin-loader.ts
T
2026-05-18 18:32:55 +02:00

720 lines
30 KiB
TypeScript

// ============================================================
// Clube67 — Plugin Loader
// ============================================================
// Scans /plugins directory, validates manifests, resolves
// dependency order, and activates each plugin.
import fs from 'fs';
import path from 'path';
import os from 'os';
import { Express } from 'express';
import multer from 'multer';
import { spawnSync } from 'child_process';
import { PluginManifest, PluginInstance, PluginContext } from './types';
import { pluginRegistry } from './plugin-registry';
import { hooks } from './hooks';
import { logger } from '../utils/logger';
import { db } from '../config/database';
import { redis } from '../config/redis';
import { authenticate } from '../middleware/auth';
import { authorize } from '../middleware/rbac';
// Source plugins dir (manifests + SQL migrations)
const PROJECT_ROOT = path.resolve(__dirname, '../../../../');
const PLUGINS_SOURCE_DIR = path.join(PROJECT_ROOT, 'plugins');
// Compiled plugins dir (JS modules)
const PLUGINS_COMPILED_DIR = path.resolve(__dirname, '../../../plugins');
// ── Manifest Loading ────────────────────────────────────────
function loadManifest(pluginDir: string): PluginManifest | null {
const manifestPath = path.join(pluginDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
logger.warn(`[PluginLoader] No manifest.json in ${path.basename(pluginDir)}`);
return null;
}
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
return JSON.parse(raw) as PluginManifest;
} catch (err: any) {
logger.error(`[PluginLoader] Invalid manifest in ${path.basename(pluginDir)}: ${err.message}`);
return null;
}
}
// ── Dependency Resolution (Topological Sort) ────────────────
function resolveDependencyOrder(manifests: PluginManifest[]): PluginManifest[] {
const byName = new Map(manifests.map(m => [m.name, m]));
const visited = new Set<string>();
const sorted: PluginManifest[] = [];
function visit(name: string, stack: Set<string>) {
if (visited.has(name)) return;
if (stack.has(name)) {
throw new Error(`[PluginLoader] Circular dependency detected involving "${name}"`);
}
stack.add(name);
const manifest = byName.get(name);
if (!manifest) return;
for (const dep of manifest.dependencies) {
visit(dep, stack);
}
stack.delete(name);
visited.add(name);
sorted.push(manifest);
}
for (const m of manifests) {
visit(m.name, new Set());
}
return sorted;
}
// ── Plugin Instance Loading ─────────────────────────────────
function loadPluginInstance(pluginName: string, manifest: PluginManifest): PluginInstance | null {
// Try compiled JS first, then source TS
const compiledDir = path.join(PLUGINS_COMPILED_DIR, pluginName);
const sourceDir = path.join(PLUGINS_SOURCE_DIR, pluginName);
const candidates = [
path.join(compiledDir, 'index.js'),
path.join(sourceDir, 'index.js'),
path.join(sourceDir, 'index.ts'),
];
let modulePath: string | null = null;
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
modulePath = candidate;
break;
}
}
if (!modulePath) {
logger.warn(`[PluginLoader] No index file in plugin "${manifest.name}"`);
return null;
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(modulePath);
const instance: PluginInstance = mod.default || mod;
instance.manifest = manifest;
return instance;
} catch (err: any) {
logger.error(`[PluginLoader] Failed to load "${manifest.name}": ${err.message}`);
return null;
}
}
function getPluginDirByName(name: string): { dirName: string; fullPath: string; manifest: PluginManifest } | null {
if (!fs.existsSync(PLUGINS_SOURCE_DIR)) return null;
const pluginDirs = fs.readdirSync(PLUGINS_SOURCE_DIR)
.map(dirName => ({ dirName, fullPath: path.join(PLUGINS_SOURCE_DIR, dirName) }))
.filter(p => fs.existsSync(path.join(p.fullPath, 'manifest.json')));
for (const p of pluginDirs) {
const manifest = loadManifest(p.fullPath);
if (manifest && manifest.name === name) {
return { dirName: p.dirName, fullPath: p.fullPath, manifest };
}
}
return null;
}
// ── Migration Runner ────────────────────────────────────────
async function runMigrations(pluginName: string, manifest: PluginManifest): Promise<void> {
if (!manifest.backend?.hasMigrations) return;
// Always read migrations from SOURCE directory (SQL files aren't compiled)
const migrationsDir = path.join(PLUGINS_SOURCE_DIR, pluginName, 'backend', 'migrations');
if (!fs.existsSync(migrationsDir)) return;
const migrationFiles = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of migrationFiles) {
const filePath = path.join(migrationsDir, file);
const sql = fs.readFileSync(filePath, 'utf-8');
// Check if migration was already applied
const migrationKey = `${manifest.name}:${file}`;
try {
if (db.client.config.client === 'pg') {
await db.raw(`
CREATE TABLE IF NOT EXISTS plugin_migrations (
id SERIAL PRIMARY KEY,
plugin_name VARCHAR(255) NOT NULL,
migration_file VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_migration UNIQUE (plugin_name, migration_file)
)
`);
} else {
await db.raw(`
CREATE TABLE IF NOT EXISTS plugin_migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
plugin_name VARCHAR(255) NOT NULL,
migration_file VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_migration (plugin_name, migration_file)
)
`);
}
const existing = await db('plugin_migrations')
.select('id')
.where({ plugin_name: manifest.name, migration_file: file })
.first();
if (existing) {
logger.info(`[PluginLoader] Migration ${migrationKey} already applied, skipping.`);
continue;
}
// Run migration — each statement is fault-tolerant for idempotent patterns
const statements = sql.split(';').filter(s => s.trim().length > 0);
for (const stmt of statements) {
try {
let finalStmt = stmt;
if (db.client.config.client === 'pg') {
// If it's a MySQL session variable script or procedural blocks, skip it
if (stmt.toLowerCase().includes('set @') ||
stmt.toLowerCase().includes('prepare ') ||
stmt.toLowerCase().includes('execute ') ||
stmt.toLowerCase().includes('deallocate ')) {
continue;
}
// Replace common MySQL types/keywords
finalStmt = finalStmt
.replace(/int\s+auto_increment/gi, 'SERIAL')
.replace(/auto_increment/gi, '')
.replace(/int\(\d+\)/gi, 'integer')
.replace(/varchar\(\d+\)/gi, 'varchar')
.replace(/text/gi, 'text')
.replace(/datetime/gi, 'timestamp')
.replace(/timestamp\s+default\s+current_timestamp\s+on\s+update\s+current_timestamp/gi, 'timestamp default current_timestamp')
.replace(/on\s+update\s+current_timestamp/gi, '')
.replace(/unique\s+key\s+\w+\s+\(([^)]+)\)/gi, 'CONSTRAINT unique_$1 UNIQUE ($1)')
.replace(/index\s+\w+\s+\(([^)]+)\)/gi, '')
.replace(/foreign\s+key\s+\(([^)]+)\)\s+references\s+(\w+)\(([^)]+)\)/gi, 'FOREIGN KEY ($1) REFERENCES $2($3)')
.replace(/engine\s*=\s*\w+/gi, '')
.replace(/default\s+charset\s*=\s*\w+/gi, '')
.replace(/collate\s*=\s*\w+/gi, '')
.replace(/modify\s+column/gi, 'ALTER COLUMN')
.replace(/add\s+column\s+if\s+not\s+exists/gi, 'ADD COLUMN')
.replace(/add\s+column/gi, 'ADD COLUMN')
.replace(/if\s+not\s+exists/gi, '')
.replace(/enum\([^)]+\)/gi, 'varchar(255)');
}
await db.raw(finalStmt);
} catch (stmtErr: any) {
if (db.client.config.client === 'pg') {
const pgErrorMsgs = [
'already exists',
'duplicate key',
'does not exist',
'already is a member'
];
if (pgErrorMsgs.some(msg => stmtErr.message.toLowerCase().includes(msg))) {
logger.info(`[PluginLoader] ⏭️ PostgreSQL skipped: ${stmtErr.message.substring(0, 80)}`);
} else {
logger.warn(`[PluginLoader] ⚠️ PostgreSQL migration statement failed (continuing): ${stmtErr.message}`);
}
} else {
const toleratedCodes = [1060, 1061, 1050, 1091];
if (toleratedCodes.includes(stmtErr.errno)) {
logger.info(`[PluginLoader] ⏭️ Skipped (already exists): ${stmtErr.message.substring(0, 80)}`);
} else {
throw stmtErr;
}
}
}
}
// Record migration
await db('plugin_migrations').insert({
plugin_name: manifest.name,
migration_file: file
});
logger.info(`[PluginLoader] ✅ Applied migration ${migrationKey}`);
} catch (err: any) {
logger.error(`[PluginLoader] ❌ Migration ${migrationKey} failed: ${err.message}`);
throw err;
}
}
}
function buildPluginContext(app: Express, io: any, manifest: PluginManifest): PluginContext {
return {
app,
io,
db,
hooks,
config: {},
logger: {
info: (msg, meta) => logger.info(`[Plugin:${manifest.name}] ${msg}`, meta),
warn: (msg, meta) => logger.warn(`[Plugin:${manifest.name}] ${msg}`, meta),
error: (msg, meta) => logger.error(`[Plugin:${manifest.name}] ${msg}`, meta),
},
};
}
function clearRequireCache(targetDir: string): void {
const normalized = path.resolve(targetDir);
Object.keys(require.cache).forEach(key => {
if (key.startsWith(normalized)) {
delete require.cache[key];
}
});
}
async function unloadPlugin(app: Express, io: any, name: string): Promise<void> {
const entry = pluginRegistry.get(name);
if (!entry) return;
const ctx = buildPluginContext(app, io, entry.manifest);
try {
await entry.instance.deactivate(ctx);
} catch (err: any) {
logger.warn(`[PluginLoader] Deactivate "${name}" failed: ${err.message}`);
}
const layers = entry.layers || [];
const routerStack = (app as any)?._router?.stack;
if (Array.isArray(routerStack) && layers.length > 0) {
(app as any)._router.stack = routerStack.filter((l: any) => !layers.includes(l));
}
pluginRegistry.remove(name);
}
async function loadPluginFromDir(app: Express, io: any, dirName: string, manifest: PluginManifest): Promise<void> {
const instance = loadPluginInstance(dirName, manifest);
if (!instance) {
pluginRegistry.register(manifest, { manifest, activate: async () => { }, deactivate: async () => { } });
pluginRegistry.markError(manifest.name, 'Failed to load plugin module');
return;
}
pluginRegistry.register(manifest, instance);
try {
await runMigrations(dirName, manifest);
} catch (err: any) {
pluginRegistry.markError(manifest.name, `Migration failed: ${err.message}`);
return;
}
const ctx = buildPluginContext(app, io, manifest);
const beforeStack = ((app as any)?._router?.stack || []).slice();
try {
await instance.activate(ctx);
pluginRegistry.markActive(manifest.name);
const afterStack = (app as any)?._router?.stack || [];
const newLayers = afterStack.filter((l: any) => !beforeStack.includes(l));
pluginRegistry.setLayers(manifest.name, newLayers);
logger.info(`[PluginLoader] ✅ Activated: ${manifest.displayName} v${manifest.version}`);
} catch (err: any) {
pluginRegistry.markError(manifest.name, err.message);
logger.error(`[PluginLoader] ❌ Failed to activate "${manifest.name}": ${err.message}`);
}
}
// ── Main Loader ─────────────────────────────────────────────
export async function loadPlugins(app: Express, io: any): Promise<void> {
logger.info(`[PluginLoader] Scanning plugins source: ${PLUGINS_SOURCE_DIR}`);
logger.info(`[PluginLoader] Compiled plugins at: ${PLUGINS_COMPILED_DIR}`);
if (!fs.existsSync(PLUGINS_SOURCE_DIR)) {
logger.info('[PluginLoader] No plugins directory found, creating...');
fs.mkdirSync(PLUGINS_SOURCE_DIR, { recursive: true });
return;
}
// 1. Scan and load manifests from source directory
const pluginDirs = fs.readdirSync(PLUGINS_SOURCE_DIR)
.map(name => ({ name, fullPath: path.join(PLUGINS_SOURCE_DIR, name) }))
.filter(p => fs.statSync(p.fullPath).isDirectory() && p.name !== 'node_modules');
const manifests: PluginManifest[] = [];
const pluginNames: Map<string, string> = new Map();
for (const { name: dirName, fullPath } of pluginDirs) {
const manifest = loadManifest(fullPath);
if (manifest && manifest.enabled) {
manifests.push(manifest);
pluginNames.set(manifest.name, dirName);
}
}
logger.info(`[PluginLoader] Found ${manifests.length} enabled plugins`);
// 2. Resolve dependency order
let sorted: PluginManifest[];
try {
sorted = resolveDependencyOrder(manifests);
} catch (err: any) {
logger.error(err.message);
return;
}
// 3. Activate plugins in order
for (const manifest of sorted) {
const dirName = pluginNames.get(manifest.name)!;
// Check dependencies
const depCheck = pluginRegistry.checkDependencies(manifest.name);
// Register first so dependency check works for later plugins
const instance = loadPluginInstance(dirName, manifest);
if (!instance) {
pluginRegistry.register(manifest, { manifest, activate: async () => { }, deactivate: async () => { } });
pluginRegistry.markError(manifest.name, 'Failed to load plugin module');
continue;
}
pluginRegistry.register(manifest, instance);
// Run migrations before activation
try {
await runMigrations(dirName, manifest);
} catch (err: any) {
pluginRegistry.markError(manifest.name, `Migration failed: ${err.message}`);
continue;
}
// Build plugin context
const ctx: PluginContext = buildPluginContext(app, io, manifest);
// Activate
try {
const beforeStack = ((app as any)?._router?.stack || []).slice();
await instance.activate(ctx);
pluginRegistry.markActive(manifest.name);
const afterStack = (app as any)?._router?.stack || [];
const newLayers = afterStack.filter((l: any) => !beforeStack.includes(l));
pluginRegistry.setLayers(manifest.name, newLayers);
logger.info(`[PluginLoader] ✅ Activated: ${manifest.displayName} v${manifest.version}`);
} catch (err: any) {
pluginRegistry.markError(manifest.name, err.message);
logger.error(`[PluginLoader] ❌ Failed to activate "${manifest.name}": ${err.message}`);
}
}
// 4. Summary
const summary = pluginRegistry.getSummary();
logger.info(`[PluginLoader] ═══════════════════════════════════`);
logger.info(`[PluginLoader] Plugins: ${summary.active} active, ${summary.error} errors, ${summary.inactive} inactive`);
logger.info(`[PluginLoader] ═══════════════════════════════════`);
// 5. Register admin API for plugin management
registerPluginAdminRoutes(app, io);
}
// ── Admin API Routes ────────────────────────────────────────
function registerPluginAdminRoutes(app: Express, io: any): void {
const adminOnly = [authenticate, authorize(['super_admin'])];
const uploadZip = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.originalname.toLowerCase().endsWith('.zip')) return cb(null, true);
cb(new Error('Apenas arquivos .zip são permitidos'));
},
});
const PLUGIN_LIST_CACHE_KEY = 'admin:plugins:list';
const PLUGIN_MENU_CACHE_PREFIX = 'admin:plugins:menu:';
const PLUGIN_CACHE_TTL_SECONDS = 30;
const clearPluginCaches = async () => {
try {
await redis.del(PLUGIN_LIST_CACHE_KEY);
const menuKeys = await redis.keys(`${PLUGIN_MENU_CACHE_PREFIX}*`);
if (menuKeys.length) await redis.del(...menuKeys);
} catch {
// ignore cache errors
}
};
function copyDir(srcDir: string, destDir: string) {
if (!fs.existsSync(srcDir)) return;
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const src = path.join(srcDir, entry.name);
const dest = path.join(destDir, entry.name);
if (entry.isDirectory()) copyDir(src, dest);
else fs.copyFileSync(src, dest);
}
}
function buildExportZip(pluginDir: string, lite: boolean): Buffer {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-export-'));
const stageDir = path.join(tmpDir, 'plugin');
fs.mkdirSync(stageDir, { recursive: true });
const manifestPath = path.join(pluginDir, 'manifest.json');
if (fs.existsSync(manifestPath)) fs.copyFileSync(manifestPath, path.join(stageDir, 'manifest.json'));
const backendDir = path.join(pluginDir, 'backend');
const routesTs = path.join(backendDir, 'routes.ts');
const routesJs = path.join(backendDir, 'routes.js');
const serviceTs = path.join(backendDir, 'service.ts');
const serviceJs = path.join(backendDir, 'service.js');
const stageBackend = path.join(stageDir, 'backend');
fs.mkdirSync(stageBackend, { recursive: true });
if (fs.existsSync(routesTs)) fs.copyFileSync(routesTs, path.join(stageBackend, 'routes.ts'));
if (fs.existsSync(routesJs)) fs.copyFileSync(routesJs, path.join(stageBackend, 'routes.js'));
if (fs.existsSync(serviceTs)) fs.copyFileSync(serviceTs, path.join(stageBackend, 'service.ts'));
if (fs.existsSync(serviceJs)) fs.copyFileSync(serviceJs, path.join(stageBackend, 'service.js'));
copyDir(path.join(backendDir, 'models'), path.join(stageBackend, 'models'));
if (!lite) {
copyDir(path.join(backendDir, 'migrations'), path.join(stageBackend, 'migrations'));
const readmePath = path.join(pluginDir, 'README.md');
if (fs.existsSync(readmePath)) fs.copyFileSync(readmePath, path.join(stageDir, 'README.md'));
}
const zipPath = path.join(tmpDir, 'plugin.zip');
const zipProc = spawnSync('zip', ['-r', zipPath, '.'], { cwd: stageDir });
if (zipProc.status !== 0) {
throw new Error('Falha ao gerar ZIP');
}
const buffer = fs.readFileSync(zipPath);
fs.rmSync(tmpDir, { recursive: true, force: true });
return buffer;
}
async function importPluginFromZip(app: Express, io: any, buffer: Buffer, reloadAfter: boolean): Promise<{ name: string }> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-import-'));
const zipPath = path.join(tmpDir, 'plugin.zip');
fs.writeFileSync(zipPath, buffer);
const listProc = spawnSync('unzip', ['-Z1', zipPath]);
if (listProc.status !== 0) throw new Error('Arquivo ZIP inválido');
const entries = listProc.stdout.toString('utf-8').split('\n').filter(Boolean);
for (const name of entries) {
const clean = name.replace(/\\/g, '/');
if (clean.startsWith('/') || clean.includes('..') || clean.includes(':')) {
throw new Error('Arquivo ZIP inválido');
}
}
const extractDir = path.join(tmpDir, 'extract');
fs.mkdirSync(extractDir, { recursive: true });
const unzipProc = spawnSync('unzip', ['-o', zipPath, '-d', extractDir]);
if (unzipProc.status !== 0) throw new Error('Falha ao extrair ZIP');
const findManifest = (dir: string): string | null => {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const full = path.join(dir, item.name);
if (item.isDirectory()) {
const found = findManifest(full);
if (found) return found;
} else if (item.name === 'manifest.json') {
return full;
}
}
return null;
};
const manifestPath = findManifest(extractDir);
if (!manifestPath) throw new Error('manifest.json não encontrado no ZIP');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PluginManifest;
if (!manifest?.name) throw new Error('manifest.json inválido');
const rootDir = path.dirname(manifestPath);
const targetDir = path.join(PLUGINS_SOURCE_DIR, manifest.name);
if (fs.existsSync(targetDir)) {
await unloadPlugin(app, io, manifest.name);
fs.rmSync(targetDir, { recursive: true, force: true });
}
fs.mkdirSync(PLUGINS_SOURCE_DIR, { recursive: true });
fs.cpSync(rootDir, targetDir, { recursive: true });
clearRequireCache(path.join(PLUGINS_SOURCE_DIR, manifest.name));
clearRequireCache(path.join(PLUGINS_COMPILED_DIR, manifest.name));
const loaded = loadManifest(targetDir);
if (!loaded) throw new Error('manifest.json inválido no destino');
await loadPluginFromDir(app, io, manifest.name, loaded);
if (reloadAfter) {
await unloadPlugin(app, io, manifest.name);
clearRequireCache(path.join(PLUGINS_SOURCE_DIR, manifest.name));
clearRequireCache(path.join(PLUGINS_COMPILED_DIR, manifest.name));
await loadPluginFromDir(app, io, manifest.name, loaded);
}
fs.rmSync(tmpDir, { recursive: true, force: true });
await clearPluginCaches();
return { name: manifest.name };
}
// GET /api/admin/plugins — list all plugins
app.get('/api/admin/plugins', ...adminOnly, (req, res) => {
(async () => {
try {
const cached = await redis.get(PLUGIN_LIST_CACHE_KEY);
if (cached) {
return res.json(JSON.parse(cached));
}
} catch {
// ignore cache errors
}
const plugins = pluginRegistry.getAll().map(entry => ({
name: entry.manifest.name,
displayName: entry.manifest.displayName,
version: entry.manifest.version,
description: entry.manifest.description,
category: entry.manifest.category,
status: entry.status,
error: entry.error,
canDisable: entry.manifest.canDisable,
dependencies: entry.manifest.dependencies,
activatedAt: entry.activatedAt,
menuItems: entry.manifest.frontend?.menuItems || [],
}));
const payload = {
summary: pluginRegistry.getSummary(),
plugins,
};
try {
await redis.set(PLUGIN_LIST_CACHE_KEY, JSON.stringify(payload), 'EX', PLUGIN_CACHE_TTL_SECONDS);
} catch {
// ignore cache errors
}
res.json(payload);
})();
});
// GET /api/admin/plugins/menu — get sidebar menu items for current user
app.get('/api/admin/plugins/menu', ...adminOnly, (req, res) => {
(async () => {
const role = (req as any).user?.role || 'user';
const cacheKey = `${PLUGIN_MENU_CACHE_PREFIX}${role}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
} catch {
// ignore cache errors
}
const activeManifests = pluginRegistry.getActiveManifests();
const menuItems = activeManifests
.flatMap(m => (m.frontend?.menuItems || []).map(item => ({
...item,
pluginName: m.name,
})))
.filter(item => item.roles.includes(role) || item.roles.includes('*'))
.sort((a, b) => a.order - b.order);
const payload = { menuItems };
try {
await redis.set(cacheKey, JSON.stringify(payload), 'EX', PLUGIN_CACHE_TTL_SECONDS);
} catch {
// ignore cache errors
}
res.json(payload);
})();
});
// GET /api/admin/plugins/hooks — list registered hooks
app.get('/api/admin/plugins/hooks', ...adminOnly, (_req, res) => {
const hooksList = hooks.listEvents().map(event => ({
event,
handlerCount: hooks.handlerCount(event),
}));
res.json({ hooks: hooksList });
});
// GET /api/admin/plugins/:name/export — download plugin package
app.get('/api/admin/plugins/:name/export', ...adminOnly, (req, res) => {
const name = req.params.name;
const info = getPluginDirByName(name);
if (!info) return res.status(404).json({ error: 'Plugin não encontrado' });
const buffer = buildExportZip(info.fullPath, false);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${name}.zip"`);
res.send(buffer);
});
// GET /api/admin/plugins/:name/export-lite — lightweight package
app.get('/api/admin/plugins/:name/export-lite', ...adminOnly, (req, res) => {
const name = req.params.name;
const info = getPluginDirByName(name);
if (!info) return res.status(404).json({ error: 'Plugin não encontrado' });
const buffer = buildExportZip(info.fullPath, true);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${name}-lite.zip"`);
res.send(buffer);
});
// POST /api/admin/plugins/import — upload plugin ZIP
app.post('/api/admin/plugins/import', ...adminOnly, uploadZip.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' });
const result = await importPluginFromZip(app, io, req.file.buffer, false);
await clearPluginCaches();
res.json({ message: 'Plugin importado', name: result.name });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Erro ao importar plugin' });
}
});
// POST /api/admin/plugins/:name/reload — reload plugin
app.post('/api/admin/plugins/:name/reload', ...adminOnly, async (req, res) => {
const name = req.params.name;
const info = getPluginDirByName(name);
if (!info) return res.status(404).json({ error: 'Plugin não encontrado' });
try {
await unloadPlugin(app, io, name);
clearRequireCache(path.join(PLUGINS_SOURCE_DIR, info.dirName));
clearRequireCache(path.join(PLUGINS_COMPILED_DIR, info.dirName));
await loadPluginFromDir(app, io, info.dirName, info.manifest);
await clearPluginCaches();
res.json({ message: 'Plugin recarregado', name });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao recarregar plugin' });
}
});
// POST /api/admin/plugins/:name/update — update plugin via ZIP
app.post('/api/admin/plugins/:name/update', ...adminOnly, uploadZip.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' });
const result = await importPluginFromZip(app, io, req.file.buffer, true);
await clearPluginCaches();
res.json({ message: 'Plugin atualizado', name: result.name });
} catch (err: any) {
res.status(400).json({ error: err.message || 'Erro ao atualizar plugin' });
}
});
}