diff --git a/app/core/PluginManager.php b/app/core/PluginManager.php index dd8492e..93436f7 100644 --- a/app/core/PluginManager.php +++ b/app/core/PluginManager.php @@ -4,34 +4,189 @@ namespace App\Core; class PluginManager { + /** @var array */ + private static array $catalog = []; + + /** @var array> */ + private static array $loaded = []; + + /** @var array> */ + private static array $dependencyErrors = []; + /** * Loads all enabled plugins from the given directory. + * Enforces declared dependencies before bootstrapping each plugin. * * @param string $pluginsDir * @return array */ public static function load(string $pluginsDir): array { - $enabled = []; - foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginPath) { + self::$catalog = self::scanCatalog($pluginsDir); + self::$loaded = []; + self::$dependencyErrors = []; + + foreach (self::$catalog as $name => $info) { + if (empty($info['meta']['enabled'])) { + continue; + } + self::resolve($name); + } + + $GLOBALS['plugin_dependency_errors'] = self::$dependencyErrors; + + return self::$loaded; + } + + /** + * @param string $pluginsDir + * @return array + */ + private static function scanCatalog(string $pluginsDir): array + { + $catalog = []; + foreach (glob(rtrim($pluginsDir, '/'). '/*', GLOB_ONLYDIR) as $pluginPath) { $manifest = $pluginPath . '/plugin.json'; if (!file_exists($manifest)) { continue; } $meta = json_decode(file_get_contents($manifest), true); - if (empty($meta['enabled'])) { - continue; + if (!is_array($meta)) { + $meta = []; } $name = basename($pluginPath); - $enabled[$name] = [ + $catalog[$name] = [ 'path' => $pluginPath, 'meta' => $meta, ]; - $bootstrap = $pluginPath . '/bootstrap.php'; - if (file_exists($bootstrap)) { - include_once $bootstrap; + } + + return $catalog; + } + + /** + * Recursively resolves a plugin and its dependencies. + */ + private static function resolve(string $plugin, array $stack = []): bool + { + if (isset(self::$loaded[$plugin])) { + return true; + } + + if (!isset(self::$catalog[$plugin])) { + return false; + } + + if (in_array($plugin, $stack, true)) { + self::$dependencyErrors[$plugin][] = 'Circular dependency detected: ' . implode(' -> ', array_merge($stack, [$plugin])); + return false; + } + + $meta = self::$catalog[$plugin]['meta']; + if (empty($meta['enabled'])) { + return false; + } + + $dependencies = $meta['dependencies'] ?? []; + if (!is_array($dependencies)) { + $dependencies = [$dependencies]; + } + + $stack[] = $plugin; + foreach ($dependencies as $dependency) { + $dependency = trim((string)$dependency); + if ($dependency === '') { + continue; + } + if (!isset(self::$catalog[$dependency])) { + self::$dependencyErrors[$plugin][] = sprintf('Missing dependency "%s"', $dependency); + continue; + } + if (empty(self::$catalog[$dependency]['meta']['enabled'])) { + self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" is disabled', $dependency); + continue; + } + if (!self::resolve($dependency, $stack)) { + self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" failed to load', $dependency); } } - return $enabled; + array_pop($stack); + + if (!empty(self::$dependencyErrors[$plugin])) { + return false; + } + + $bootstrap = self::$catalog[$plugin]['path'] . '/bootstrap.php'; + if (file_exists($bootstrap)) { + include_once $bootstrap; + } + + self::$loaded[$plugin] = self::$catalog[$plugin]; + + return true; + } + + /** + * Returns the scanned plugin catalog (enabled and disabled). + * + * @return array + */ + public static function getCatalog(): array + { + return self::$catalog; + } + + /** + * Returns all plugins that successfully loaded (dependencies satisfied). + * + * @return array + */ + public static function getLoaded(): array + { + return self::$loaded; + } + + /** + * Returns dependency validation errors collected during load. + * + * @return array> + */ + public static function getDependencyErrors(): array + { + return self::$dependencyErrors; + } + + /** + * Persists a plugin's enabled flag back to its manifest. + */ + public static function setEnabled(string $plugin, bool $enabled): bool + { + if (!isset(self::$catalog[$plugin])) { + return false; + } + + $manifestPath = self::$catalog[$plugin]['path'] . '/plugin.json'; + if (!is_file($manifestPath) || !is_readable($manifestPath) || !is_writable($manifestPath)) { + return false; + } + + $raw = file_get_contents($manifestPath); + $data = json_decode($raw ?: '', true); + if (!is_array($data)) { + $data = self::$catalog[$plugin]['meta']; + } + + $data['enabled'] = $enabled; + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; + if (file_put_contents($manifestPath, $json, LOCK_EX) === false) { + return false; + } + + self::$catalog[$plugin]['meta'] = $data; + if (!$enabled && isset(self::$loaded[$plugin])) { + unset(self::$loaded[$plugin]); + } + + return true; } } diff --git a/app/pages/admin.php b/app/pages/admin.php index 895c040..047331c 100644 --- a/app/pages/admin.php +++ b/app/pages/admin.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/../core/Maintenance.php'; require_once __DIR__ . '/../core/MigrationRunner.php'; +require_once __DIR__ . '/../core/PluginManager.php'; require_once '../app/helpers/security.php'; include_once '../app/helpers/feedback.php'; @@ -19,11 +20,11 @@ if (!Session::isValidSession()) { exit; } +// Check if the user has admin permissions $canAdmin = false; if (isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) { $canAdmin = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser'); } - if (!$canAdmin) { Feedback::flash('SECURITY', 'PERMISSION_DENIED'); header('Location: ' . $app_root); @@ -38,8 +39,10 @@ $sectionRegistry = [ 'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'], 'maintenance' => ['label' => 'Maintenance', 'position' => 200, 'hook' => null, 'type' => 'core'], 'migrations' => ['label' => 'Migrations', 'position' => 300, 'hook' => null, 'type' => 'core'], + 'plugins' => ['label' => 'Plugins', 'position' => 400, 'hook' => null, 'type' => 'core'], ]; +// Register sections for plugins $registerSection = static function(array $section) use (&$sectionRegistry): void { $key = strtolower(trim($section['key'] ?? '')); $label = trim((string)($section['label'] ?? '')); @@ -108,7 +111,6 @@ foreach ($sectionRegistry as $key => $meta) { ]; } -// Hooks section for plugins $sectionStatePayload = \App\Core\HookDispatcher::applyFilters('admin.sections.state', [ 'sections' => $sectionRegistry, 'state' => [], @@ -121,6 +123,76 @@ if (is_array($sectionStatePayload)) { $sectionState = $sectionStatePayload['state'] ?? (is_array($sectionStatePayload) ? $sectionStatePayload : []); } +// Get plugin catalog and list of loaded plugins +// with their dependencies +$pluginCatalog = \App\Core\PluginManager::getCatalog(); +$pluginLoadedMap = \App\Core\PluginManager::getLoaded(); +$pluginDependencyErrors = \App\Core\PluginManager::getDependencyErrors(); + +$normalizeDependencies = static function ($meta): array { + $deps = $meta['dependencies'] ?? []; + if (!is_array($deps)) { + $deps = $deps === null || $deps === '' ? [] : [$deps]; + } + $deps = array_map('trim', $deps); + $deps = array_filter($deps, static function($dep) { + return $dep !== ''; + }); + return array_values(array_unique($deps)); +}; + +$pluginDependentsIndex = []; +foreach ($pluginCatalog as $slug => $info) { + $deps = $normalizeDependencies($info['meta'] ?? []); + foreach ($deps as $dep) { + $pluginDependentsIndex[$dep][] = $slug; + } +} + +// Build plugin admin map with details, state and dependencies +$pluginAdminMap = []; +foreach ($pluginCatalog as $slug => $info) { + $meta = $info['meta'] ?? []; + $name = trim((string)($meta['name'] ?? $slug)); + $enabled = !empty($meta['enabled']); + $dependencies = $normalizeDependencies($meta); + $dependents = array_values($pluginDependentsIndex[$slug] ?? []); + $enabledDependents = array_values(array_filter($dependents, static function($depSlug) use ($pluginCatalog) { + return !empty($pluginCatalog[$depSlug]['meta']['enabled']); + })); + $missingDependencies = array_values(array_filter($dependencies, static function($depSlug) use ($pluginCatalog) { + return !isset($pluginCatalog[$depSlug]) || empty($pluginCatalog[$depSlug]['meta']['enabled']); + })); + + $pluginAdminMap[$slug] = [ + 'slug' => $slug, + 'name' => $name, + 'version' => (string)($meta['version'] ?? ''), + 'description' => (string)($meta['description'] ?? ''), + 'enabled' => $enabled, + 'loaded' => isset($pluginLoadedMap[$slug]), + 'dependencies' => $dependencies, + 'dependents' => $dependents, + 'enabled_dependents' => $enabledDependents, + 'missing_dependencies' => $missingDependencies, + 'dependency_errors' => $pluginDependencyErrors[$slug] ?? [], + 'can_enable' => !$enabled && empty($missingDependencies), + 'can_disable' => $enabled && empty($enabledDependents), + ]; +} + +$pluginAdminList = array_values($pluginAdminMap); +usort($pluginAdminList, static function(array $a, array $b): int { + return strcmp(strtolower($a['name']), strtolower($b['name'])); +}); + +$sectionState['plugins'] = [ + 'plugins' => $pluginAdminList, + 'dependency_errors' => $pluginDependencyErrors, + 'plugin_index' => $pluginAdminMap, +]; + +// Prepare the DB migrations details $migrationsDir = __DIR__ . '/../../doc/database/migrations'; if ($postAction === 'read_migration') { @@ -193,6 +265,7 @@ if ($postAction !== '' && $postAction !== 'read_migration') { } try { + // Maintenance actions if ($postAction === 'maintenance_on') { $msg = trim($_POST['maintenance_message'] ?? ''); \App\Core\Maintenance::enable($msg); @@ -200,6 +273,7 @@ if ($postAction !== '' && $postAction !== 'read_migration') { } elseif ($postAction === 'maintenance_off') { \App\Core\Maintenance::disable(); Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode disabled.', true); + // DB migrations actions } elseif ($postAction === 'migrate_up') { $runner = new \App\Core\MigrationRunner($db, $migrationsDir); $applied = $runner->applyPendingMigrations(); @@ -227,6 +301,37 @@ if ($postAction !== '' && $postAction !== 'read_migration') { ]; $_SESSION['migration_modal_open'] = $applied[0]; } + // Plugin actions + } elseif ($postAction === 'plugin_enable' || $postAction === 'plugin_disable') { + $slug = strtolower(trim($_POST['plugin'] ?? '')); + if ($slug === '' || !isset($pluginAdminMap[$slug])) { + Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false); + } else { + $pluginMeta = $pluginAdminMap[$slug]; + if ($postAction === 'plugin_enable') { + if (!$pluginMeta['can_enable']) { + $reason = 'Resolve missing dependencies before enabling this plugin.'; + if (!empty($pluginMeta['missing_dependencies'])) { + $reason = 'Enable required plugins first: ' . implode(', ', $pluginMeta['missing_dependencies']); + } + Feedback::flash('ERROR', 'DEFAULT', $reason, false); + } elseif (!\App\Core\PluginManager::setEnabled($slug, true)) { + Feedback::flash('ERROR', 'DEFAULT', 'Failed to enable plugin. Check file permissions on plugin.json.', false); + } else { + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled. Reload admin to finish loading it.', $pluginMeta['name']), true); + } + } else { + if (!$pluginMeta['can_disable']) { + $reason = 'Disable dependent plugins first: ' . implode(', ', $pluginMeta['enabled_dependents']); + Feedback::flash('ERROR', 'DEFAULT', $reason, false); + } elseif (!\App\Core\PluginManager::setEnabled($slug, false)) { + Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable plugin. Check file permissions on plugin.json.', false); + } else { + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" disabled.', $pluginMeta['name']), true); + } + } + } + // Test migrations actions } elseif ($postAction === 'create_test_migration') { $timestamp = date('Ymd_His'); $filename = $timestamp . '_test_migration.sql'; diff --git a/app/templates/admin.php b/app/templates/admin.php index a974f24..09eadf3 100644 --- a/app/templates/admin.php +++ b/app/templates/admin.php @@ -343,6 +343,143 @@ if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) { + + +
+
+
+
+

Plugin overview

+

Enable or disable functionality and review dependency health.

+
+
+ / enabled +
+
+ +
+ Dependency issues detected. Resolve the following before enabling affected plugins: +
    + $errors): + if (empty($errors)) { + continue; + } + ?> +
  • :
  • + +
+
+ + +

No plugins detected in the plugins directory.

+ +
+ + + + + + + + + + + Enabled' + : 'Disabled'; + if ($plugin['enabled'] && empty($depErrors) && $plugin['loaded']) { + $statusBadges[] = 'Loaded'; + } + if (!empty($missingDeps) || !empty($depErrors)) { + $statusBadges[] = 'Issues'; + } + ?> + + + + + + + + +
PluginStatusDepends onActions
+ + + v + + +

+ +
+ + +

+ +
+ +
    + OK' + : 'Off'; + if (!empty($depMeta['dependency_errors'])) { + $depStatusBadge = 'Error'; + } + } elseif (in_array($dep, $missingDeps, true)) { + $depStatusBadge = 'Missing'; + } + ?> +
  • + + + () + +
  • + +
+ + - + +
+
+ + + + + + + + + + +
+
+
+ +
+