Compare commits
No commits in common. "a77cf5b328705c83ec0ad4b591eaea60fb60bc0c" and "08953c627267ff7f8a9f7e95e6f49ecc7b99f2f6" have entirely different histories.
a77cf5b328
...
08953c6272
|
|
@ -139,10 +139,6 @@ class Feedback {
|
||||||
'type' => self::TYPE_ERROR,
|
'type' => self::TYPE_ERROR,
|
||||||
'dismissible' => false
|
'dismissible' => false
|
||||||
],
|
],
|
||||||
'MIGRATIONS_PENDING' => [
|
|
||||||
'type' => self::TYPE_WARNING,
|
|
||||||
'dismissible' => true
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
private static $strings = null;
|
private static $strings = null;
|
||||||
|
|
|
||||||
|
|
@ -7,28 +7,12 @@ use Exception;
|
||||||
|
|
||||||
class MigrationRunner
|
class MigrationRunner
|
||||||
{
|
{
|
||||||
private PDO $pdo;
|
private PDO $db;
|
||||||
private string $migrationsDir;
|
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)
|
|
||||||
{
|
{
|
||||||
// Normalize to PDO
|
$this->db = $db;
|
||||||
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, '/');
|
$this->migrationsDir = rtrim($migrationsDir, '/');
|
||||||
if (!is_dir($this->migrationsDir)) {
|
if (!is_dir($this->migrationsDir)) {
|
||||||
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
|
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
|
||||||
|
|
@ -38,21 +22,12 @@ class MigrationRunner
|
||||||
|
|
||||||
private function ensureMigrationsTable(): void
|
private function ensureMigrationsTable(): void
|
||||||
{
|
{
|
||||||
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
$sql = "CREATE TABLE IF NOT EXISTS migrations (
|
||||||
if ($driver === 'sqlite') {
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
$sql = "CREATE TABLE IF NOT EXISTS migrations (
|
migration VARCHAR(255) NOT NULL UNIQUE,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
applied_at DATETIME NOT NULL
|
||||||
migration TEXT NOT NULL UNIQUE,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
|
||||||
applied_at TEXT NOT NULL
|
$this->db->exec($sql);
|
||||||
)";
|
|
||||||
} 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
|
public function listAllMigrations(): array
|
||||||
|
|
@ -64,7 +39,7 @@ class MigrationRunner
|
||||||
|
|
||||||
public function listAppliedMigrations(): array
|
public function listAppliedMigrations(): array
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->query('SELECT migration FROM migrations ORDER BY migration ASC');
|
$stmt = $this->db->query('SELECT migration FROM migrations ORDER BY migration ASC');
|
||||||
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +64,7 @@ class MigrationRunner
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->pdo->beginTransaction();
|
$this->db->beginTransaction();
|
||||||
foreach ($pending as $migration) {
|
foreach ($pending as $migration) {
|
||||||
$path = $this->migrationsDir . '/' . $migration;
|
$path = $this->migrationsDir . '/' . $migration;
|
||||||
$sql = file_get_contents($path);
|
$sql = file_get_contents($path);
|
||||||
|
|
@ -100,16 +75,16 @@ class MigrationRunner
|
||||||
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
|
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
|
||||||
foreach ($statements as $stmtSql) {
|
foreach ($statements as $stmtSql) {
|
||||||
if ($stmtSql === '') continue;
|
if ($stmtSql === '') continue;
|
||||||
$this->pdo->exec($stmtSql);
|
$this->db->exec($stmtSql);
|
||||||
}
|
}
|
||||||
$ins = $this->pdo->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
|
$ins = $this->db->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
|
||||||
$ins->execute([':m' => $migration]);
|
$ins->execute([':m' => $migration]);
|
||||||
$appliedNow[] = $migration;
|
$appliedNow[] = $migration;
|
||||||
}
|
}
|
||||||
$this->pdo->commit();
|
$this->db->commit();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
if ($this->pdo->inTransaction()) {
|
if ($this->db->inTransaction()) {
|
||||||
$this->pdo->rollBack();
|
$this->db->rollBack();
|
||||||
}
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,5 @@ return [
|
||||||
'DB_ERROR' => 'Error connecting to the database: %s',
|
'DB_ERROR' => 'Error connecting to the database: %s',
|
||||||
'DB_CONNECT_ERROR' => 'Error connecting to DB: %s',
|
'DB_CONNECT_ERROR' => 'Error connecting to DB: %s',
|
||||||
'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"',
|
'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"',
|
||||||
'MIGRATIONS_PENDING' => '%s',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?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';
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?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,13 +69,8 @@
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-right">
|
<div class="dropdown-menu dropdown-menu-right">
|
||||||
<h6 class="dropdown-header">system</h6>
|
<h6 class="dropdown-header">system</h6>
|
||||||
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
|
<?php if ($userObject->hasRight($userId, 'superuser') ||
|
||||||
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin-tools">
|
$userObject->hasRight($userId, 'view config file')) {?>
|
||||||
<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">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=config">
|
||||||
<i class="fas fa-wrench"></i>Configuration
|
<i class="fas fa-wrench"></i>Configuration
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,6 @@ $allowed_urls = [
|
||||||
'graphs','latest','livejs','agents',
|
'graphs','latest','livejs','agents',
|
||||||
'profile','credentials','config','security',
|
'profile','credentials','config','security',
|
||||||
'settings','theme','theme-asset',
|
'settings','theme','theme-asset',
|
||||||
'admin-tools',
|
|
||||||
'status',
|
'status',
|
||||||
'help','about',
|
'help','about',
|
||||||
'login','logout',
|
'login','logout',
|
||||||
|
|
@ -195,7 +194,7 @@ try {
|
||||||
$msg = 'Database schema is out of date. Pending migrations: ' . implode(', ', $pending) . '. Run: php scripts/migrate.php up';
|
$msg = 'Database schema is out of date. Pending migrations: ' . implode(', ', $pending) . '. Run: php scripts/migrate.php up';
|
||||||
// Log and show as a system message
|
// Log and show as a system message
|
||||||
$logObject->log('warning', $msg, ['scope' => 'system']);
|
$logObject->log('warning', $msg, ['scope' => 'system']);
|
||||||
Feedback::flash('SYSTEM', 'MIGRATIONS_PENDING', $msg, false, true);
|
Feedback::flash('DB', 'MIGRATIONS_PENDING', $msg, false, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue