Compare commits
2 Commits
08953c6272
...
a77cf5b328
| Author | SHA1 | Date |
|---|---|---|
|
|
a77cf5b328 | |
|
|
f22fa76987 |
|
|
@ -139,6 +139,10 @@ class Feedback {
|
|||
'type' => self::TYPE_ERROR,
|
||||
'dismissible' => false
|
||||
],
|
||||
'MIGRATIONS_PENDING' => [
|
||||
'type' => self::TYPE_WARNING,
|
||||
'dismissible' => true
|
||||
],
|
||||
];
|
||||
|
||||
private static $strings = null;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue