2025-12-23 14:47:37 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Admin control center
|
|
|
|
|
*
|
|
|
|
|
* Provides maintenance/migration tooling and exposes hook placeholders
|
|
|
|
|
* so plugins can contribute additional sections, actions, and metrics.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
require_once __DIR__ . '/../core/Maintenance.php';
|
|
|
|
|
require_once __DIR__ . '/../core/MigrationRunner.php';
|
2025-12-23 16:39:07 +00:00
|
|
|
require_once __DIR__ . '/../core/PluginManager.php';
|
2025-12-23 14:47:37 +00:00
|
|
|
require_once '../app/helpers/security.php';
|
|
|
|
|
include_once '../app/helpers/feedback.php';
|
|
|
|
|
|
|
|
|
|
$security = SecurityHelper::getInstance();
|
|
|
|
|
|
|
|
|
|
if (!Session::isValidSession()) {
|
|
|
|
|
header('Location: ' . $app_root . '?page=login');
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 16:39:07 +00:00
|
|
|
// Check if the user has admin permissions
|
2025-12-23 14:47:37 +00:00
|
|
|
$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);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$postAction = $_POST['action'] ?? '';
|
|
|
|
|
$queryAction = $_GET['action'] ?? '';
|
|
|
|
|
$action = $postAction ?: $queryAction;
|
|
|
|
|
$targetId = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : null;
|
|
|
|
|
$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'],
|
2025-12-23 16:39:07 +00:00
|
|
|
'plugins' => ['label' => 'Plugins', 'position' => 400, 'hook' => null, 'type' => 'core'],
|
2025-12-23 14:47:37 +00:00
|
|
|
];
|
|
|
|
|
|
2025-12-23 16:39:07 +00:00
|
|
|
// Register sections for plugins
|
2025-12-23 14:47:37 +00:00
|
|
|
$registerSection = static function(array $section) use (&$sectionRegistry): void {
|
|
|
|
|
$key = strtolower(trim($section['key'] ?? ''));
|
|
|
|
|
$label = trim((string)($section['label'] ?? ''));
|
|
|
|
|
if ($key === '' || $label === '') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$position = (int)($section['position'] ?? 900);
|
|
|
|
|
$sectionRegistry[$key] = [
|
|
|
|
|
'label' => $label,
|
|
|
|
|
'position' => $position,
|
|
|
|
|
'hook' => $section['hook'] ?? ('admin.' . $key . '.render'),
|
|
|
|
|
'type' => $section['type'] ?? 'plugin',
|
|
|
|
|
];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Hooks sections for plugins
|
|
|
|
|
do_hook('admin.sections.register', [
|
|
|
|
|
'register' => $registerSection,
|
|
|
|
|
'app_root' => $app_root,
|
|
|
|
|
'sections' => &$sectionRegistry,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
uasort($sectionRegistry, static function(array $a, array $b): int {
|
|
|
|
|
if ($a['position'] === $b['position']) {
|
|
|
|
|
return strcmp($a['label'], $b['label']);
|
|
|
|
|
}
|
|
|
|
|
return $a['position'] <=> $b['position'];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (empty($sectionRegistry)) {
|
|
|
|
|
$sectionRegistry = [
|
|
|
|
|
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$validSections = array_keys($sectionRegistry);
|
|
|
|
|
|
|
|
|
|
$buildAdminUrl = static function(string $section = 'overview') use ($app_root, &$sectionRegistry): string {
|
|
|
|
|
if (!isset($sectionRegistry[$section])) {
|
|
|
|
|
$section = array_key_first($sectionRegistry) ?? 'overview';
|
|
|
|
|
}
|
|
|
|
|
$suffix = $section !== 'overview' ? ('§ion=' . urlencode($section)) : '';
|
|
|
|
|
return $app_root . '?page=admin' . $suffix;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$sectionUrls = [];
|
|
|
|
|
foreach (array_keys($sectionRegistry) as $sectionKey) {
|
|
|
|
|
$sectionUrls[$sectionKey] = $buildAdminUrl($sectionKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$requestedSection = strtolower(trim($_GET['section'] ?? 'overview'));
|
|
|
|
|
if (!isset($sectionRegistry[$requestedSection])) {
|
|
|
|
|
$requestedSection = array_key_first($sectionRegistry) ?? 'overview';
|
|
|
|
|
}
|
|
|
|
|
$activeSection = $requestedSection;
|
|
|
|
|
|
|
|
|
|
$adminTabs = [];
|
|
|
|
|
foreach ($sectionRegistry as $key => $meta) {
|
|
|
|
|
$adminTabs[$key] = [
|
|
|
|
|
'label' => $meta['label'],
|
|
|
|
|
'url' => $sectionUrls[$key],
|
|
|
|
|
'hook' => $meta['hook'],
|
|
|
|
|
'type' => $meta['type'],
|
|
|
|
|
'position' => $meta['position'],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$sectionStatePayload = \App\Core\HookDispatcher::applyFilters('admin.sections.state', [
|
|
|
|
|
'sections' => $sectionRegistry,
|
|
|
|
|
'state' => [],
|
|
|
|
|
'db' => $db ?? null,
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
'app_root' => $app_root,
|
|
|
|
|
]);
|
|
|
|
|
$sectionState = [];
|
|
|
|
|
if (is_array($sectionStatePayload)) {
|
|
|
|
|
$sectionState = $sectionStatePayload['state'] ?? (is_array($sectionStatePayload) ? $sectionStatePayload : []);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 16:39:07 +00:00
|
|
|
// 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));
|
2026-01-19 09:31:34 +00:00
|
|
|
$enabled = \App\Core\PluginManager::isEnabled($slug); // Use database setting
|
2025-12-23 16:39:07 +00:00
|
|
|
$dependencies = $normalizeDependencies($meta);
|
|
|
|
|
$dependents = array_values($pluginDependentsIndex[$slug] ?? []);
|
2026-01-19 09:31:34 +00:00
|
|
|
$enabledDependents = array_values(array_filter($dependents, static function($depSlug) {
|
|
|
|
|
return \App\Core\PluginManager::isEnabled($depSlug); // Use database setting
|
2025-12-23 16:39:07 +00:00
|
|
|
}));
|
|
|
|
|
$missingDependencies = array_values(array_filter($dependencies, static function($depSlug) use ($pluginCatalog) {
|
2026-01-19 09:31:34 +00:00
|
|
|
return !isset($pluginCatalog[$depSlug]) || !\App\Core\PluginManager::isEnabled($depSlug); // Use database setting
|
2025-12-23 16:39:07 +00:00
|
|
|
}));
|
|
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Check for migration files and existing tables
|
|
|
|
|
$migrationFiles = glob($info['path'] . '/migrations/*.sql');
|
|
|
|
|
$hasMigration = !empty($migrationFiles);
|
|
|
|
|
$existingTables = [];
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 10:08:17 +00:00
|
|
|
if ($hasMigration) {
|
|
|
|
|
$db = \App\App::db();
|
|
|
|
|
if ($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;
|
|
|
|
|
}
|
2026-01-19 09:31:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-19 10:08:17 +00:00
|
|
|
$existingTables = array_unique($existingTables);
|
2026-01-19 09:31:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 16:39:07 +00:00
|
|
|
$pluginAdminMap[$slug] = [
|
|
|
|
|
'slug' => $slug,
|
|
|
|
|
'name' => $name,
|
|
|
|
|
'version' => (string)($meta['version'] ?? ''),
|
|
|
|
|
'description' => (string)($meta['description'] ?? ''),
|
2026-01-19 09:31:34 +00:00
|
|
|
'path' => $info['path'],
|
2025-12-23 16:39:07 +00:00
|
|
|
'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),
|
2026-01-19 09:31:34 +00:00
|
|
|
'has_migration' => $hasMigration,
|
|
|
|
|
'existing_tables' => $existingTables,
|
|
|
|
|
'can_install' => $hasMigration && empty($existingTables),
|
2025-12-23 16:39:07 +00:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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
|
2025-12-23 14:47:37 +00:00
|
|
|
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
|
|
|
|
|
|
|
|
|
|
if ($postAction === 'read_migration') {
|
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
|
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
|
|
|
$csrfToken = $_POST['csrf_token'] ?? $csrfHeader;
|
|
|
|
|
if (!$security->verifyCsrfToken($csrfToken)) {
|
|
|
|
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$canAdmin) {
|
|
|
|
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$filename = basename($_POST['filename'] ?? '');
|
|
|
|
|
if ($filename === '' || !preg_match('/^[A-Za-z0-9_\-]+\.sql$/', $filename)) {
|
|
|
|
|
echo json_encode(['success' => false, 'error' => 'Invalid filename']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$path = realpath($migrationsDir . '/' . $filename);
|
|
|
|
|
if ($path === false || strpos($path, realpath($migrationsDir)) !== 0) {
|
|
|
|
|
echo json_encode(['success' => false, 'error' => 'File not found']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$content = @file_get_contents($path);
|
|
|
|
|
if ($content === false) {
|
|
|
|
|
echo json_encode(['success' => false, 'error' => 'Could not read file']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
echo json_encode(['success' => true, 'name' => $filename, 'content' => $content]);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hooks actions for plugins
|
|
|
|
|
if ($action !== '' && $action !== 'read_migration') {
|
|
|
|
|
$customActionPayload = \App\Core\HookDispatcher::applyFilters('admin.actions.handle', [
|
|
|
|
|
'handled' => false,
|
|
|
|
|
'action' => $action,
|
|
|
|
|
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
|
|
|
|
|
'request' => $_REQUEST,
|
|
|
|
|
'security' => $security,
|
|
|
|
|
'app_root' => $app_root,
|
|
|
|
|
'build_admin_url' => $buildAdminUrl,
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
'db' => $db ?? null,
|
|
|
|
|
'target_id' => $targetId,
|
|
|
|
|
'section_state' => $sectionState,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (!empty($customActionPayload['handled'])) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($postAction !== '' && $postAction !== 'read_migration') {
|
|
|
|
|
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
|
|
|
|
|
Feedback::flash('SECURITY', 'CSRF_INVALID');
|
|
|
|
|
header('Location: ' . $buildAdminUrl($activeSection));
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$postSection = strtolower(trim($_POST['section'] ?? $activeSection));
|
|
|
|
|
if (!in_array($postSection, $validSections, true)) {
|
|
|
|
|
$postSection = 'overview';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-23 16:39:07 +00:00
|
|
|
// Maintenance actions
|
2025-12-23 14:47:37 +00:00
|
|
|
if ($postAction === 'maintenance_on') {
|
|
|
|
|
$msg = trim($_POST['maintenance_message'] ?? '');
|
|
|
|
|
\App\Core\Maintenance::enable($msg);
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode enabled.', true);
|
|
|
|
|
} elseif ($postAction === 'maintenance_off') {
|
|
|
|
|
\App\Core\Maintenance::disable();
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode disabled.', true);
|
2025-12-23 16:39:07 +00:00
|
|
|
// DB migrations actions
|
2025-12-23 14:47:37 +00:00
|
|
|
} elseif ($postAction === 'migrate_up') {
|
|
|
|
|
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
|
|
|
|
|
$applied = $runner->applyPendingMigrations();
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', empty($applied) ? 'No pending migrations.' : 'Applied migrations: ' . implode(', ', $applied), true);
|
|
|
|
|
} elseif ($postAction === 'migrate_apply_one') {
|
|
|
|
|
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
|
|
|
|
|
$migrationName = trim($_POST['migration_name'] ?? '');
|
|
|
|
|
$applied = $migrationName !== '' ? $runner->applyMigrationByName($migrationName) : [];
|
|
|
|
|
if (empty($applied)) {
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
|
|
|
|
|
$_SESSION['migration_modal_result'] = [
|
|
|
|
|
'name' => $migrationName ?: null,
|
|
|
|
|
'status' => 'info',
|
|
|
|
|
'message' => 'No pending migrations to apply.'
|
|
|
|
|
];
|
|
|
|
|
if (!empty($migrationName)) {
|
|
|
|
|
$_SESSION['migration_modal_open'] = $migrationName;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migration: ' . implode(', ', $applied), true);
|
|
|
|
|
$_SESSION['migration_modal_result'] = [
|
|
|
|
|
'name' => $applied[0],
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'Migration ' . $applied[0] . ' applied successfully.'
|
|
|
|
|
];
|
|
|
|
|
$_SESSION['migration_modal_open'] = $applied[0];
|
|
|
|
|
}
|
2025-12-23 16:39:07 +00:00
|
|
|
// 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)) {
|
2026-01-19 09:31:34 +00:00
|
|
|
Feedback::flash('ERROR', 'DEFAULT', 'Failed to enable plugin. Check database connection and error logs.', false);
|
2025-12-23 16:39:07 +00:00
|
|
|
} else {
|
2026-01-19 09:31:34 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-12-23 16:39:07 +00:00
|
|
|
}
|
|
|
|
|
} 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)) {
|
2026-01-19 09:31:34 +00:00
|
|
|
Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable plugin. Check database connection and error logs.', false);
|
2025-12-23 16:39:07 +00:00
|
|
|
} else {
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" disabled.', $pluginMeta['name']), true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-19 09:31:34 +00:00
|
|
|
// 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';
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
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);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Check for migration functions
|
|
|
|
|
if (strpos($bootstrapContent, '_ensure_tables') !== false) {
|
|
|
|
|
// Temporarily include bootstrap to test migrations
|
|
|
|
|
include_once $bootstrapPath;
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
$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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-23 16:39:07 +00:00
|
|
|
// Test migrations actions
|
2025-12-23 14:47:37 +00:00
|
|
|
} elseif ($postAction === 'create_test_migration') {
|
|
|
|
|
$timestamp = date('Ymd_His');
|
|
|
|
|
$filename = $timestamp . '_test_migration.sql';
|
|
|
|
|
$filepath = $migrationsDir . '/' . $filename;
|
|
|
|
|
$testMigration = "-- Test migration for testing purposes\n";
|
|
|
|
|
$testMigration .= "-- This migration adds a test setting to settings table\n";
|
|
|
|
|
$testMigration .= "INSERT INTO settings (`key`, `value`, updated_at) VALUES ('test_migration_flag', '1', NOW())\n";
|
|
|
|
|
$testMigration .= "ON DUPLICATE KEY UPDATE `value` = '1', updated_at = NOW();\n";
|
|
|
|
|
if (file_put_contents($filepath, $testMigration)) {
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', 'Test migration created: ' . $filename, true);
|
|
|
|
|
} else {
|
|
|
|
|
Feedback::flash('ERROR', 'DEFAULT', 'Failed to create test migration file', false);
|
|
|
|
|
}
|
|
|
|
|
} elseif ($postAction === 'clear_test_migrations') {
|
|
|
|
|
$testFiles = glob($migrationsDir . '/*_test_migration.sql') ?: [];
|
|
|
|
|
$removedCount = 0;
|
|
|
|
|
foreach ($testFiles as $file) {
|
|
|
|
|
$filename = basename($file);
|
|
|
|
|
if (file_exists($file)) {
|
|
|
|
|
unlink($file);
|
|
|
|
|
$removedCount++;
|
|
|
|
|
}
|
|
|
|
|
$stmt = $db->getConnection()->prepare('DELETE FROM migrations WHERE migration = :migration');
|
|
|
|
|
$stmt->execute([':migration' => $filename]);
|
|
|
|
|
}
|
|
|
|
|
Feedback::flash('NOTICE', 'DEFAULT', $removedCount > 0 ? ('Cleared ' . $removedCount . ' test migration(s)') : 'No test migrations to clear', true);
|
|
|
|
|
}
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
Feedback::flash('ERROR', 'DEFAULT', 'Action failed: ' . $e->getMessage(), false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header('Location: ' . $buildAdminUrl($postSection));
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$maintenance_enabled = \App\Core\Maintenance::isEnabled();
|
|
|
|
|
$maintenance_message = \App\Core\Maintenance::getMessage();
|
|
|
|
|
|
|
|
|
|
$pending = [];
|
|
|
|
|
$applied = [];
|
|
|
|
|
$next_pending = null;
|
|
|
|
|
$migration_contents = [];
|
|
|
|
|
$test_migrations_exist = false;
|
|
|
|
|
$migration_records = [];
|
|
|
|
|
$migration_error = null;
|
|
|
|
|
|
|
|
|
|
$migration_modal_result = $_SESSION['migration_modal_result'] ?? null;
|
|
|
|
|
if (isset($_SESSION['migration_modal_result'])) {
|
|
|
|
|
unset($_SESSION['migration_modal_result']);
|
|
|
|
|
}
|
|
|
|
|
$modal_to_open = $_SESSION['migration_modal_open'] ?? null;
|
|
|
|
|
if (isset($_SESSION['migration_modal_open'])) {
|
|
|
|
|
unset($_SESSION['migration_modal_open']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
|
|
|
|
|
$pending = $runner->listPendingMigrations();
|
|
|
|
|
$applied = $runner->listAppliedMigrations();
|
|
|
|
|
|
|
|
|
|
$sortTestFirst = static function (array $items): array {
|
|
|
|
|
usort($items, static function ($a, $b) {
|
|
|
|
|
$aTest = strpos($a, '_test_migration') !== false;
|
|
|
|
|
$bTest = strpos($b, '_test_migration') !== false;
|
|
|
|
|
if ($aTest === $bTest) {
|
|
|
|
|
return strcmp($a, $b);
|
|
|
|
|
}
|
|
|
|
|
return $aTest ? -1 : 1;
|
|
|
|
|
});
|
|
|
|
|
return $items;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$pending = $sortTestFirst($pending);
|
|
|
|
|
$applied = $sortTestFirst($applied);
|
|
|
|
|
$next_pending = $pending[0] ?? null;
|
|
|
|
|
$test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql'));
|
|
|
|
|
|
|
|
|
|
$all = array_unique(array_merge($pending, $applied));
|
|
|
|
|
foreach ($all as $fname) {
|
|
|
|
|
$path = realpath($migrationsDir . '/' . $fname);
|
|
|
|
|
$content = false;
|
|
|
|
|
if ($path && strpos($path, realpath($migrationsDir)) === 0) {
|
|
|
|
|
$content = @file_get_contents($path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$record = $runner->getMigrationRecord($fname);
|
|
|
|
|
if ($record) {
|
|
|
|
|
$migration_records[$fname] = $record;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($content !== false && $content !== null) {
|
|
|
|
|
$migration_contents[$fname] = $content;
|
|
|
|
|
} elseif (!empty($record['content'])) {
|
|
|
|
|
$migration_contents[$fname] = $record['content'];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$migration_error = $e->getMessage();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// 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));
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Start output buffering to catch any unwanted output
|
|
|
|
|
ob_start();
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Disable error display for JSON responses
|
|
|
|
|
$originalErrorReporting = error_reporting();
|
|
|
|
|
$originalDisplayErrors = ini_get('display_errors');
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
$isAjax = (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
|
|
|
|
|
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
|
|
|
|
|
isset($_GET['ajax']);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
if ($isAjax) {
|
|
|
|
|
error_reporting(0);
|
|
|
|
|
ini_set('display_errors', 0);
|
|
|
|
|
}
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
$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;
|
|
|
|
|
}
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
$pluginInfo = $pluginAdminMap[$pluginSlug];
|
|
|
|
|
$checkResults = [];
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
try {
|
|
|
|
|
// Check plugin files exist
|
|
|
|
|
$migrationFiles = glob($pluginInfo['path'] . '/migrations/*.sql');
|
|
|
|
|
$hasMigration = !empty($migrationFiles);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
$checkResults['files'] = [
|
|
|
|
|
'manifest' => file_exists($pluginInfo['path'] . '/plugin.json'),
|
|
|
|
|
'bootstrap' => file_exists($pluginInfo['path'] . '/bootstrap.php'),
|
|
|
|
|
'migration' => $hasMigration,
|
|
|
|
|
];
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Check database tables
|
2026-01-19 10:08:17 +00:00
|
|
|
$db = \App\App::db();
|
2026-01-19 09:31:34 +00:00
|
|
|
$pluginTables = [];
|
|
|
|
|
if ($db instanceof PDO) {
|
|
|
|
|
$stmt = $db->query("SHOW TABLES");
|
|
|
|
|
$allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
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;
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// 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),
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$checkResults['error'] = $e->getMessage();
|
|
|
|
|
}
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Handle AJAX request
|
|
|
|
|
if ($isAjax) {
|
|
|
|
|
// Restore error reporting
|
|
|
|
|
error_reporting($originalErrorReporting);
|
|
|
|
|
ini_set('display_errors', $originalDisplayErrors);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
ob_end_clean(); // Clear and end output buffer
|
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
|
header('Cache-Control: no-cache, must-revalidate');
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
$jsonData = json_encode([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'pluginInfo' => $pluginInfo,
|
|
|
|
|
'checkResults' => $checkResults,
|
|
|
|
|
'csrf_token' => $csrf_token,
|
|
|
|
|
'app_root' => $app_root
|
|
|
|
|
]);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
error_log('JSON response: ' . $jsonData);
|
|
|
|
|
echo $jsonData;
|
|
|
|
|
exit;
|
|
|
|
|
}
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Restore error reporting for non-AJAX requests
|
|
|
|
|
error_reporting($originalErrorReporting);
|
|
|
|
|
ini_set('display_errors', $originalDisplayErrors);
|
2026-01-19 09:40:36 +00:00
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Include check page template for non-AJAX requests
|
|
|
|
|
include '../app/templates/admin_plugin_check.php';
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 14:47:37 +00:00
|
|
|
$overviewPillsPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.pills', [
|
|
|
|
|
'pills' => [],
|
|
|
|
|
'sections' => $sectionRegistry,
|
|
|
|
|
'section_state' => $sectionState,
|
|
|
|
|
'app_root' => $app_root,
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
]);
|
|
|
|
|
$adminOverviewPills = [];
|
|
|
|
|
if (is_array($overviewPillsPayload)) {
|
|
|
|
|
$adminOverviewPills = $overviewPillsPayload['pills'] ?? (is_array($overviewPillsPayload) ? $overviewPillsPayload : []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$overviewStatusesPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.statuses', [
|
|
|
|
|
'statuses' => [],
|
|
|
|
|
'sections' => $sectionRegistry,
|
|
|
|
|
'section_state' => $sectionState,
|
|
|
|
|
'app_root' => $app_root,
|
|
|
|
|
'user_id' => $userId,
|
|
|
|
|
]);
|
|
|
|
|
$adminOverviewStatuses = [];
|
|
|
|
|
if (is_array($overviewStatusesPayload)) {
|
|
|
|
|
$adminOverviewStatuses = $overviewStatusesPayload['statuses'] ?? (is_array($overviewStatusesPayload) ? $overviewStatusesPayload : []);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 09:31:34 +00:00
|
|
|
// Get any new feedback messages
|
|
|
|
|
include_once '../app/helpers/feedback.php';
|
2025-12-23 14:47:37 +00:00
|
|
|
|
|
|
|
|
include '../app/templates/admin.php';
|