diff --git a/app/core/PluginManager.php b/app/core/PluginManager.php index 93436f7..588b101 100644 --- a/app/core/PluginManager.php +++ b/app/core/PluginManager.php @@ -27,7 +27,7 @@ class PluginManager self::$dependencyErrors = []; foreach (self::$catalog as $name => $info) { - if (empty($info['meta']['enabled'])) { + if (!self::isEnabled($name)) { continue; } self::resolve($name); @@ -83,7 +83,7 @@ class PluginManager } $meta = self::$catalog[$plugin]['meta']; - if (empty($meta['enabled'])) { + if (!self::isEnabled($plugin)) { return false; } @@ -102,7 +102,7 @@ class PluginManager self::$dependencyErrors[$plugin][] = sprintf('Missing dependency "%s"', $dependency); continue; } - if (empty(self::$catalog[$dependency]['meta']['enabled'])) { + if (!self::isEnabled($dependency)) { self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" is disabled', $dependency); continue; } @@ -157,7 +157,8 @@ class PluginManager } /** - * Persists a plugin's enabled flag back to its manifest. + * Persists a plugin's enabled flag to the database settings table. + * Note: This method no longer requires write access to plugin.json files. */ public static function setEnabled(string $plugin, bool $enabled): bool { @@ -165,28 +166,139 @@ class PluginManager return false; } - $manifestPath = self::$catalog[$plugin]['path'] . '/plugin.json'; - if (!is_file($manifestPath) || !is_readable($manifestPath) || !is_writable($manifestPath)) { + global $db; + if (!$db instanceof PDO) { return false; } - $raw = file_get_contents($manifestPath); - $data = json_decode($raw ?: '', true); - if (!is_array($data)) { - $data = self::$catalog[$plugin]['meta']; - } + try { + // Update or insert plugin setting in database + $stmt = $db->prepare( + 'INSERT INTO settings (`key`, `value`, updated_at) + VALUES (:key, :value, NOW()) + ON DUPLICATE KEY UPDATE `value` = :value, updated_at = NOW()' + ); + $key = 'plugin_enabled_' . $plugin; + $value = $enabled ? '1' : '0'; + $stmt->execute([':key' => $key, ':value' => $value]); - $data['enabled'] = $enabled; - $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; - if (file_put_contents($manifestPath, $json, LOCK_EX) === false) { + // Clear loaded cache if disabling + if (!$enabled && isset(self::$loaded[$plugin])) { + unset(self::$loaded[$plugin]); + } + + return true; + } catch (PDOException $e) { + // Log the actual error for debugging + error_log('PluginManager::setEnabled failed for ' . $plugin . ': ' . $e->getMessage()); + return false; + } + } + + /** + * Check if a plugin is enabled from database settings. + */ + public static function isEnabled(string $plugin): bool + { + if (!isset(self::$catalog[$plugin])) { return false; } - self::$catalog[$plugin]['meta'] = $data; - if (!$enabled && isset(self::$loaded[$plugin])) { - unset(self::$loaded[$plugin]); + global $db; + if ($db instanceof PDO) { + try { + $stmt = $db->prepare('SELECT `value` FROM settings WHERE `key` = :key LIMIT 1'); + $key = 'plugin_enabled_' . $plugin; + $stmt->execute([':key' => $key]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result !== false) { + return $result['value'] === '1'; + } + } catch (PDOException $e) { + // Log error but return false + error_log('PluginManager::isEnabled database error for ' . $plugin . ': ' . $e->getMessage()); + } + } + + // Default to disabled if no database entry or database unavailable + return false; + } + + /** + * Install plugin by running its migrations. + */ + public static function install(string $plugin): bool + { + if (!isset(self::$catalog[$plugin])) { + return false; } - return true; + $pluginPath = self::$catalog[$plugin]['path']; + $bootstrapPath = $pluginPath . '/bootstrap.php'; + + if (!file_exists($bootstrapPath)) { + return false; + } + + try { + // Include bootstrap to run migrations + include_once $bootstrapPath; + + // Look for migration function + $migrationFunction = str_replace('-', '_', $plugin) . '_ensure_tables'; + if (function_exists($migrationFunction)) { + $migrationFunction(); + return true; + } + + return false; + } catch (Throwable $e) { + return false; + } + } + + /** + * Purge plugin by dropping its tables and removing settings. + */ + public static function purge(string $plugin): bool + { + if (!isset(self::$catalog[$plugin])) { + return false; + } + + global $db; + if (!$db instanceof PDO) { + return false; + } + + try { + // First disable the plugin + self::setEnabled($plugin, false); + + // Remove plugin settings + $stmt = $db->prepare('DELETE FROM settings WHERE `key` LIKE :pattern'); + $stmt->execute([':pattern' => 'plugin_enabled_' . $plugin]); + + // Drop plugin-specific tables (user_pro_* tables for this plugin) + $stmt = $db->prepare('SHOW TABLES LIKE "user_pro_%"'); + $stmt->execute(); + $tables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + + foreach ($tables as $table) { + // Check if this table belongs to the plugin by checking its migration file + $migrationFile = self::$catalog[$plugin]['path'] . '/migrations/create_' . $plugin . '_tables.sql'; + if (file_exists($migrationFile)) { + $migrationContent = file_get_contents($migrationFile); + if (strpos($migrationContent, $table) !== false) { + $db->exec("DROP TABLE IF EXISTS `$table`"); + } + } + } + + return true; + } catch (Throwable $e) { + return false; + } } } diff --git a/app/pages/admin.php b/app/pages/admin.php index 047331c..7e9824b 100644 --- a/app/pages/admin.php +++ b/app/pages/admin.php @@ -154,21 +154,42 @@ $pluginAdminMap = []; foreach ($pluginCatalog as $slug => $info) { $meta = $info['meta'] ?? []; $name = trim((string)($meta['name'] ?? $slug)); - $enabled = !empty($meta['enabled']); + $enabled = \App\Core\PluginManager::isEnabled($slug); // Use database setting $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']); + $enabledDependents = array_values(array_filter($dependents, static function($depSlug) { + return \App\Core\PluginManager::isEnabled($depSlug); // Use database setting })); $missingDependencies = array_values(array_filter($dependencies, static function($depSlug) use ($pluginCatalog) { - return !isset($pluginCatalog[$depSlug]) || empty($pluginCatalog[$depSlug]['meta']['enabled']); + return !isset($pluginCatalog[$depSlug]) || !\App\Core\PluginManager::isEnabled($depSlug); // Use database setting })); + // Check for migration files and existing tables + $migrationFiles = glob($info['path'] . '/migrations/*.sql'); + $hasMigration = !empty($migrationFiles); + $existingTables = []; + + if ($hasMigration && isset($db) && $db instanceof PDO) { + $stmt = $db->query("SHOW TABLES"); + $allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + + foreach ($migrationFiles as $migrationFile) { + $migrationContent = file_get_contents($migrationFile); + foreach ($allTables as $table) { + if (strpos($migrationContent, $table) !== false) { + $existingTables[] = $table; + } + } + } + $existingTables = array_unique($existingTables); + } + $pluginAdminMap[$slug] = [ 'slug' => $slug, 'name' => $name, 'version' => (string)($meta['version'] ?? ''), 'description' => (string)($meta['description'] ?? ''), + 'path' => $info['path'], 'enabled' => $enabled, 'loaded' => isset($pluginLoadedMap[$slug]), 'dependencies' => $dependencies, @@ -178,6 +199,9 @@ foreach ($pluginCatalog as $slug => $info) { 'dependency_errors' => $pluginDependencyErrors[$slug] ?? [], 'can_enable' => !$enabled && empty($missingDependencies), 'can_disable' => $enabled && empty($enabledDependents), + 'has_migration' => $hasMigration, + 'existing_tables' => $existingTables, + 'can_install' => $hasMigration && empty($existingTables), ]; } @@ -316,21 +340,98 @@ if ($postAction !== '' && $postAction !== 'read_migration') { } 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); + Feedback::flash('ERROR', 'DEFAULT', 'Failed to enable plugin. Check database connection and error logs.', false); } else { - Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled. Reload admin to finish loading it.', $pluginMeta['name']), true); + // Automatically install plugin tables when enabling + $installResult = \App\Core\PluginManager::install($slug); + if ($installResult) { + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled and installed successfully.', $pluginMeta['name']), true); + } else { + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled but installation failed. Check migration files.', $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); + Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable plugin. Check database connection and error logs.', false); } else { Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" disabled.', $pluginMeta['name']), true); } } } + // Plugin install action + } elseif ($postAction === 'plugin_install') { + $slug = strtolower(trim($_POST['plugin'] ?? '')); + if ($slug === '' || !isset($pluginAdminMap[$slug])) { + Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false); + } else { + if (\App\Core\PluginManager::install($slug)) { + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" installed successfully.', $pluginAdminMap[$slug]['name']), true); + } else { + Feedback::flash('ERROR', 'DEFAULT', 'Plugin installation failed. Check migration files.', false); + } + } + // Plugin purge action + } elseif ($postAction === 'plugin_purge') { + $slug = strtolower(trim($_POST['plugin'] ?? '')); + if ($slug === '' || !isset($pluginAdminMap[$slug])) { + Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false); + } else { + if (\App\Core\PluginManager::purge($slug)) { + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" purged successfully. All data and tables removed.', $pluginAdminMap[$slug]['name']), true); + } else { + Feedback::flash('ERROR', 'DEFAULT', 'Plugin purge failed. Check database permissions.', false); + } + } + // Plugin check action + } elseif ($postAction === 'plugin_check') { + $slug = strtolower(trim($_POST['plugin'] ?? '')); + if ($slug === '' || !isset($pluginAdminMap[$slug])) { + Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false); + } else { + // Redirect to plugin check page + header('Location: ' . $app_root . '?page=admin§ion=plugins&action=plugin_check_page&plugin=' . urlencode($slug)); + exit; + } + // Plugin migration test actions + } elseif ($postAction === 'test_plugin_migrations') { + $slug = strtolower(trim($_POST['plugin'] ?? '')); + if ($slug === '' || !isset($pluginAdminMap[$slug])) { + Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false); + } else { + try { + $pluginPath = $pluginAdminMap[$slug]['path']; + $bootstrapPath = $pluginPath . '/bootstrap.php'; + + if (!file_exists($bootstrapPath)) { + Feedback::flash('ERROR', 'DEFAULT', 'Plugin has no bootstrap file.', false); + } else { + // Load plugin bootstrap in isolation to test migrations + $migrationFunctions = []; + $bootstrapContent = file_get_contents($bootstrapPath); + + // Check for migration functions + if (strpos($bootstrapContent, '_ensure_tables') !== false) { + // Temporarily include bootstrap to test migrations + include_once $bootstrapPath; + + $migrationFunctionName = str_replace('-', '_', $slug) . '_ensure_tables'; + if (function_exists($migrationFunctionName)) { + $migrationFunctionName(); + Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" migrations executed successfully.', $pluginAdminMap[$slug]['name']), true); + } else { + Feedback::flash('ERROR', 'DEFAULT', 'Plugin migration function not found.', false); + } + } else { + Feedback::flash('ERROR', 'DEFAULT', 'Plugin has no migration function.', false); + } + } + } catch (Throwable $e) { + Feedback::flash('ERROR', 'DEFAULT', 'Migration test failed: ' . $e->getMessage(), false); + } + } // Test migrations actions } elseif ($postAction === 'create_test_migration') { $timestamp = date('Ymd_His'); @@ -432,6 +533,139 @@ try { $migration_error = $e->getMessage(); } +// Generate CSRF token early for all templates +$csrf_token = $security->generateCsrfToken(); + +// Handle plugin check page +if ($queryAction === 'plugin_check_page' && isset($_GET['plugin'])) { + // Simple test for JSON response + if (isset($_GET['test'])) { + header('Content-Type: application/json'); + echo json_encode(['test' => 'working', 'timestamp' => time()]); + exit; + } + + // Debug: Log request details + error_log('Plugin check request: ' . print_r([ + 'action' => $queryAction, + 'plugin' => $_GET['plugin'], + 'ajax' => isset($_SERVER['HTTP_X_REQUESTED_WITH']), + 'ajax_header' => $_SERVER['HTTP_X_REQUESTED_WITH'] ?? 'not set', + 'content_type' => $_SERVER['CONTENT_TYPE'] ?? 'not set', + 'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'not set' + ], true)); + + // Start output buffering to catch any unwanted output + ob_start(); + + // Disable error display for JSON responses + $originalErrorReporting = error_reporting(); + $originalDisplayErrors = ini_get('display_errors'); + + $isAjax = (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && + strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') || + isset($_GET['ajax']); + + if ($isAjax) { + error_reporting(0); + ini_set('display_errors', 0); + } + + $pluginSlug = strtolower(trim($_GET['plugin'])); + if (!isset($pluginAdminMap[$pluginSlug])) { + if ($isAjax) { + ob_end_clean(); // Clear and end output buffer + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Unknown plugin specified.']); + exit; + } + Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false); + header('Location: ' . $app_root . '?page=admin§ion=plugins'); + exit; + } + + $pluginInfo = $pluginAdminMap[$pluginSlug]; + $checkResults = []; + + try { + // Check plugin files exist + $migrationFiles = glob($pluginInfo['path'] . '/migrations/*.sql'); + $hasMigration = !empty($migrationFiles); + + $checkResults['files'] = [ + 'manifest' => file_exists($pluginInfo['path'] . '/plugin.json'), + 'bootstrap' => file_exists($pluginInfo['path'] . '/bootstrap.php'), + 'migration' => $hasMigration, + ]; + + // Check database tables + global $db; + $pluginTables = []; + if ($db instanceof PDO) { + $stmt = $db->query("SHOW TABLES"); + $allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + + if ($hasMigration) { + // Check each migration file for table references + foreach ($migrationFiles as $migrationFile) { + $migrationContent = file_get_contents($migrationFile); + foreach ($allTables as $table) { + if (strpos($migrationContent, $table) !== false) { + $pluginTables[] = $table; + } + } + } + $pluginTables = array_unique($pluginTables); + } + } + $checkResults['tables'] = $pluginTables; + + // Check plugin functions + $bootstrapPath = $pluginInfo['path'] . '/bootstrap.php'; + if (file_exists($bootstrapPath)) { + include_once $bootstrapPath; + $migrationFunction = str_replace('-', '_', $pluginSlug) . '_ensure_tables'; + $checkResults['functions'] = [ + 'migration' => function_exists($migrationFunction), + ]; + } + + } catch (Throwable $e) { + $checkResults['error'] = $e->getMessage(); + } + + // Handle AJAX request + if ($isAjax) { + // Restore error reporting + error_reporting($originalErrorReporting); + ini_set('display_errors', $originalDisplayErrors); + + ob_end_clean(); // Clear and end output buffer + header('Content-Type: application/json'); + header('Cache-Control: no-cache, must-revalidate'); + + $jsonData = json_encode([ + 'success' => true, + 'pluginInfo' => $pluginInfo, + 'checkResults' => $checkResults, + 'csrf_token' => $csrf_token, + 'app_root' => $app_root + ]); + + error_log('JSON response: ' . $jsonData); + echo $jsonData; + exit; + } + + // Restore error reporting for non-AJAX requests + error_reporting($originalErrorReporting); + ini_set('display_errors', $originalDisplayErrors); + + // Include check page template for non-AJAX requests + include '../app/templates/admin_plugin_check.php'; + exit; +} + $overviewPillsPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.pills', [ 'pills' => [], 'sections' => $sectionRegistry, @@ -456,6 +690,7 @@ if (is_array($overviewStatusesPayload)) { $adminOverviewStatuses = $overviewStatusesPayload['statuses'] ?? (is_array($overviewStatusesPayload) ? $overviewStatusesPayload : []); } -$csrf_token = $security->generateCsrfToken(); +// Get any new feedback messages +include_once '../app/helpers/feedback.php'; include '../app/templates/admin.php'; diff --git a/app/templates/admin.php b/app/templates/admin.php index 6dbdfbb..57b3123 100644 --- a/app/templates/admin.php +++ b/app/templates/admin.php @@ -22,6 +22,18 @@ if (!empty($modal_to_open)) { $preselectModalId = 'migrationModal' . md5($modal_to_open); } +?> + + + -
- - - +
- - + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
- - +
+ + + + + + + + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + - + + + + + +
@@ -564,8 +645,183 @@ endif; ?> + + + + file_exists($plugin['path'] . '/plugin.json'), + 'bootstrap' => file_exists($plugin['path'] . '/bootstrap.php'), + 'migration' => file_exists($plugin['path'] . '/migrations/create_' . $plugin['slug'] . '_tables.sql'), + ]; + + // Check database tables + global $db; + if ($db instanceof PDO) { + $stmt = $db->query("SHOW TABLES LIKE 'user_pro_%'"); + $allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0); + + $migrationFile = $plugin['path'] . '/migrations/create_' . $plugin['slug'] . '_tables.sql'; + if (file_exists($migrationFile)) { + $migrationContent = file_get_contents($migrationFile); + $pluginTables = []; + foreach ($allTables as $table) { + if (strpos($migrationContent, $table) !== false) { + $pluginTables[] = $table; + } + } + $checkResults['tables'] = $pluginTables; + } + } + + // Check plugin functions + $bootstrapPath = $plugin['path'] . '/bootstrap.php'; + if (file_exists($bootstrapPath)) { + include_once $bootstrapPath; + $migrationFunction = str_replace('-', '_', $plugin['slug']) . '_ensure_tables'; + $checkResults['functions'] = [ + 'migration' => function_exists($migrationFunction), + ]; + } + + } catch (Throwable $e) { + $checkResults['error'] = $e->getMessage(); + } + ?> + + + + +
+ + + +
+