Compare commits

...

2 Commits

Author SHA1 Message Date
Yasen Pramatarov a77cf5b328 Adds simple admin-tools page 2025-09-25 11:37:54 +03:00
Yasen Pramatarov f22fa76987 Fixes db migration code 2025-09-24 21:29:31 +03:00
7 changed files with 218 additions and 19 deletions

View File

@ -139,6 +139,10 @@ class Feedback {
'type' => self::TYPE_ERROR,
'dismissible' => false
],
'MIGRATIONS_PENDING' => [
'type' => self::TYPE_WARNING,
'dismissible' => true
],
];
private static $strings = null;

View File

@ -7,12 +7,28 @@ use Exception;
class MigrationRunner
{
private PDO $db;
private PDO $pdo;
private string $migrationsDir;
public function __construct(PDO $db, string $migrationsDir)
/**
* @param mixed $db Either a PDO instance or the application's Database wrapper
* @param string $migrationsDir Directory containing .sql migrations
*/
public function __construct($db, string $migrationsDir)
{
$this->db = $db;
// Normalize to PDO
if ($db instanceof PDO) {
$this->pdo = $db;
} elseif (is_object($db) && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
if (!$pdo instanceof PDO) {
throw new Exception('Database wrapper did not return a PDO instance');
}
$this->pdo = $pdo;
} else {
$type = is_object($db) ? get_class($db) : gettype($db);
throw new Exception("Unsupported database type: {$type}");
}
$this->migrationsDir = rtrim($migrationsDir, '/');
if (!is_dir($this->migrationsDir)) {
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
@ -22,12 +38,21 @@ class MigrationRunner
private function ensureMigrationsTable(): void
{
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$this->db->exec($sql);
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
)";
} else {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$this->pdo->exec($sql);
}
public function listAllMigrations(): array
@ -39,7 +64,7 @@ class MigrationRunner
public function listAppliedMigrations(): array
{
$stmt = $this->db->query('SELECT migration FROM migrations ORDER BY migration ASC');
$stmt = $this->pdo->query('SELECT migration FROM migrations ORDER BY migration ASC');
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
@ -64,7 +89,7 @@ class MigrationRunner
}
try {
$this->db->beginTransaction();
$this->pdo->beginTransaction();
foreach ($pending as $migration) {
$path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path);
@ -75,16 +100,16 @@ class MigrationRunner
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
foreach ($statements as $stmtSql) {
if ($stmtSql === '') continue;
$this->db->exec($stmtSql);
$this->pdo->exec($stmtSql);
}
$ins = $this->db->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
$ins = $this->pdo->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
$ins->execute([':m' => $migration]);
$appliedNow[] = $migration;
}
$this->db->commit();
$this->pdo->commit();
} catch (Exception $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}

View File

@ -44,5 +44,6 @@ return [
'DB_ERROR' => 'Error connecting to the database: %s',
'DB_CONNECT_ERROR' => 'Error connecting to DB: %s',
'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"',
'MIGRATIONS_PENDING' => '%s',
],
];

View File

@ -0,0 +1,94 @@
<?php
/**
* Admin tools controller
*
* Allows superusers to:
* - Enable/disable maintenance mode
* - Run database migrations
*/
// Security and CSRF
require_once __DIR__ . '/../helpers/security.php';
$security = SecurityHelper::getInstance();
// Must be logged in
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Must be superuser
$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;
}
// Handle actions
$action = $_POST['action'] ?? '';
if ($action !== '') {
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('SECURITY', 'CSRF_INVALID');
header('Location: ' . $app_root . '?page=admin-tools');
exit;
}
try {
if ($action === 'maintenance_on') {
require_once __DIR__ . '/../core/Maintenance.php';
$msg = trim($_POST['maintenance_message'] ?? '');
\App\Core\Maintenance::enable($msg);
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode enabled.', true);
} elseif ($action === 'maintenance_off') {
require_once __DIR__ . '/../core/Maintenance.php';
\App\Core\Maintenance::disable();
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode disabled.', true);
} elseif ($action === 'migrate_up') {
require_once __DIR__ . '/../core/MigrationRunner.php';
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$applied = $runner->applyPendingMigrations();
if (empty($applied)) {
Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
} else {
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migrations: ' . implode(', ', $applied), true);
}
}
} catch (Throwable $e) {
Feedback::flash('ERROR', 'DEFAULT', 'Action failed: ' . $e->getMessage(), false);
}
header('Location: ' . $app_root . '?page=admin-tools');
exit;
}
// Prepare data for view
require_once __DIR__ . '/../core/Maintenance.php';
$maintenance_enabled = \App\Core\Maintenance::isEnabled();
$maintenance_message = \App\Core\Maintenance::getMessage();
require_once __DIR__ . '/../core/MigrationRunner.php';
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
$pending = [];
$applied = [];
try {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$pending = $runner->listPendingMigrations();
$applied = $runner->listAppliedMigrations();
} catch (Throwable $e) {
// show error in the page
$migration_error = $e->getMessage();
}
// CSRF token
$csrf_token = $security->generateCsrfToken();
// Get any new feedback messages
include __DIR__ . '/../helpers/feedback.php';
// Load the template
include __DIR__ . '/../templates/admin-tools.php';

