Adds plugin management section, part of the admin page

main
Yasen Pramatarov 2025-12-23 18:39:07 +02:00
parent 1ca0515ee1
commit 2a97539093
3 changed files with 408 additions and 11 deletions

View File

@ -4,34 +4,189 @@ namespace App\Core;
class PluginManager
{
/** @var array<string, array{path: string, meta: array}> */
private static array $catalog = [];
/** @var array<string, array{path: string, meta: array}>> */
private static array $loaded = [];
/** @var array<string, array<int, string>> */
private static array $dependencyErrors = [];
/**
* Loads all enabled plugins from the given directory.
* Enforces declared dependencies before bootstrapping each plugin.
*
* @param string $pluginsDir
* @return array<string, array{path: string, meta: 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<string, array{path: string, meta: 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';
}
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);
}
}
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;
}
return $enabled;
/**
* Returns the scanned plugin catalog (enabled and disabled).
*
* @return array<string, array{path: string, meta: array}>
*/
public static function getCatalog(): array
{
return self::$catalog;
}
/**
* Returns all plugins that successfully loaded (dependencies satisfied).
*
* @return array<string, array{path: string, meta: array}>
*/
public static function getLoaded(): array
{
return self::$loaded;
}
/**
* Returns dependency validation errors collected during load.
*
* @return array<string, array<int, string>>
*/
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;
}
}

View File

@ -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';

View File

@ -343,6 +343,143 @@ if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) {
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'plugins'): ?>
<?php
$pluginsState = $sectionState['plugins'] ?? [];
$pluginsList = $pluginsState['plugins'] ?? [];
$dependencyErrors = $pluginsState['dependency_errors'] ?? [];
$totalPlugins = count($pluginsList);
$enabledPlugins = count(array_filter($pluginsList, static function($plugin) {
return !empty($plugin['enabled']);
}));
$issuesPlugins = count(array_filter($pluginsList, static function($plugin) {
return !empty($plugin['dependency_errors']) || !$plugin['loaded'];
}));
?>
<div class="tm-admin-grid">
<article class="tm-admin-card">
<header>
<div>
<h2 class="tm-admin-card-title">Plugin overview</h2>
<p class="tm-admin-card-subtitle">Enable or disable functionality and review dependency health.</p>
</div>
<div class="tm-hero-pill pill-primary">
<?= htmlspecialchars($enabledPlugins) ?> / <?= htmlspecialchars($totalPlugins) ?> enabled
</div>
</header>
<?php if (!empty($dependencyErrors)): ?>
<div class="alert alert-warning">
<strong>Dependency issues detected.</strong> Resolve the following before enabling affected plugins:
<ul class="mb-0 mt-2">
<?php foreach ($dependencyErrors as $slug => $errors):
if (empty($errors)) {
continue;
}
?>
<li><strong><?= htmlspecialchars($slug) ?>:</strong> <?= htmlspecialchars(implode('; ', $errors)) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (empty($pluginsList)): ?>
<p class="tm-admin-empty mb-0">No plugins detected in the plugins directory.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover tm-admin-table">
<thead>
<tr>
<th>Plugin</th>
<th>Status</th>
<th>Depends on</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<?php
$pluginIndex = $pluginsState['plugin_index'] ?? [];
foreach ($pluginsList as $plugin):
$missingDeps = $plugin['missing_dependencies'] ?? [];
$depErrors = $plugin['dependency_errors'] ?? [];
$dependents = $plugin['dependents'] ?? [];
$enabledDependents = $plugin['enabled_dependents'] ?? [];
$statusBadges = [];
$statusBadges[] = $plugin['enabled']
? '<span class="badge text-uppercase" style="background-color:#198754;color:#fff;">Enabled</span>'
: '<span class="badge text-uppercase" style="background-color:#6c757d;color:#fff;">Disabled</span>';
if ($plugin['enabled'] && empty($depErrors) && $plugin['loaded']) {
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#0dcaf0;color:#052c65;">Loaded</span>';
}
if (!empty($missingDeps) || !empty($depErrors)) {
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#ffc107;color:#212529;">Issues</span>';
}
?>
<tr>
<td>
<strong><?= htmlspecialchars($plugin['name']) ?></strong>
<?php if (!empty($plugin['version'])): ?>
<span class="text-muted">v<?= htmlspecialchars($plugin['version']) ?></span>
<?php endif; ?>
<?php if (!empty($plugin['description'])): ?>
<p class="tm-admin-muted mb-0"><?= htmlspecialchars($plugin['description']) ?></p>
<?php endif; ?>
</td>
<td>
<?= implode(' ', $statusBadges) ?>
<?php if (!empty($depErrors)): ?>
<p class="tm-admin-muted text-warning mb-0"><?= htmlspecialchars(implode(' ', $depErrors)) ?></p>
<?php endif; ?>
</td>
<td>
<?php if (!empty($plugin['dependencies'])): ?>
<ul class="tm-admin-inline-list">
<?php foreach ($plugin['dependencies'] as $dep):
$depMeta = $pluginIndex[$dep] ?? null;
$depStatusBadge = '';
if ($depMeta) {
$depStatusBadge = $depMeta['enabled']
? '<span class="badge" style="background-color:#198754;color:#fff;">OK</span>'
: '<span class="badge" style="background-color:#ffc107;color:#212529;">Off</span>';
if (!empty($depMeta['dependency_errors'])) {
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Error</span>';
}
} elseif (in_array($dep, $missingDeps, true)) {
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Missing</span>';
}
?>
<li>
<?= htmlspecialchars($dep) ?>
<?php if ($depStatusBadge !== ''): ?>
<span class="tm-admin-dep-status">(<?= $depStatusBadge ?>)</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-right">
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="section" value="plugins">
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
<?php if ($plugin['enabled']): ?>
<input type="hidden" name="action" value="plugin_disable">
<button type="submit" class="btn btn-sm btn-outline-danger" <?= $plugin['can_disable'] ? '' : 'disabled' ?>>Disable</button>
<?php else: ?>
<input type="hidden" name="action" value="plugin_enable">
<button type="submit" class="btn btn-sm btn-outline-success" <?= $plugin['can_enable'] ? '' : 'disabled' ?>>Enable</button>
<?php endif; ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</article>
</div>
<?php elseif (!empty($tabMeta['hook'])): ?>
<?php
do_hook($tabMeta['hook'], [