diff --git a/app/pages/admin.php b/app/pages/admin.php new file mode 100644 index 0000000..895c040 --- /dev/null +++ b/app/pages/admin.php @@ -0,0 +1,356 @@ +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'], +]; + +$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'], + ]; +} + +// Hooks section for plugins +$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 : []); +} + +$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 { + 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); + } 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]; + } + } 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(); +} + +$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 : []); +} + +$csrf_token = $security->generateCsrfToken(); + +include '../app/templates/admin.php'; diff --git a/app/templates/admin.php b/app/templates/admin.php new file mode 100644 index 0000000..a974f24 --- /dev/null +++ b/app/templates/admin.php @@ -0,0 +1,449 @@ + + + [ + 'label' => 'Overview', + 'url' => $sectionUrls['overview'] ?? ($app_root . '?page=admin'), + 'type' => 'core', + 'hook' => null, + 'position' => 100, + ], + ]; +} + +$heroPills = [ + [ + 'label' => 'Maintenance', + 'value' => $maintenance_enabled ? 'enabled' : 'off', + 'icon' => 'fas fa-power-off', + 'tone' => $maintenance_enabled ? 'danger' : 'success', + ], + [ + 'label' => 'Migrations', + 'value' => count($pending) . ' pending', + 'icon' => 'fas fa-database', + 'tone' => empty($pending) ? 'neutral' : 'warning', + ], +]; + +if (!empty($adminOverviewPills) && is_array($adminOverviewPills)) { + foreach ($adminOverviewPills as $pill) { + if (!is_array($pill)) { + continue; + } + $heroPills[] = [ + 'label' => (string)($pill['label'] ?? 'Status'), + 'value' => (string)($pill['value'] ?? ''), + 'icon' => (string)($pill['icon'] ?? 'fas fa-info-circle'), + 'tone' => (string)($pill['tone'] ?? 'info'), + ]; + } +} + +$statusItems = [ + [ + 'label' => 'Maintenance mode', + 'description' => $maintenance_enabled ? 'Live site shows downtime banner.' : 'Visitors see the normal experience.', + 'value' => $maintenance_enabled ? 'ON' : 'OFF', + 'tone' => $maintenance_enabled ? 'warning' : 'success', + ], + [ + 'label' => 'Schema migrations', + 'description' => empty($pending) ? 'Database matches code.' : 'Pending updates detected.', + 'value' => count($pending) . ' pending', + 'tone' => empty($pending) ? 'success' : 'warning', + ], +]; + +if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) { + foreach ($adminOverviewStatuses as $status) { + if (!is_array($status)) { + continue; + } + $statusItems[] = [ + 'label' => (string)($status['label'] ?? 'Status'), + 'description' => (string)($status['description'] ?? ''), + 'value' => (string)($status['value'] ?? ''), + 'tone' => (string)($status['tone'] ?? 'info'), + ]; + } +} +?> + +
+
+
+
+

Admin control center

+

+ Centralized administration dashboard for system-wide management. +

+
+
+ +
+ + +
+ +
+
+ +
+
+ +
+
+ $tabMeta): + $isActive = $activeSection === $sectionKey; + $tabUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey))); +?> + + + + +
+ + $tabMeta): + $panelUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey))); + $isActive = $activeSection === $sectionKey; +?> +
+ +
+
+
+

Current status

+

High-level signals that require your attention.

+
+
    + +
  • +
    + + +

    + +
    + + + +
  • + +
+
+ +
+
+

Maintenance

+

Toggle maintenance or update visitor message.

+
+
+ + + + + +
+ + +
+
+
+ + + +
+
+ +
+
+

Next migration

+

Peek at what will run when you apply updates.

+
+ +

Next:

+ + +

No migrations queued.

+ +
+
+ + + + +
+
+
+ +
+
+
+
+

Maintenance mode

+

Let your users know when maintenance is in progress.

+
+ + + +
+
+

Message

+
+ + + +
+ + +
+
+
+
+ + + +
+
+
+ +
+
+
+
+

Database migrations

+

Review pending SQL and apply with confidence.

+
+
+ + +
Error:
+ + +
+

Test migration tools

+
+
+ + + + +
+
+ + + + +
+
+
+ +
+
+

Pending migrations

+ + Next: + +
+
    + +
  • No pending migrations
  • + + +
  • +
    + +
    + +
  • + + +
+
+ +
+
+

Applied migrations

+ +
+
    + +
  • No applied migrations yet
  • + + +
  • +
    + +
    + +
  • + + +
+
+ +
+ + + + +
+
+
+ + $sectionKey, + 'active_section' => $activeSection, + 'app_root' => $app_root, + 'section_url' => $panelUrl, + 'section_urls' => $sectionUrls ?? [], + 'csrf_token' => $csrf_token, + 'state' => $sectionState[$sectionKey] ?? [], + 'section_state' => $sectionState, + 'db' => $db ?? null, + ]); + ?> + +
+

No renderer available for this section.

+
+ +
+ +
+ + $content): + $modalId = 'migrationModal' . md5($name); + $record = $migration_records[$name] ?? null; + $appliedAtRaw = $record['applied_at'] ?? null; + $appliedAtFormatted = null; + if (!empty($appliedAtRaw)) { + $timestamp = strtotime($appliedAtRaw); + $appliedAtFormatted = $timestamp ? date('M d, Y H:i', $timestamp) : $appliedAtRaw; + } + $isModalNext = (!empty($next_pending) && $next_pending === $name); + $modalResult = (!empty($migration_modal_result) && ($migration_modal_result['name'] ?? '') === $name) ? $migration_modal_result : null; +?> + + + +
+ + + +
+ + diff --git a/app/templates/page-header.php b/app/templates/page-header.php index f78181b..a6b9c28 100644 --- a/app/templates/page-header.php +++ b/app/templates/page-header.php @@ -36,7 +36,7 @@ -