720 lines
30 KiB
TypeScript
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' });
|
|
}
|
|
});
|
|
}
|