jilo-web/app/pages/admin.php

357 lines
13 KiB
PHP

<?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';
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;
}
$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'],
];
$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' ? ('&section=' . 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';