View File

@ -0,0 +1,69 @@
<?php
/** @var bool $maintenance_enabled */
/** @var string $maintenance_message */
/** @var array $pending */
/** @var array $applied */
/** @var string $csrf_token */
?>
<div class="container mt-4">
<h2>Admin tools</h2>
<p class="text-muted">System maintenance and database utilities.</p>
<div class="row mt-4">
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">Maintenance mode</div>
<div class="card-body">
<p>Status: <strong class="<?= $maintenance_enabled ? 'text-danger' : 'text-success' ?>">
<?= $maintenance_enabled ? 'Enabled' : 'Disabled' ?></strong></p>
<form method="post" class="mb-2">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_on">
<div class="mb-2">
<label for="maintenance_message" class="form-label">Message (optional)</label>
<input type="text" id="maintenance_message" name="maintenance_message" class="form-control" value="<?= htmlspecialchars($maintenance_message) ?>" placeholder="Upgrading database">
</div>
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
</form>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_off">
<button type="submit" class="btn btn-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?>>Disable maintenance</button>
</form>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">Database migrations</div>
<div class="card-body">
<?php if (!empty($migration_error)): ?>
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
<?php endif; ?>
<p>
<strong>Pending (<?= count($pending) ?>):</strong>
<?php if (empty($pending)): ?>
<span class="text-success">None</span>
<?php else: ?>
<code><?= htmlspecialchars(implode(', ', $pending)) ?></code>
<?php endif; ?>
</p>
<p>
<strong>Applied (<?= count($applied) ?>):</strong>
<?php if (empty($applied)): ?>
<span class="text-muted">None</span>
<?php else: ?>
<code><?= htmlspecialchars(implode(', ', $applied)) ?></code>
<?php endif; ?>
</p>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up">
<button type="submit" class="btn btn-primary" <?= empty($pending) ? 'disabled' : '' ?>>Apply pending migrations</button>
</form>
</div>
</div>
</div>
</div>
</div>

View File

@ -69,8 +69,13 @@
</a>
<div class="dropdown-menu dropdown-menu-right">
<h6 class="dropdown-header">system</h6>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file')) {?>
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin-tools">
<i class="fas fa-toolbox"></i>Admin tools
</a>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file')) {?>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=config">
<i class="fas fa-wrench"></i>Configuration
</a>

View File

@ -146,6 +146,7 @@ $allowed_urls = [
'graphs','latest','livejs','agents',
'profile','credentials','config','security',
'settings','theme','theme-asset',
'admin-tools',
'status',
'help','about',
'login','logout',
@ -194,7 +195,7 @@ try {
$msg = 'Database schema is out of date. Pending migrations: ' . implode(', ', $pending) . '. Run: php scripts/migrate.php up';
// Log and show as a system message
$logObject->log('warning', $msg, ['scope' => 'system']);
Feedback::flash('DB', 'MIGRATIONS_PENDING', $msg, false, true);
Feedback::flash('SYSTEM', 'MIGRATIONS_PENDING', $msg, false, true);
}
}
} catch (\Throwable $e) {