Compare commits
No commits in common. "HEAD" and "v0.4.1" have entirely different histories.
|
|
@ -4,4 +4,3 @@ jilo.db
|
||||||
jilo-web.db
|
jilo-web.db
|
||||||
packaging/deb-package/
|
packaging/deb-package/
|
||||||
packaging/rpm-package/
|
packaging/rpm-package/
|
||||||
/public_html/uploads/avatars/
|
|
||||||
|
|
|
||||||
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -4,49 +4,6 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
#### Links
|
|
||||||
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4.1...HEAD
|
|
||||||
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4.1...HEAD
|
|
||||||
- github: https://github.com/lindeas/jilo-web/compare/v0.4.1...HEAD
|
|
||||||
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4.1...HEAD
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- CSS for dashboard widgets
|
|
||||||
- Monthly dashboard statistics redesign
|
|
||||||
- Tracking of applied database migrations in the database
|
|
||||||
- Option to run database migrations one by one
|
|
||||||
- Log Throttler to prevent log flooding
|
|
||||||
- Logger helper with fallback when no log plugin is available
|
|
||||||
- Plugin asset page
|
|
||||||
- Plugin hooks for profile page, account menu, and asset loading
|
|
||||||
- Email helper and email templates, including password reset email
|
|
||||||
- Admin page and admin dashboard for all administrative tasks
|
|
||||||
- Plugin namagement section for the admin dashboard
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Updated credentials pages and removed unused "credentials.php"
|
|
||||||
- Redesigned admin tools, themes, profile, credentials/2FA, and authentication pages
|
|
||||||
- Redesigned sidebar, main elements, menus, and overall CSS
|
|
||||||
- Updated pagination styling
|
|
||||||
- Reorganized dashboard layout
|
|
||||||
- Switched profile edit and action pages to uniform action-card design
|
|
||||||
- Replaced "error_log" with "app_log" in 2FA
|
|
||||||
- Updated index bootstrap to use global "APP_PATH"
|
|
||||||
- Refactored database migration system and Admin Tools functionality
|
|
||||||
- Removed "admin-tools" page, all functionality is now in "admin" page
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Database migration reliability issues
|
|
||||||
- Validator rejecting "0" as a valid value
|
|
||||||
- Collapsing sidebar layout issues
|
|
||||||
- Profile avatar upload issues
|
|
||||||
- Public pages incorrectly requiring authentication
|
|
||||||
- Correct encoding of login redirect URL parameters
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0.4.1 - 2025-11-13
|
## 0.4.1 - 2025-11-13
|
||||||
|
|
||||||
#### Links
|
#### Links
|
||||||
|
|
@ -244,6 +201,8 @@ All notable changes to this project will be documented in this file.
|
||||||
### Changed
|
### Changed
|
||||||
- Changed the layout with bootstrap CSS classes
|
- Changed the layout with bootstrap CSS classes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0.1 - 2024-07-08
|
## 0.1 - 2024-07-08
|
||||||
|
|
|
||||||
|
|
@ -67,23 +67,28 @@ class PasswordReset {
|
||||||
|
|
||||||
// Send email with reset link
|
// Send email with reset link
|
||||||
$to = $user['email'];
|
$to = $user['email'];
|
||||||
// Load email helper
|
|
||||||
require_once __DIR__ . '/../helpers/email_helper.php';
|
|
||||||
|
|
||||||
$subject = "{$config['site_name']} - Password reset request";
|
$subject = "{$config['site_name']} - Password reset request";
|
||||||
|
$message = "Dear user,\n\n";
|
||||||
|
$message .= "We received a request to reset your password for your {$config['site_name']} account.\n\n";
|
||||||
|
$message .= "To set a new password, please click the link below:\n\n";
|
||||||
|
$message .= $resetLink . "\n\n";
|
||||||
|
$message .= "This link will expire in 1 hour for security reasons.\n\n";
|
||||||
|
$message .= "If you did not request this password reset, please ignore this email. Your account remains secure.\n\n";
|
||||||
|
if (!empty($config['site_name'])) {
|
||||||
|
$message .= "Best regards,\n";
|
||||||
|
$message .= "The {$config['site_name']} team\n";
|
||||||
|
if (!empty($config['site_slogan'])) {
|
||||||
|
$message .= ":: {$config['site_slogan']} ::";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$variables = [
|
$headers = [
|
||||||
'site_name' => $config['site_name'],
|
|
||||||
'reset_link' => $resetLink,
|
|
||||||
'site_slogan' => $config['site_slogan'] ?? ''
|
|
||||||
];
|
|
||||||
|
|
||||||
$additionalHeaders = [
|
|
||||||
'From' => "noreply@{$config['domain']}",
|
'From' => "noreply@{$config['domain']}",
|
||||||
'Reply-To' => "noreply@{$config['domain']}"
|
'Reply-To' => "noreply@{$config['domain']}",
|
||||||
|
'X-Mailer' => 'PHP/' . phpversion()
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!sendTemplateEmail($to, $subject, 'password_reset', $variables, $config, $additionalHeaders)) {
|
if (!mail($to, $subject, $message, $headers)) {
|
||||||
return ['success' => false, 'message' => 'Failed to send reset email'];
|
return ['success' => false, 'message' => 'Failed to send reset email'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Already required in index.php, but we require it here,
|
|
||||||
// because this class could be used standalone
|
|
||||||
require_once __DIR__ . '/../helpers/logger_loader.php';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class TwoFactorAuthentication
|
* Class TwoFactorAuthentication
|
||||||
*
|
*
|
||||||
|
|
@ -16,7 +12,7 @@ class TwoFactorAuthentication {
|
||||||
private $period = 30; // Time step in seconds (T0)
|
private $period = 30; // Time step in seconds (T0)
|
||||||
private $digits = 6; // Number of digits in TOTP code
|
private $digits = 6; // Number of digits in TOTP code
|
||||||
private $algorithm = 'sha1'; // HMAC algorithm
|
private $algorithm = 'sha1'; // HMAC algorithm
|
||||||
private $issuer = 'Jilo';
|
private $issuer = 'TotalMeet';
|
||||||
private $window = 1; // Time window of 1 step before/after
|
private $window = 1; // Time window of 1 step before/after
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -102,10 +98,7 @@ class TwoFactorAuthentication {
|
||||||
if ($code !== null) {
|
if ($code !== null) {
|
||||||
// Verify the setup code
|
// Verify the setup code
|
||||||
if (!$this->verify($userId, $code)) {
|
if (!$this->verify($userId, $code)) {
|
||||||
app_log('warning', '2FA setup code verification failed', [
|
error_log("Code verification failed");
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,10 +117,7 @@ class TwoFactorAuthentication {
|
||||||
if ($this->db->inTransaction()) {
|
if ($this->db->inTransaction()) {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
}
|
}
|
||||||
app_log('error', '2FA enable error: ' . $e->getMessage(), [
|
error_log('2FA enable error: ' . $e->getMessage());
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,10 +157,7 @@ class TwoFactorAuthentication {
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
app_log('error', '2FA verification error: ' . $e->getMessage(), [
|
error_log('2FA verification error: ' . $e->getMessage());
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -364,10 +351,7 @@ class TwoFactorAuthentication {
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
app_log('error', 'Backup code verification error: ' . $e->getMessage(), [
|
error_log('Backup code verification error: ' . $e->getMessage());
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -394,10 +378,7 @@ class TwoFactorAuthentication {
|
||||||
return $stmt->execute([$userId]);
|
return $stmt->execute([$userId]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
app_log('error', '2FA disable error: ' . $e->getMessage(), [
|
error_log('2FA disable error: ' . $e->getMessage());
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -416,10 +397,7 @@ class TwoFactorAuthentication {
|
||||||
return $result && $result['enabled'];
|
return $result && $result['enabled'];
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
app_log('error', '2FA status check error: ' . $e->getMessage(), [
|
error_log('2FA status check error: ' . $e->getMessage());
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -435,10 +413,7 @@ class TwoFactorAuthentication {
|
||||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
app_log('error', 'Failed to get user 2FA settings: ' . $e->getMessage(), [
|
error_log('Failed to get user 2FA settings: ' . $e->getMessage());
|
||||||
'scope' => 'security',
|
|
||||||
'user_id' => $userId,
|
|
||||||
]);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -473,20 +473,6 @@ class User {
|
||||||
$newFileName = md5(time() . $fileName) . '.' . $fileExtension;
|
$newFileName = md5(time() . $fileName) . '.' . $fileExtension;
|
||||||
$dest_path = $avatars_path . $newFileName;
|
$dest_path = $avatars_path . $newFileName;
|
||||||
|
|
||||||
// ensure avatars directory exists
|
|
||||||
if (!is_dir($avatars_path)) {
|
|
||||||
if (!mkdir($avatars_path, 0755, true)) {
|
|
||||||
$_SESSION['error'] .= 'Unable to create avatars directory. ';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if directory is writable
|
|
||||||
if (!is_writable($avatars_path)) {
|
|
||||||
$_SESSION['error'] .= 'Avatars directory is not writable. ';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// move the file to avatars folder
|
// move the file to avatars folder
|
||||||
if (move_uploaded_file($fileTmpPath, $dest_path)) {
|
if (move_uploaded_file($fileTmpPath, $dest_path)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -500,50 +486,24 @@ class User {
|
||||||
':user_id' => $userId
|
':user_id' => $userId
|
||||||
]);
|
]);
|
||||||
// all went OK
|
// all went OK
|
||||||
$_SESSION['notice'] = 'Avatar updated successfully. ';
|
$_SESSION['notice'] .= 'Avatar updated successfully. ';
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$_SESSION['error'] .= 'Database error updating avatar. ';
|
|
||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$_SESSION['error'] = 'Error moving the uploaded file. Please check directory permissions. ';
|
$_SESSION['error'] .= 'Error moving the uploaded file. ';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$_SESSION['error'] = 'Invalid avatar file type. Only JPG, PNG, and JPEG are allowed. ';
|
$_SESSION['error'] .= 'Invalid avatar file type. ';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle different upload errors
|
$_SESSION['error'] .= 'Error uploading the avatar file. ';
|
||||||
switch ($avatar_file['error']) {
|
|
||||||
case UPLOAD_ERR_INI_SIZE:
|
|
||||||
case UPLOAD_ERR_FORM_SIZE:
|
|
||||||
$_SESSION['error'] = 'Avatar file is too large. Maximum size is 500KB. ';
|
|
||||||
break;
|
|
||||||
case UPLOAD_ERR_PARTIAL:
|
|
||||||
$_SESSION['error'] = 'Avatar file was only partially uploaded. ';
|
|
||||||
break;
|
|
||||||
case UPLOAD_ERR_NO_FILE:
|
|
||||||
$_SESSION['error'] = 'No avatar file was uploaded. ';
|
|
||||||
break;
|
|
||||||
case UPLOAD_ERR_NO_TMP_DIR:
|
|
||||||
$_SESSION['error'] = 'Missing temporary folder for file upload. ';
|
|
||||||
break;
|
|
||||||
case UPLOAD_ERR_CANT_WRITE:
|
|
||||||
$_SESSION['error'] = 'Failed to write avatar file to disk. ';
|
|
||||||
break;
|
|
||||||
case UPLOAD_ERR_EXTENSION:
|
|
||||||
$_SESSION['error'] = 'File upload stopped by extension. ';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
$_SESSION['error'] = 'Unknown upload error occurred. ';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$_SESSION['error'] = 'An error occurred while processing the avatar: ' . $e->getMessage();
|
|
||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -21,25 +21,9 @@ class Validator {
|
||||||
$value = $this->data[$field] ?? null;
|
$value = $this->data[$field] ?? null;
|
||||||
|
|
||||||
switch ($rule) {
|
switch ($rule) {
|
||||||
// case for required fields that can be empty strings
|
|
||||||
case 'required':
|
case 'required':
|
||||||
if ($parameter && empty($value)) {
|
if ($parameter && empty($value)) {
|
||||||
$label = $this->formatFieldLabel($field);
|
$this->addError($field, "Field is required");
|
||||||
$this->addError($field, "$label is required");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
// special case for required fields that can't be empty strings or null
|
|
||||||
case 'required_strict':
|
|
||||||
if ($parameter) {
|
|
||||||
if ($value === null) {
|
|
||||||
$label = $this->formatFieldLabel($field);
|
|
||||||
$this->addError($field, "$label is required");
|
|
||||||
} elseif (is_string($value)) {
|
|
||||||
if (trim($value) === '') {
|
|
||||||
$label = $this->formatFieldLabel($field);
|
|
||||||
$this->addError($field, "$label is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'email':
|
case 'email':
|
||||||
|
|
@ -108,10 +92,6 @@ class Validator {
|
||||||
$this->errors[$field][] = $message;
|
$this->errors[$field][] = $message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatFieldLabel($field) {
|
|
||||||
return ucfirst(str_replace('_', ' ', $field));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getErrors() {
|
public function getErrors() {
|
||||||
return $this->errors;
|
return $this->errors;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
require_once __DIR__ . '/Settings.php';
|
|
||||||
|
|
||||||
class LogThrottler
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Log a message no more than once per interval.
|
|
||||||
*
|
|
||||||
* @param object $logger Logger implementing log($level, $message, array $context)
|
|
||||||
* @param mixed $db PDO or DatabaseConnector for Settings
|
|
||||||
* @param string $key Unique key for throttling (e.g. migrations_pending)
|
|
||||||
* @param int $intervalSeconds Minimum seconds between logs
|
|
||||||
* @param string $level Log level
|
|
||||||
* @param string $message Log message
|
|
||||||
* @param array $context Log context
|
|
||||||
*/
|
|
||||||
public static function logThrottled($logger, $db, string $key, int $intervalSeconds, string $level, string $message, array $context = []): void
|
|
||||||
{
|
|
||||||
if (!is_object($logger) || !method_exists($logger, 'log')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = null;
|
|
||||||
$shouldLog = true;
|
|
||||||
$settingsKey = 'log_throttle_' . $key;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$settings = new Settings($db);
|
|
||||||
$lastLogged = $settings->get($settingsKey);
|
|
||||||
if ($lastLogged) {
|
|
||||||
$lastTimestamp = strtotime($lastLogged);
|
|
||||||
if ($lastTimestamp !== false && (time() - $lastTimestamp) < $intervalSeconds) {
|
|
||||||
$shouldLog = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$settings = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($shouldLog) {
|
|
||||||
$logger->log($level, $message, $context);
|
|
||||||
if ($settings) {
|
|
||||||
$settings->set($settingsKey, date('Y-m-d H:i:s'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class MigrationException extends Exception
|
|
||||||
{
|
|
||||||
private string $migration;
|
|
||||||
|
|
||||||
public function __construct(string $migration, string $message, ?Exception $previous = null)
|
|
||||||
{
|
|
||||||
$this->migration = $migration;
|
|
||||||
parent::__construct($message, 0, $previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMigration(): string
|
|
||||||
{
|
|
||||||
return $this->migration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
namespace App\Core;
|
namespace App\Core;
|
||||||
|
|
||||||
require_once __DIR__ . '/NullLogger.php';
|
|
||||||
require_once __DIR__ . '/MigrationException.php';
|
|
||||||
|
|
||||||
use PDO;
|
use PDO;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
|
|
@ -12,10 +9,6 @@ class MigrationRunner
|
||||||
{
|
{
|
||||||
private PDO $pdo;
|
private PDO $pdo;
|
||||||
private string $migrationsDir;
|
private string $migrationsDir;
|
||||||
private string $driver;
|
|
||||||
private bool $isSqlite = false;
|
|
||||||
private $logger;
|
|
||||||
private array $lastResults = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $db Either a PDO instance or the application's Database wrapper
|
* @param mixed $db Either a PDO instance or the application's Database wrapper
|
||||||
|
|
@ -40,77 +33,28 @@ class MigrationRunner
|
||||||
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}");
|
||||||
}
|
}
|
||||||
$this->driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
|
||||||
$this->isSqlite = ($this->driver === 'sqlite');
|
|
||||||
$this->ensureMigrationsTable();
|
$this->ensureMigrationsTable();
|
||||||
$this->ensureMigrationColumns();
|
|
||||||
$this->initializeLogger();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureMigrationsTable(): void
|
private function ensureMigrationsTable(): void
|
||||||
{
|
{
|
||||||
if ($this->isSqlite) {
|
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
$sql = "CREATE TABLE IF NOT EXISTS migrations (
|
$sql = "CREATE TABLE IF NOT EXISTS migrations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
migration TEXT NOT NULL UNIQUE,
|
migration TEXT NOT NULL UNIQUE,
|
||||||
applied_at TEXT NOT NULL,
|
applied_at TEXT NOT NULL
|
||||||
content_hash TEXT NULL,
|
|
||||||
content TEXT NULL
|
|
||||||
)";
|
)";
|
||||||
} else {
|
} else {
|
||||||
$sql = "CREATE TABLE IF NOT EXISTS migrations (
|
$sql = "CREATE TABLE IF NOT EXISTS migrations (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
migration VARCHAR(255) NOT NULL UNIQUE,
|
migration VARCHAR(255) NOT NULL UNIQUE,
|
||||||
applied_at DATETIME NOT NULL,
|
applied_at DATETIME NOT NULL
|
||||||
content_hash CHAR(64) NULL,
|
|
||||||
content LONGTEXT NULL
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
|
||||||
}
|
}
|
||||||
$this->pdo->exec($sql);
|
$this->pdo->exec($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureMigrationColumns(): void
|
|
||||||
{
|
|
||||||
$this->ensureColumnExists(
|
|
||||||
'content_hash',
|
|
||||||
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content_hash TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content_hash CHAR(64) NULL DEFAULT NULL AFTER applied_at"
|
|
||||||
);
|
|
||||||
$this->ensureColumnExists(
|
|
||||||
'content',
|
|
||||||
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content LONGTEXT NULL DEFAULT NULL AFTER content_hash"
|
|
||||||
);
|
|
||||||
$this->ensureColumnExists(
|
|
||||||
'result',
|
|
||||||
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN result TEXT NULL" : "ALTER TABLE migrations ADD COLUMN result LONGTEXT NULL DEFAULT NULL AFTER content"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureColumnExists(string $column, string $alterSql): void
|
|
||||||
{
|
|
||||||
if ($this->columnExists('migrations', $column)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->pdo->exec($alterSql);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function columnExists(string $table, string $column): bool
|
|
||||||
{
|
|
||||||
if ($this->isSqlite) {
|
|
||||||
$stmt = $this->pdo->query("PRAGMA table_info({$table})");
|
|
||||||
$columns = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
|
||||||
foreach ($columns as $col) {
|
|
||||||
if (($col['name'] ?? '') === $column) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare("SHOW COLUMNS FROM {$table} LIKE :column");
|
|
||||||
$stmt->execute([':column' => $column]);
|
|
||||||
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listAllMigrations(): array
|
public function listAllMigrations(): array
|
||||||
{
|
{
|
||||||
$files = glob($this->migrationsDir . '/*.sql');
|
$files = glob($this->migrationsDir . '/*.sql');
|
||||||
|
|
@ -128,8 +72,7 @@ class MigrationRunner
|
||||||
{
|
{
|
||||||
$all = $this->listAllMigrations();
|
$all = $this->listAllMigrations();
|
||||||
$applied = $this->listAppliedMigrations();
|
$applied = $this->listAppliedMigrations();
|
||||||
$pending = array_values(array_diff($all, $applied));
|
return array_values(array_diff($all, $applied));
|
||||||
return $this->sortMigrations($pending);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasPendingMigrations(): bool
|
public function hasPendingMigrations(): bool
|
||||||
|
|
@ -138,219 +81,39 @@ class MigrationRunner
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applyPendingMigrations(): array
|
public function applyPendingMigrations(): array
|
||||||
{
|
|
||||||
return $this->runMigrations($this->listPendingMigrations());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyNextMigration(): array
|
|
||||||
{
|
{
|
||||||
$pending = $this->listPendingMigrations();
|
$pending = $this->listPendingMigrations();
|
||||||
if (empty($pending)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return $this->runMigrations([reset($pending)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyMigrationByName(string $migration): array
|
|
||||||
{
|
|
||||||
$pending = $this->listPendingMigrations();
|
|
||||||
if (!in_array($migration, $pending, true)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return $this->runMigrations([$migration]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function runMigrations(array $migrations): array
|
|
||||||
{
|
|
||||||
$appliedNow = [];
|
$appliedNow = [];
|
||||||
if (empty($migrations)) {
|
if (empty($pending)) {
|
||||||
return $appliedNow;
|
return $appliedNow;
|
||||||
}
|
}
|
||||||
$this->lastResults = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->pdo->beginTransaction();
|
$this->pdo->beginTransaction();
|
||||||
foreach ($migrations as $migration) {
|
foreach ($pending as $migration) {
|
||||||
try {
|
|
||||||
$path = $this->migrationsDir . '/' . $migration;
|
$path = $this->migrationsDir . '/' . $migration;
|
||||||
$sql = file_get_contents($path);
|
$sql = file_get_contents($path);
|
||||||
if ($sql === false) {
|
if ($sql === false) {
|
||||||
throw new Exception("Unable to read migration file: {$migration}");
|
throw new Exception("Unable to read migration file: {$migration}");
|
||||||
}
|
}
|
||||||
$trimmedSql = trim($sql);
|
// Split on ; at line ends, but allow inside procedures? Keep simple for our use-cases
|
||||||
$hash = hash('sha256', $trimmedSql);
|
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
|
||||||
|
|
||||||
if ($this->contentHashExists($hash)) {
|
|
||||||
$this->recordMigration($migration, $trimmedSql, $hash);
|
|
||||||
$appliedNow[] = $migration;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$statements = $this->splitStatements($trimmedSql);
|
|
||||||
foreach ($statements as $stmtSql) {
|
foreach ($statements as $stmtSql) {
|
||||||
if ($stmtSql === '') {
|
if ($stmtSql === '') continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$this->pdo->exec($stmtSql);
|
$this->pdo->exec($stmtSql);
|
||||||
}
|
}
|
||||||
$statementCount = count($statements);
|
$ins = $this->pdo->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
|
||||||
$resultMessage = sprintf('Migration "%s" applied successfully (%d statement%s).', $migration, $statementCount, $statementCount === 1 ? '' : 's');
|
$ins->execute([':m' => $migration]);
|
||||||
$this->lastResults[$migration] = [
|
|
||||||
'content' => $trimmedSql,
|
|
||||||
'message' => $resultMessage,
|
|
||||||
'is_test' => $this->isTestMigration($migration)
|
|
||||||
];
|
|
||||||
if ($this->isTestMigration($migration)) {
|
|
||||||
$appliedNow[] = $migration;
|
$appliedNow[] = $migration;
|
||||||
$this->logger->log('info', $resultMessage . ' (test migration)', ['scope' => 'system', 'migration' => $migration]);
|
|
||||||
$this->cleanupTestMigrationFile($migration);
|
|
||||||
} else {
|
|
||||||
$this->recordMigration($migration, $trimmedSql, $hash, $resultMessage);
|
|
||||||
$appliedNow[] = $migration;
|
|
||||||
$this->logger->log('info', $resultMessage, ['scope' => 'system', 'migration' => $migration]);
|
|
||||||
}
|
}
|
||||||
} catch (Exception $migrationException) {
|
|
||||||
throw new MigrationException($migration, $migrationException->getMessage(), $migrationException);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($this->pdo->inTransaction()) {
|
|
||||||
$this->pdo->commit();
|
$this->pdo->commit();
|
||||||
}
|
|
||||||
} catch (MigrationException $e) {
|
|
||||||
if ($this->pdo->inTransaction()) {
|
|
||||||
$this->pdo->rollBack();
|
|
||||||
}
|
|
||||||
$this->logger->log('error', sprintf('Migration "%s" failed: %s', $e->getMigration(), $e->getMessage()), ['scope' => 'system', 'migration' => $e->getMigration()]);
|
|
||||||
throw $e;
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
if ($this->pdo->inTransaction()) {
|
if ($this->pdo->inTransaction()) {
|
||||||
$this->pdo->rollBack();
|
$this->pdo->rollBack();
|
||||||
}
|
}
|
||||||
$this->logger->log('error', 'Migration run failed: ' . $e->getMessage(), ['scope' => 'system']);
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $appliedNow;
|
return $appliedNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function splitStatements(string $sql): array
|
|
||||||
{
|
|
||||||
if ($sql === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function contentHashExists(string $hash): bool
|
|
||||||
{
|
|
||||||
if ($hash === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$stmt = $this->pdo->prepare('SELECT 1 FROM migrations WHERE content_hash = :hash LIMIT 1');
|
|
||||||
$stmt->execute([':hash' => $hash]);
|
|
||||||
return (bool)$stmt->fetchColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordMigration(string $name, string $content, string $hash, ?string $result = null): void
|
|
||||||
{
|
|
||||||
$timestampExpr = $this->isSqlite ? "datetime('now')" : 'NOW()';
|
|
||||||
$sql = "INSERT INTO migrations (migration, applied_at, content_hash, content, result) VALUES (:migration, {$timestampExpr}, :hash, :content, :result)";
|
|
||||||
$stmt = $this->pdo->prepare($sql);
|
|
||||||
$stmt->execute([
|
|
||||||
':migration' => $name,
|
|
||||||
':hash' => $hash,
|
|
||||||
':content' => $content === '' ? null : $content,
|
|
||||||
':result' => $result,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sortMigrations(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isTestMigration(string $migration): bool
|
|
||||||
{
|
|
||||||
return strpos($migration, '_test_migration') !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function cleanupTestMigrationFile(string $migration): void
|
|
||||||
{
|
|
||||||
$path = $this->migrationsDir . '/' . $migration;
|
|
||||||
if (is_file($path)) {
|
|
||||||
@unlink($path);
|
|
||||||
}
|
|
||||||
$stmt = $this->pdo->prepare('DELETE FROM migrations WHERE migration = :migration');
|
|
||||||
$stmt->execute([':migration' => $migration]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markMigrationApplied(string $migration, ?string $note = null): bool
|
|
||||||
{
|
|
||||||
$path = $this->migrationsDir . '/' . $migration;
|
|
||||||
$content = '';
|
|
||||||
if (is_file($path)) {
|
|
||||||
$fileContent = file_get_contents($path);
|
|
||||||
if ($fileContent !== false) {
|
|
||||||
$content = trim($fileContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$hash = $content === '' ? '' : hash('sha256', $content);
|
|
||||||
if ($hash !== '' && $this->contentHashExists($hash)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $note ?? 'Marked as applied manually.';
|
|
||||||
$this->recordMigration($migration, $content, $hash, $result);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function skipMigration(string $migration): bool
|
|
||||||
{
|
|
||||||
$source = $this->migrationsDir . '/' . $migration;
|
|
||||||
if (!is_file($source)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$skippedDir = $this->migrationsDir . '/skipped';
|
|
||||||
if (!is_dir($skippedDir)) {
|
|
||||||
if (!mkdir($skippedDir, 0775, true) && !is_dir($skippedDir)) {
|
|
||||||
throw new Exception('Unable to create skipped migrations directory.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$destination = $skippedDir . '/' . $migration;
|
|
||||||
if (rename($source, $destination)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function initializeLogger(): void
|
|
||||||
{
|
|
||||||
$logger = $GLOBALS['logObject'] ?? null;
|
|
||||||
if (is_object($logger) && method_exists($logger, 'log')) {
|
|
||||||
$this->logger = $logger;
|
|
||||||
} else {
|
|
||||||
$this->logger = new NullLogger();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMigrationRecord(string $migration): ?array
|
|
||||||
{
|
|
||||||
$stmt = $this->pdo->prepare('SELECT migration, applied_at, content, result FROM migrations WHERE migration = :migration LIMIT 1');
|
|
||||||
$stmt->execute([':migration' => $migration]);
|
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
return $row ?: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastResults(): array
|
|
||||||
{
|
|
||||||
return $this->lastResults;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,189 +4,34 @@ namespace App\Core;
|
||||||
|
|
||||||
class PluginManager
|
class PluginManager
|
||||||
{
|
{
|
||||||
/** @var array<string, array{path: string, meta: array}> */
|
|
||||||
private static array $catalog = [];
|
|
||||||
|
|
||||||
/** @var array<string, array{path: string, meta: array}>> */
|
|
||||||
private static array $loaded = [];
|
|
||||||
|
|
||||||
/** @var array<string, array<int, string>> */
|
|
||||||
private static array $dependencyErrors = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all enabled plugins from the given directory.
|
* Loads all enabled plugins from the given directory.
|
||||||
* Enforces declared dependencies before bootstrapping each plugin.
|
|
||||||
*
|
*
|
||||||
* @param string $pluginsDir
|
* @param string $pluginsDir
|
||||||
* @return array<string, array{path: string, meta: array}>
|
* @return array<string, array{path: string, meta: array}>
|
||||||
*/
|
*/
|
||||||
public static function load(string $pluginsDir): array
|
public static function load(string $pluginsDir): array
|
||||||
{
|
{
|
||||||
self::$catalog = self::scanCatalog($pluginsDir);
|
$enabled = [];
|
||||||
self::$loaded = [];
|
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginPath) {
|
||||||
self::$dependencyErrors = [];
|
|
||||||
|
|
||||||
foreach (self::$catalog as $name => $info) {
|
|
||||||
if (empty($info['meta']['enabled'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self::resolve($name);
|
|
||||||
}
|
|
||||||
|
|
||||||
$GLOBALS['plugin_dependency_errors'] = self::$dependencyErrors;
|
|
||||||
|
|
||||||
return self::$loaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $pluginsDir
|
|
||||||
* @return array<string, array{path: string, meta: array}>
|
|
||||||
*/
|
|
||||||
private static function scanCatalog(string $pluginsDir): array
|
|
||||||
{
|
|
||||||
$catalog = [];
|
|
||||||
foreach (glob(rtrim($pluginsDir, '/'). '/*', GLOB_ONLYDIR) as $pluginPath) {
|
|
||||||
$manifest = $pluginPath . '/plugin.json';
|
$manifest = $pluginPath . '/plugin.json';
|
||||||
if (!file_exists($manifest)) {
|
if (!file_exists($manifest)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$meta = json_decode(file_get_contents($manifest), true);
|
$meta = json_decode(file_get_contents($manifest), true);
|
||||||
if (!is_array($meta)) {
|
if (empty($meta['enabled'])) {
|
||||||
$meta = [];
|
continue;
|
||||||
}
|
}
|
||||||
$name = basename($pluginPath);
|
$name = basename($pluginPath);
|
||||||
$catalog[$name] = [
|
$enabled[$name] = [
|
||||||
'path' => $pluginPath,
|
'path' => $pluginPath,
|
||||||
'meta' => $meta,
|
'meta' => $meta,
|
||||||
];
|
];
|
||||||
}
|
$bootstrap = $pluginPath . '/bootstrap.php';
|
||||||
|
|
||||||
return $catalog;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively resolves a plugin and its dependencies.
|
|
||||||
*/
|
|
||||||
private static function resolve(string $plugin, array $stack = []): bool
|
|
||||||
{
|
|
||||||
if (isset(self::$loaded[$plugin])) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset(self::$catalog[$plugin])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($plugin, $stack, true)) {
|
|
||||||
self::$dependencyErrors[$plugin][] = 'Circular dependency detected: ' . implode(' -> ', array_merge($stack, [$plugin]));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = self::$catalog[$plugin]['meta'];
|
|
||||||
if (empty($meta['enabled'])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dependencies = $meta['dependencies'] ?? [];
|
|
||||||
if (!is_array($dependencies)) {
|
|
||||||
$dependencies = [$dependencies];
|
|
||||||
}
|
|
||||||
|
|
||||||
$stack[] = $plugin;
|
|
||||||
foreach ($dependencies as $dependency) {
|
|
||||||
$dependency = trim((string)$dependency);
|
|
||||||
if ($dependency === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isset(self::$catalog[$dependency])) {
|
|
||||||
self::$dependencyErrors[$plugin][] = sprintf('Missing dependency "%s"', $dependency);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (empty(self::$catalog[$dependency]['meta']['enabled'])) {
|
|
||||||
self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" is disabled', $dependency);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!self::resolve($dependency, $stack)) {
|
|
||||||
self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" failed to load', $dependency);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
array_pop($stack);
|
|
||||||
|
|
||||||
if (!empty(self::$dependencyErrors[$plugin])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$bootstrap = self::$catalog[$plugin]['path'] . '/bootstrap.php';
|
|
||||||
if (file_exists($bootstrap)) {
|
if (file_exists($bootstrap)) {
|
||||||
include_once $bootstrap;
|
include_once $bootstrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$loaded[$plugin] = self::$catalog[$plugin];
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
return $enabled;
|
||||||
/**
|
|
||||||
* Returns the scanned plugin catalog (enabled and disabled).
|
|
||||||
*
|
|
||||||
* @return array<string, array{path: string, meta: array}>
|
|
||||||
*/
|
|
||||||
public static function getCatalog(): array
|
|
||||||
{
|
|
||||||
return self::$catalog;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all plugins that successfully loaded (dependencies satisfied).
|
|
||||||
*
|
|
||||||
* @return array<string, array{path: string, meta: array}>
|
|
||||||
*/
|
|
||||||
public static function getLoaded(): array
|
|
||||||
{
|
|
||||||
return self::$loaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns dependency validation errors collected during load.
|
|
||||||
*
|
|
||||||
* @return array<string, array<int, string>>
|
|
||||||
*/
|
|
||||||
public static function getDependencyErrors(): array
|
|
||||||
{
|
|
||||||
return self::$dependencyErrors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists a plugin's enabled flag back to its manifest.
|
|
||||||
*/
|
|
||||||
public static function setEnabled(string $plugin, bool $enabled): bool
|
|
||||||
{
|
|
||||||
if (!isset(self::$catalog[$plugin])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$manifestPath = self::$catalog[$plugin]['path'] . '/plugin.json';
|
|
||||||
if (!is_file($manifestPath) || !is_readable($manifestPath) || !is_writable($manifestPath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = file_get_contents($manifestPath);
|
|
||||||
$data = json_decode($raw ?: '', true);
|
|
||||||
if (!is_array($data)) {
|
|
||||||
$data = self::$catalog[$plugin]['meta'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['enabled'] = $enabled;
|
|
||||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
|
||||||
if (file_put_contents($manifestPath, $json, LOCK_EX) === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::$catalog[$plugin]['meta'] = $data;
|
|
||||||
if (!$enabled && isset(self::$loaded[$plugin])) {
|
|
||||||
unset(self::$loaded[$plugin]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email Template Helper
|
|
||||||
*
|
|
||||||
* Provides functions to render email templates with variable substitution
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render email template with variables
|
|
||||||
*
|
|
||||||
* @param string $templateName Template filename (without extension)
|
|
||||||
* @param array $variables Variables to substitute
|
|
||||||
* @param array $options Additional options
|
|
||||||
* @return string Rendered template content
|
|
||||||
*/
|
|
||||||
function renderEmailTemplate($templateName, $variables = [], array $options = []) {
|
|
||||||
$searchPaths = [];
|
|
||||||
|
|
||||||
// Explicit plugin template path takes priority
|
|
||||||
if (!empty($options['plugin_template'])) {
|
|
||||||
$searchPaths[] = rtrim((string)$options['plugin_template'], DIRECTORY_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin name maps to its templates directory (if registered)
|
|
||||||
if (!empty($options['plugin'])) {
|
|
||||||
$pluginKey = (string)$options['plugin'];
|
|
||||||
$pluginInfo = $GLOBALS['enabled_plugins'][$pluginKey] ?? null;
|
|
||||||
if (!empty($pluginInfo['path'])) {
|
|
||||||
$pluginBase = rtrim($pluginInfo['path'], DIRECTORY_SEPARATOR);
|
|
||||||
|
|
||||||
// We search for email templates in the following locations:
|
|
||||||
// we can add more locations if needed, but "views/emails" is the standard location
|
|
||||||
$searchPaths[] = $pluginBase . '/views/emails';
|
|
||||||
$searchPaths[] = $pluginBase . '/views/email';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to core app templates
|
|
||||||
$searchPaths[] = __DIR__ . '/../templates/emails';
|
|
||||||
|
|
||||||
$templateFile = null;
|
|
||||||
foreach ($searchPaths as $basePath) {
|
|
||||||
$candidate = rtrim($basePath, DIRECTORY_SEPARATOR) . '/' . $templateName . '.txt';
|
|
||||||
if (is_file($candidate)) {
|
|
||||||
$templateFile = $candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($templateFile === null) {
|
|
||||||
throw new RuntimeException("Email template '$templateName' not found in any configured template paths");
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = file_get_contents($templateFile);
|
|
||||||
|
|
||||||
// Replace {{variable}} placeholders
|
|
||||||
foreach ($variables as $key => $value) {
|
|
||||||
$content = str_replace('{{' . $key . '}}', $value, $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email using template
|
|
||||||
*
|
|
||||||
* @param string $to Recipient email
|
|
||||||
* @param string $subject Email subject
|
|
||||||
* @param string $templateName Template name
|
|
||||||
* @param array $variables Template variables
|
|
||||||
* @param array $config Application config
|
|
||||||
* @param array $additionalHeaders Additional email headers
|
|
||||||
* @param array $options Additional options
|
|
||||||
* @return bool Success status
|
|
||||||
*/
|
|
||||||
function sendTemplateEmail($to, $subject, $templateName, $variables, $config, $additionalHeaders = [], array $options = []) {
|
|
||||||
try {
|
|
||||||
$message = renderEmailTemplate($templateName, $variables, $options);
|
|
||||||
|
|
||||||
$fromDomain = $config['domain'] ?? ($_SERVER['HTTP_HOST'] ?? 'totalmeet.local');
|
|
||||||
$headers = array_merge([
|
|
||||||
'From: noreply@' . $fromDomain,
|
|
||||||
'X-Mailer: PHP/' . phpversion(),
|
|
||||||
'Content-Type: text/plain; charset=UTF-8'
|
|
||||||
], $additionalHeaders);
|
|
||||||
|
|
||||||
return mail($to, $subject, $message, implode("\r\n", $headers));
|
|
||||||
} catch (Exception $e) {
|
|
||||||
error_log("Failed to send template email: " . $e->getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,25 +13,3 @@ function getLoggerInstance($database) {
|
||||||
require_once __DIR__ . '/../core/NullLogger.php';
|
require_once __DIR__ . '/../core/NullLogger.php';
|
||||||
return new \App\Core\NullLogger();
|
return new \App\Core\NullLogger();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('app_log')) {
|
|
||||||
/**
|
|
||||||
* Lightweight logging helper that prefers the plugin logger but falls back to NullLogger.
|
|
||||||
*/
|
|
||||||
function app_log(string $level, string $message, array $context = []): void {
|
|
||||||
global $logObject;
|
|
||||||
|
|
||||||
if (isset($logObject) && is_object($logObject) && method_exists($logObject, 'log')) {
|
|
||||||
$logObject->log($level, $message, $context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
static $fallbackLogger = null;
|
|
||||||
if ($fallbackLogger === null) {
|
|
||||||
require_once __DIR__ . '/../core/NullLogger.php';
|
|
||||||
$fallbackLogger = new \App\Core\NullLogger();
|
|
||||||
}
|
|
||||||
|
|
||||||
$fallbackLogger->log($level, $message, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,81 @@
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="pagination">
|
||||||
<?php
|
<?php
|
||||||
/**
|
|
||||||
* Pagination helper
|
|
||||||
* @param string $url Base URL for pagination links
|
|
||||||
* @param int $browse_page Current page number
|
|
||||||
* @param int $page_count Total number of pages
|
|
||||||
*/
|
|
||||||
function renderPagination($url, $browse_page = 1, $page_count = 1) {
|
|
||||||
$param = '';
|
$param = '';
|
||||||
// calls
|
if (isset($_REQUEST['id'])) {
|
||||||
|
$param .= '&id=' . htmlspecialchars($_REQUEST['id']);
|
||||||
|
}
|
||||||
if (isset($_REQUEST['name'])) {
|
if (isset($_REQUEST['name'])) {
|
||||||
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
|
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
|
||||||
}
|
}
|
||||||
if (isset($_REQUEST['invitees'])) {
|
if (isset($_REQUEST['ip'])) {
|
||||||
$param .= '&invitees=' . htmlspecialchars($_REQUEST['invitees']);
|
$param .= '&ip=' . htmlspecialchars($_REQUEST['ip']);
|
||||||
}
|
}
|
||||||
if (isset($_REQUEST['description'])) {
|
if (isset($_REQUEST['event'])) {
|
||||||
$param .= '&description=' . htmlspecialchars($_REQUEST['description']);
|
$param .= '&event=' . htmlspecialchars($_REQUEST['event']);
|
||||||
}
|
}
|
||||||
if (isset($_REQUEST['filter'])) {
|
|
||||||
$param .= '&filter=' . htmlspecialchars($_REQUEST['filter']);
|
|
||||||
}
|
|
||||||
// contacts
|
|
||||||
if (isset($_REQUEST['name'])) {
|
|
||||||
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
|
|
||||||
}
|
|
||||||
if (isset($_REQUEST['phone'])) {
|
|
||||||
$param .= '&phone=' . htmlspecialchars($_REQUEST['phone']);
|
|
||||||
}
|
|
||||||
if (isset($_REQUEST['email'])) {
|
|
||||||
$param .= '&email=' . htmlspecialchars($_REQUEST['email']);
|
|
||||||
}
|
|
||||||
// messages
|
|
||||||
if (isset($_REQUEST['from'])) {
|
|
||||||
$param .= '&from=' . htmlspecialchars($_REQUEST['from']);
|
|
||||||
}
|
|
||||||
if (isset($_REQUEST['to'])) {
|
|
||||||
$param .= '&to=' . htmlspecialchars($_REQUEST['to']);
|
|
||||||
}
|
|
||||||
if (isset($_REQUEST['subject'])) {
|
|
||||||
$param .= '&subject=' . htmlspecialchars($_REQUEST['subject']);
|
|
||||||
}
|
|
||||||
// notifications
|
|
||||||
if (isset($_REQUEST['message'])) {
|
|
||||||
$param .= '&message=' . htmlspecialchars($_REQUEST['message']);
|
|
||||||
}
|
|
||||||
// time period
|
|
||||||
if (isset($_REQUEST['from_time'])) {
|
if (isset($_REQUEST['from_time'])) {
|
||||||
$param .= '&from_time=' . htmlspecialchars($_REQUEST['from_time']);
|
$param .= '&from_time=' . htmlspecialchars($from_time);
|
||||||
}
|
}
|
||||||
if (isset($_REQUEST['until_time'])) {
|
if (isset($_REQUEST['until_time'])) {
|
||||||
$param .= '&until_time=' . htmlspecialchars($_REQUEST['until_time']);
|
$param .= '&until_time=' . htmlspecialchars($until_time);
|
||||||
}
|
}
|
||||||
|
|
||||||
$max_visible_pages = 10;
|
$max_visible_pages = 10;
|
||||||
$step_pages = 10;
|
$step_pages = 10;
|
||||||
|
|
||||||
echo '<div class="tm-pagination text-center"><div class="pagination">';
|
|
||||||
|
|
||||||
if ($browse_page > 1) {
|
if ($browse_page > 1) {
|
||||||
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=1' . $param . '">first</a>';
|
echo '<span><a href="' . htmlspecialchars($url) . '&p=1">first</a></span>';
|
||||||
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page - 1) . $param . '">«</a>';
|
|
||||||
} else {
|
} else {
|
||||||
echo '<span class="pagination-link disabled">first</span>';
|
echo '<span>first</span>';
|
||||||
echo '<span class="pagination-link disabled">«</span>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($i = 1; $i <= $page_count; $i++) {
|
for ($i = 1; $i <= $page_count; $i++) {
|
||||||
// always show the first, last, step pages (10, 20, 30, etc.),
|
// always show the first, last, step pages (10, 20, 30, etc.),
|
||||||
// and pages around current page
|
// and the pages close to the current one
|
||||||
if ($i == 1 || $i == $page_count ||
|
if (
|
||||||
$i % $step_pages == 0 ||
|
$i === 1 || // first page
|
||||||
abs($i - $browse_page) < $max_visible_pages / 2) {
|
$i === $page_count || // last page
|
||||||
|
$i === $browse_page || // current page
|
||||||
if ($i == $browse_page) {
|
$i === $browse_page -1 ||
|
||||||
echo '<span class="pagination-link active">' . $i . '</span>';
|
$i === $browse_page +1 ||
|
||||||
|
$i === $browse_page -2 ||
|
||||||
|
$i === $browse_page +2 ||
|
||||||
|
($i % $step_pages === 0 && $i > $max_visible_pages) // the step pages - 10, 20, etc.
|
||||||
|
) {
|
||||||
|
if ($i === $browse_page) {
|
||||||
|
// current page, no link
|
||||||
|
if ($browse_page > 1) {
|
||||||
|
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) -1) . '"><<</a></span>';
|
||||||
} else {
|
} else {
|
||||||
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $i . $param . '">' . $i . '</a>';
|
echo '<span><<</span>';
|
||||||
}
|
}
|
||||||
} elseif ($i == 2 || $i == $page_count - 1 ||
|
echo '[' . htmlspecialchars($i) . ']';
|
||||||
($i > $browse_page + $max_visible_pages / 2 && $i % $step_pages == 1) ||
|
|
||||||
($i < $browse_page - $max_visible_pages / 2 && $i % $step_pages == $step_pages - 1)) {
|
if ($browse_page < $page_count) {
|
||||||
echo '<span class="pagination-link pagination-ellipsis disabled">...</span>';
|
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) +1) . '">>></a></span>';
|
||||||
|
} else {
|
||||||
|
echo '<span>>></span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// other pages
|
||||||
|
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . htmlspecialchars($i) . '">[' . htmlspecialchars($i) . ']</a></span>';
|
||||||
|
}
|
||||||
|
// show ellipses between distant pages
|
||||||
|
} elseif (
|
||||||
|
$i === $browse_page -3 ||
|
||||||
|
$i === $browse_page +3
|
||||||
|
) {
|
||||||
|
echo '<span>...</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($browse_page < $page_count) {
|
if ($browse_page < $page_count) {
|
||||||
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page + 1) . $param . '">»</a>';
|
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($page_count)) . '">last</a></span>';
|
||||||
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $page_count . $param . '">last</a>';
|
|
||||||
} else {
|
} else {
|
||||||
echo '<span class="pagination-link disabled">»</span>';
|
echo '<span>last</span>';
|
||||||
echo '<span class="pagination-link disabled">last</span>';
|
|
||||||
}
|
}
|
||||||
|
?>
|
||||||
echo '</div></div>';
|
</div>
|
||||||
}
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -284,115 +284,6 @@ EOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get descriptive metadata for a theme.
|
|
||||||
*
|
|
||||||
* @param string $themeId
|
|
||||||
* @return array{name:string,description:string,version:string,author:string,tags:array}
|
|
||||||
*/
|
|
||||||
public static function getThemeMetadata(string $themeId): array
|
|
||||||
{
|
|
||||||
static $cache = [];
|
|
||||||
if (isset($cache[$themeId])) {
|
|
||||||
return $cache[$themeId];
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = self::getConfig();
|
|
||||||
$defaults = $config['default_config'] ?? [];
|
|
||||||
$availableEntry = $config['available_themes'][$themeId] ?? null;
|
|
||||||
|
|
||||||
$metadata = [
|
|
||||||
'name' => is_array($availableEntry) ? ($availableEntry['name'] ?? ucfirst($themeId)) : ($availableEntry ?? ucfirst($themeId)),
|
|
||||||
'description' => $defaults['description'] ?? '',
|
|
||||||
'version' => $defaults['version'] ?? '',
|
|
||||||
'author' => $defaults['author'] ?? '',
|
|
||||||
'tags' => [],
|
|
||||||
'type' => $themeId === 'default' ? 'Core built-in' : 'Custom',
|
|
||||||
'path' => $themeId === 'default' ? 'app/templates' : ('themes/' . $themeId),
|
|
||||||
'last_modified' => null,
|
|
||||||
'file_count' => null
|
|
||||||
];
|
|
||||||
|
|
||||||
if (is_array($availableEntry)) {
|
|
||||||
$metadata = array_merge($metadata, array_intersect_key($availableEntry, array_flip(['name', 'description', 'version', 'author', 'tags'])));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($themeId !== 'default') {
|
|
||||||
$themesDir = rtrim($config['paths']['themes'] ?? (__DIR__ . '/../../themes'), '/');
|
|
||||||
$themeConfigPath = $themesDir . '/' . $themeId . '/config.php';
|
|
||||||
if (file_exists($themeConfigPath)) {
|
|
||||||
$themeConfig = require $themeConfigPath;
|
|
||||||
if (is_array($themeConfig)) {
|
|
||||||
$metadata = array_merge($metadata, array_intersect_key($themeConfig, array_flip(['name', 'description', 'version', 'author', 'tags'])));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($metadata['description'])) {
|
|
||||||
$metadata['description'] = $defaults['description'] ?? 'A Jilo Web theme';
|
|
||||||
}
|
|
||||||
if (empty($metadata['version'])) {
|
|
||||||
$metadata['version'] = $defaults['version'] ?? '1.0.0';
|
|
||||||
}
|
|
||||||
if (empty($metadata['author'])) {
|
|
||||||
$metadata['author'] = $defaults['author'] ?? 'Lindeas';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($metadata['tags']) || !is_array($metadata['tags'])) {
|
|
||||||
$metadata['tags'] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$paths = $config['paths'] ?? [];
|
|
||||||
if ($themeId === 'default') {
|
|
||||||
$absolutePath = realpath($paths['templates'] ?? (__DIR__ . '/../templates')) ?: null;
|
|
||||||
} else {
|
|
||||||
$absolutePath = self::getThemePath($themeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($absolutePath && is_dir($absolutePath)) {
|
|
||||||
[$lastModified, $fileCount] = self::getDirectoryStats($absolutePath);
|
|
||||||
if ($lastModified !== null) {
|
|
||||||
$metadata['last_modified'] = $lastModified;
|
|
||||||
}
|
|
||||||
if ($fileCount > 0) {
|
|
||||||
$metadata['file_count'] = $fileCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cache[$themeId] = $metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate directory statistics for a theme folder.
|
|
||||||
*/
|
|
||||||
private static function getDirectoryStats(string $path): array
|
|
||||||
{
|
|
||||||
$latest = null;
|
|
||||||
$count = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
|
|
||||||
);
|
|
||||||
foreach ($iterator as $fileInfo) {
|
|
||||||
if (!$fileInfo->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$count++;
|
|
||||||
$mtime = $fileInfo->getMTime();
|
|
||||||
if ($latest === null || $mtime > $latest) {
|
|
||||||
$latest = $mtime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return [null, 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$latest, $count];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the URL for a theme asset
|
* Get the URL for a theme asset
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get any old feedback messages
|
||||||
|
include_once '../app/helpers/feedback.php';
|
||||||
|
|
||||||
|
// Handle actions
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
// AJAX: view migration file contents
|
||||||
|
if ($action === 'read_migration') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// CSRF check
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission check
|
||||||
|
if (!$canAdmin) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filename to avoid traversal
|
||||||
|
$filename = basename($_POST['filename'] ?? '');
|
||||||
|
if ($filename === '' || !preg_match('/^[A-Za-z0-9_\-]+\.sql$/', $filename)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid filename']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Clean up any test migration files after applying
|
||||||
|
if (!empty($applied)) {
|
||||||
|
foreach ($applied as $migration) {
|
||||||
|
if (strpos($migration, '_test_migration.sql') !== false) {
|
||||||
|
$filepath = $migrationsDir . '/' . $migration;
|
||||||
|
if (file_exists($filepath)) {
|
||||||
|
unlink($filepath);
|
||||||
|
}
|
||||||
|
// Remove from database migrations table to leave no trace
|
||||||
|
$stmt = $db->getConnection()->prepare("DELETE FROM migrations WHERE migration = :migration");
|
||||||
|
$stmt->execute([':migration' => $migration]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($applied)) {
|
||||||
|
Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
|
||||||
|
} else {
|
||||||
|
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migrations: ' . implode(', ', $applied), true);
|
||||||
|
}
|
||||||
|
} elseif ($action === 'create_test_migration') {
|
||||||
|
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
|
||||||
|
$timestamp = date('Ymd_His');
|
||||||
|
$filename = $timestamp . '_test_migration.sql';
|
||||||
|
$filepath = $migrationsDir . '/' . $filename;
|
||||||
|
|
||||||
|
// Create a simple test migration that adds a test setting (MariaDB compatible)
|
||||||
|
$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 ($action === 'clear_test_migrations') {
|
||||||
|
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
|
||||||
|
|
||||||
|
// Find and remove test migration files
|
||||||
|
$testFiles = glob($migrationsDir . '/*_test_migration.sql');
|
||||||
|
$removedCount = 0;
|
||||||
|
|
||||||
|
foreach ($testFiles as $file) {
|
||||||
|
$filename = basename($file);
|
||||||
|
if (file_exists($file)) {
|
||||||
|
unlink($file);
|
||||||
|
$removedCount++;
|
||||||
|
}
|
||||||
|
// Remove from database migrations table to leave no trace
|
||||||
|
$stmt = $db->getConnection()->prepare("DELETE FROM migrations WHERE migration = :migration");
|
||||||
|
$stmt->execute([':migration' => $filename]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($removedCount > 0) {
|
||||||
|
Feedback::flash('NOTICE', 'DEFAULT', 'Cleared ' . $removedCount . ' test migration(s)', true);
|
||||||
|
} else {
|
||||||
|
Feedback::flash('NOTICE', 'DEFAULT', 'No test migrations to clear', 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 = [];
|
||||||
|
$migration_contents = [];
|
||||||
|
$test_migrations_exist = false;
|
||||||
|
try {
|
||||||
|
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
|
||||||
|
$pending = $runner->listPendingMigrations();
|
||||||
|
$applied = $runner->listAppliedMigrations();
|
||||||
|
|
||||||
|
// Check if any test migrations exist
|
||||||
|
$test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql'));
|
||||||
|
|
||||||
|
// Preload contents for billing-admin style modals
|
||||||
|
$all = array_unique(array_merge($pending, $applied));
|
||||||
|
foreach ($all as $fname) {
|
||||||
|
$path = realpath($migrationsDir . '/' . $fname);
|
||||||
|
if ($path && strpos($path, realpath($migrationsDir)) === 0) {
|
||||||
|
$content = @file_get_contents($path);
|
||||||
|
if ($content !== false) {
|
||||||
|
$migration_contents[$fname] = $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// show error in the page
|
||||||
|
$migration_error = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF token
|
||||||
|
$csrf_token = $security->generateCsrfToken();
|
||||||
|
|
||||||
|
// Load the template
|
||||||
|
include __DIR__ . '/../templates/admin-tools.php';
|
||||||
|
|
@ -1,461 +0,0 @@
|
||||||
<?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 __DIR__ . '/../core/PluginManager.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the user has admin permissions
|
|
||||||
$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'],
|
|
||||||
'plugins' => ['label' => 'Plugins', 'position' => 400, 'hook' => null, 'type' => 'core'],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Register sections for plugins
|
|
||||||
$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'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$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 : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get plugin catalog and list of loaded plugins
|
|
||||||
// with their dependencies
|
|
||||||
$pluginCatalog = \App\Core\PluginManager::getCatalog();
|
|
||||||
$pluginLoadedMap = \App\Core\PluginManager::getLoaded();
|
|
||||||
$pluginDependencyErrors = \App\Core\PluginManager::getDependencyErrors();
|
|
||||||
|
|
||||||
$normalizeDependencies = static function ($meta): array {
|
|
||||||
$deps = $meta['dependencies'] ?? [];
|
|
||||||
if (!is_array($deps)) {
|
|
||||||
$deps = $deps === null || $deps === '' ? [] : [$deps];
|
|
||||||
}
|
|
||||||
$deps = array_map('trim', $deps);
|
|
||||||
$deps = array_filter($deps, static function($dep) {
|
|
||||||
return $dep !== '';
|
|
||||||
});
|
|
||||||
return array_values(array_unique($deps));
|
|
||||||
};
|
|
||||||
|
|
||||||
$pluginDependentsIndex = [];
|
|
||||||
foreach ($pluginCatalog as $slug => $info) {
|
|
||||||
$deps = $normalizeDependencies($info['meta'] ?? []);
|
|
||||||
foreach ($deps as $dep) {
|
|
||||||
$pluginDependentsIndex[$dep][] = $slug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build plugin admin map with details, state and dependencies
|
|
||||||
$pluginAdminMap = [];
|
|
||||||
foreach ($pluginCatalog as $slug => $info) {
|
|
||||||
$meta = $info['meta'] ?? [];
|
|
||||||
$name = trim((string)($meta['name'] ?? $slug));
|
|
||||||
$enabled = !empty($meta['enabled']);
|
|
||||||
$dependencies = $normalizeDependencies($meta);
|
|
||||||
$dependents = array_values($pluginDependentsIndex[$slug] ?? []);
|
|
||||||
$enabledDependents = array_values(array_filter($dependents, static function($depSlug) use ($pluginCatalog) {
|
|
||||||
return !empty($pluginCatalog[$depSlug]['meta']['enabled']);
|
|
||||||
}));
|
|
||||||
$missingDependencies = array_values(array_filter($dependencies, static function($depSlug) use ($pluginCatalog) {
|
|
||||||
return !isset($pluginCatalog[$depSlug]) || empty($pluginCatalog[$depSlug]['meta']['enabled']);
|
|
||||||
}));
|
|
||||||
|
|
||||||
$pluginAdminMap[$slug] = [
|
|
||||||
'slug' => $slug,
|
|
||||||
'name' => $name,
|
|
||||||
'version' => (string)($meta['version'] ?? ''),
|
|
||||||
'description' => (string)($meta['description'] ?? ''),
|
|
||||||
'enabled' => $enabled,
|
|
||||||
'loaded' => isset($pluginLoadedMap[$slug]),
|
|
||||||
'dependencies' => $dependencies,
|
|
||||||
'dependents' => $dependents,
|
|
||||||
'enabled_dependents' => $enabledDependents,
|
|
||||||
'missing_dependencies' => $missingDependencies,
|
|
||||||
'dependency_errors' => $pluginDependencyErrors[$slug] ?? [],
|
|
||||||
'can_enable' => !$enabled && empty($missingDependencies),
|
|
||||||
'can_disable' => $enabled && empty($enabledDependents),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$pluginAdminList = array_values($pluginAdminMap);
|
|
||||||
usort($pluginAdminList, static function(array $a, array $b): int {
|
|
||||||
return strcmp(strtolower($a['name']), strtolower($b['name']));
|
|
||||||
});
|
|
||||||
|
|
||||||
$sectionState['plugins'] = [
|
|
||||||
'plugins' => $pluginAdminList,
|
|
||||||
'dependency_errors' => $pluginDependencyErrors,
|
|
||||||
'plugin_index' => $pluginAdminMap,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Prepare the DB migrations details
|
|
||||||
$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 {
|
|
||||||
// Maintenance actions
|
|
||||||
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);
|
|
||||||
// DB migrations actions
|
|
||||||
} 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];
|
|
||||||
}
|
|
||||||
// Plugin actions
|
|
||||||
} elseif ($postAction === 'plugin_enable' || $postAction === 'plugin_disable') {
|
|
||||||
$slug = strtolower(trim($_POST['plugin'] ?? ''));
|
|
||||||
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
|
|
||||||
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
|
|
||||||
} else {
|
|
||||||
$pluginMeta = $pluginAdminMap[$slug];
|
|
||||||
if ($postAction === 'plugin_enable') {
|
|
||||||
if (!$pluginMeta['can_enable']) {
|
|
||||||
$reason = 'Resolve missing dependencies before enabling this plugin.';
|
|
||||||
if (!empty($pluginMeta['missing_dependencies'])) {
|
|
||||||
$reason = 'Enable required plugins first: ' . implode(', ', $pluginMeta['missing_dependencies']);
|
|
||||||
}
|
|
||||||
Feedback::flash('ERROR', 'DEFAULT', $reason, false);
|
|
||||||
} elseif (!\App\Core\PluginManager::setEnabled($slug, true)) {
|
|
||||||
Feedback::flash('ERROR', 'DEFAULT', 'Failed to enable plugin. Check file permissions on plugin.json.', false);
|
|
||||||
} else {
|
|
||||||
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled. Reload admin to finish loading it.', $pluginMeta['name']), true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!$pluginMeta['can_disable']) {
|
|
||||||
$reason = 'Disable dependent plugins first: ' . implode(', ', $pluginMeta['enabled_dependents']);
|
|
||||||
Feedback::flash('ERROR', 'DEFAULT', $reason, false);
|
|
||||||
} elseif (!\App\Core\PluginManager::setEnabled($slug, false)) {
|
|
||||||
Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable plugin. Check file permissions on plugin.json.', false);
|
|
||||||
} else {
|
|
||||||
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" disabled.', $pluginMeta['name']), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Test migrations actions
|
|
||||||
} 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';
|
|
||||||
|
|
@ -23,11 +23,8 @@ $item = $_REQUEST['item'] ?? '';
|
||||||
|
|
||||||
// if a form is submitted
|
// if a form is submitted
|
||||||
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
||||||
// Ensure security helper is available
|
|
||||||
require_once '../app/helpers/security.php';
|
|
||||||
$security = SecurityHelper::getInstance();
|
|
||||||
|
|
||||||
// Validate CSRF token
|
// Validate CSRF token
|
||||||
|
$security->verifyCsrfToken($_POST['csrf_token'] ?? '');
|
||||||
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
|
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.');
|
Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.');
|
||||||
header("Location: $app_root?page=credentials");
|
header("Location: $app_root?page=credentials");
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ if ($response['db'] === null) {
|
||||||
|
|
||||||
|
|
||||||
// display the widget
|
// display the widget
|
||||||
include '../app/templates/dashboard-monthly.php';
|
include '../app/templates/widget-monthly.php';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -154,7 +154,7 @@ if ($response['db'] === null) {
|
||||||
$widget['pagination'] = false;
|
$widget['pagination'] = false;
|
||||||
|
|
||||||
// display the widget
|
// display the widget
|
||||||
include '../app/templates/dashboard-conferences.php';
|
include '../app/templates/widget.php';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -224,6 +224,6 @@ if ($response['db'] === null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// display the widget
|
// display the widget
|
||||||
include '../app/templates/dashboard-conferences.php';
|
include '../app/templates/widget.php';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,6 @@ function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $app_ro
|
||||||
) {
|
) {
|
||||||
$redirect = $candidate;
|
$redirect = $candidate;
|
||||||
}
|
}
|
||||||
header('Location: ' . $redirect);
|
header('Location: ' . htmlspecialchars($redirect));
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Plugin Asset handler
|
|
||||||
*
|
|
||||||
* Serves plugin assets (CSS, JS, images, fonts, etc.) securely by validating the
|
|
||||||
* requested plugin name, asset path and enabled status. This way each plugin can
|
|
||||||
* keep its assets in its local folders and only load them when needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once __DIR__ . '/../classes/session.php';
|
|
||||||
|
|
||||||
$pluginName = $_GET['plugin'] ?? '';
|
|
||||||
$assetPath = $_GET['path'] ?? '';
|
|
||||||
|
|
||||||
$sendError = static function(int $code, string $message): void {
|
|
||||||
http_response_code($code);
|
|
||||||
header('Content-Type: text/plain');
|
|
||||||
exit($message);
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($pluginName === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $pluginName)) {
|
|
||||||
$sendError(400, 'Invalid plugin specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($assetPath === '') {
|
|
||||||
$sendError(400, 'No asset path specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9_\-.\/]+$/', $assetPath) || strpos($assetPath, '..') !== false) {
|
|
||||||
$sendError(400, 'Invalid asset path');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($GLOBALS['enabled_plugins'][$pluginName])) {
|
|
||||||
$sendError(404, 'Plugin not enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
$pluginsRoot = realpath(dirname(__DIR__, 2) . '/plugins');
|
|
||||||
if ($pluginsRoot === false) {
|
|
||||||
$sendError(500, 'Plugins directory missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
$pluginBase = realpath($pluginsRoot . '/' . $pluginName);
|
|
||||||
if ($pluginBase === false || strpos($pluginBase, $pluginsRoot) !== 0) {
|
|
||||||
$sendError(404, 'Plugin not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fullPath = realpath($pluginBase . '/' . ltrim($assetPath, '/'));
|
|
||||||
if ($fullPath === false || strpos($fullPath, $pluginBase) !== 0 || !is_file($fullPath) || !is_readable($fullPath)) {
|
|
||||||
$sendError(404, 'Asset not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
|
||||||
$contentTypes = [
|
|
||||||
'css' => 'text/css',
|
|
||||||
'js' => 'application/javascript',
|
|
||||||
'json' => 'application/json',
|
|
||||||
'png' => 'image/png',
|
|
||||||
'jpg' => 'image/jpeg',
|
|
||||||
'jpeg' => 'image/jpeg',
|
|
||||||
'gif' => 'image/gif',
|
|
||||||
'svg' => 'image/svg+xml',
|
|
||||||
'webp' => 'image/webp',
|
|
||||||
'woff' => 'font/woff',
|
|
||||||
'woff2'=> 'font/woff2',
|
|
||||||
'ttf' => 'font/ttf',
|
|
||||||
'eot' => 'application/vnd.ms-fontobject'
|
|
||||||
];
|
|
||||||
|
|
||||||
header('Content-Type: ' . ($contentTypes[$extension] ?? 'application/octet-stream'));
|
|
||||||
header('Content-Length: ' . filesize($fullPath));
|
|
||||||
header('Cache-Control: public, max-age=86400');
|
|
||||||
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT');
|
|
||||||
|
|
||||||
readfile($fullPath);
|
|
||||||
|
|
@ -14,20 +14,6 @@
|
||||||
|
|
||||||
$action = $_REQUEST['action'] ?? '';
|
$action = $_REQUEST['action'] ?? '';
|
||||||
$item = $_REQUEST['item'] ?? '';
|
$item = $_REQUEST['item'] ?? '';
|
||||||
// pass the user details to the profile hooks
|
|
||||||
$profileHooksContext = [
|
|
||||||
'userId' => $userId ?? null,
|
|
||||||
'db' => $db ?? null,
|
|
||||||
'app_root' => $app_root ?? '/',
|
|
||||||
'user' => $userDetails[0] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (class_exists('\\App\\Core\\HookDispatcher')) {
|
|
||||||
$profileHooksContext = \App\Core\HookDispatcher::applyFilters('profile.context', $profileHooksContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
// plugins can add additional panels to the profile page
|
|
||||||
$profilePanelsContext = $profileHooksContext;
|
|
||||||
|
|
||||||
// if a form is submitted, it's from the edit page
|
// if a form is submitted, it's from the edit page
|
||||||
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
||||||
|
|
|
||||||
|
|
@ -51,20 +51,11 @@ if (isset($_GET['switch_to'])) {
|
||||||
$themes = \App\Helpers\Theme::getAvailableThemes();
|
$themes = \App\Helpers\Theme::getAvailableThemes();
|
||||||
$currentTheme = \App\Helpers\Theme::getCurrentThemeName();
|
$currentTheme = \App\Helpers\Theme::getCurrentThemeName();
|
||||||
|
|
||||||
// Prepare theme data with screenshot URLs and metadata for the view
|
// Prepare theme data with screenshot URLs for the view
|
||||||
$themeData = [];
|
$themeData = [];
|
||||||
foreach ($themes as $id => $name) {
|
foreach ($themes as $id => $name) {
|
||||||
$meta = \App\Helpers\Theme::getThemeMetadata($id);
|
|
||||||
$themeData[$id] = [
|
$themeData[$id] = [
|
||||||
'name' => $meta['name'] ?? $name,
|
'name' => $name,
|
||||||
'description' => $meta['description'] ?? '',
|
|
||||||
'version' => $meta['version'] ?? '',
|
|
||||||
'author' => $meta['author'] ?? '',
|
|
||||||
'tags' => $meta['tags'] ?? [],
|
|
||||||
'type' => $meta['type'] ?? '',
|
|
||||||
'path' => $meta['path'] ?? '',
|
|
||||||
'last_modified' => $meta['last_modified'] ?? null,
|
|
||||||
'file_count' => $meta['file_count'] ?? null,
|
|
||||||
'screenshotUrl' => \App\Helpers\Theme::getAssetUrl($id, 'screenshot.png'),
|
'screenshotUrl' => \App\Helpers\Theme::getAssetUrl($id, 'screenshot.png'),
|
||||||
'isActive' => $id === $currentTheme
|
'isActive' => $id === $currentTheme
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
/** @var bool $maintenance_enabled */
|
||||||
|
/** @var string $maintenance_message */
|
||||||
|
/** @var array $pending */
|
||||||
|
/** @var array $applied */
|
||||||
|
/** @var string $csrf_token */
|
||||||
|
?>
|
||||||
|
<!-- admin tools page -->
|
||||||
|
<div class="container-fluid mt-2">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12 mb-2">
|
||||||
|
<h2>Admin tools</h2>
|
||||||
|
<small class="text-muted">System maintenance and database utilities.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fas fa-tools me-2 text-secondary"></i>
|
||||||
|
Maintenance mode
|
||||||
|
</h5>
|
||||||
|
<span class="badge <?= $maintenance_enabled ? 'bg-danger' : 'bg-success' ?>"><?= $maintenance_enabled ? 'enabled' : 'disabled' ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="post" class="mb-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="maintenance_on">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="maintenance_message" class="form-label mb-1">Message (optional)</label>
|
||||||
|
<input type="text" id="maintenance_message" name="maintenance_message" class="form-control form-control-sm" value="<?= htmlspecialchars($maintenance_message) ?>" placeholder="Upgrading database">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning btn-sm" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="mt-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="maintenance_off">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= $maintenance_enabled ? '' : 'disabled' ?>>Disable maintenance</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center py-3">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fas fa-database me-2 text-secondary"></i>
|
||||||
|
Database migrations
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<?php if (!empty($migration_error)): ?>
|
||||||
|
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="alert alert-info mb-3">
|
||||||
|
<strong>Test Migration Tools</strong><br>
|
||||||
|
<small class="text-muted">These tools create fake migrations in the database only (no files) for testing the migration warning functionality.</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<form method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="create_test_migration">
|
||||||
|
<button type="submit" class="btn btn-outline-info btn-sm" <?= !empty($test_migrations_exist) ? 'disabled' : '' ?>>Create test migration</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="clear_test_migrations">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= empty($test_migrations_exist) ? 'disabled' : '' ?>>Clear test migrations</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><strong>Pending</strong></div>
|
||||||
|
<span class="badge <?= empty($pending) ? 'bg-success' : 'bg-warning text-dark' ?>"><?= count($pending) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 small border rounded" style="max-height: 240px; overflow: auto;">
|
||||||
|
<?php if (empty($pending)): ?>
|
||||||
|
<div class="p-2"><span class="text-success">none</span></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<?php foreach ($pending as $fname): ?>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-monospace small"><?= htmlspecialchars($fname) ?></span>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#migrationModal<?= md5($fname) ?>">View
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><strong>Applied</strong></div>
|
||||||
|
<span class="badge bg-secondary"><?= count($applied) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 small border rounded" style="max-height: 240px; overflow: auto;">
|
||||||
|
<?php if (empty($applied)): ?>
|
||||||
|
<div class="p-2"><span class="text-muted">none</span></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<?php foreach ($applied as $fname): ?>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-monospace small"><?= htmlspecialchars($fname) ?></span>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-toggle="modal"
|
||||||
|
data-target="#migrationModal<?= md5($fname) ?>">View
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="mt-3">
|
||||||
|
<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 btn-sm" <?= empty($pending) ? 'disabled' : '' ?>>Apply pending migrations</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Migration viewer modals (one per file) -->
|
||||||
|
<?php if (!empty($migration_contents)):
|
||||||
|
foreach ($migration_contents as $name => $content):
|
||||||
|
$modalId = 'migrationModal' . md5($name);
|
||||||
|
?>
|
||||||
|
<div class="modal fade" id="<?= $modalId ?>" tabindex="-1" aria-labelledby="<?= $modalId ?>Label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="<?= $modalId ?>Label"><?= htmlspecialchars($name) ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<pre class="mb-0" style="max-height: 60vh; overflow: auto;"><code class="p-3 d-block"><?= htmlspecialchars($content) ?></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach;
|
||||||
|
endif; ?>
|
||||||
|
|
@ -1,586 +0,0 @@
|
||||||
<?php
|
|
||||||
/** @var bool $maintenance_enabled */
|
|
||||||
/** @var string $maintenance_message */
|
|
||||||
/** @var array $pending */
|
|
||||||
/** @var array $applied */
|
|
||||||
/** @var string $csrf_token */
|
|
||||||
/** @var string|null $next_pending */
|
|
||||||
/** @var array $migration_contents */
|
|
||||||
/** @var array $migration_records */
|
|
||||||
/** @var bool $test_migrations_exist */
|
|
||||||
/** @var array|null $migration_modal_result */
|
|
||||||
/** @var string|null $modal_to_open */
|
|
||||||
/** @var string|null $migration_error */
|
|
||||||
/** @var array $adminOverviewPills */
|
|
||||||
/** @var array $adminOverviewStatuses */
|
|
||||||
/** @var array $sectionState */
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$preselectModalId = null;
|
|
||||||
if (!empty($modal_to_open)) {
|
|
||||||
$preselectModalId = 'migrationModal' . md5($modal_to_open);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tabs = $adminTabs ?? [];
|
|
||||||
if (empty($tabs)) {
|
|
||||||
$tabs = [
|
|
||||||
'overview' => [
|
|
||||||
'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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<section class="tm-hero">
|
|
||||||
<div class="tm-hero-card tm-hero-card--admin">
|
|
||||||
<div class="tm-hero-body">
|
|
||||||
<div class="tm-hero-heading">
|
|
||||||
<h1 class="tm-hero-title">Admin control center</h1>
|
|
||||||
<p class="tm-hero-subtitle">
|
|
||||||
Centralized administration dashboard for system-wide management.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="tm-hero-meta tm-hero-meta--stacked">
|
|
||||||
<?php foreach ($heroPills as $pill):
|
|
||||||
$toneClass = 'pill-' . preg_replace('/[^a-z0-9_-]/i', '', $pill['tone'] ?? 'info');
|
|
||||||
?>
|
|
||||||
<div class="tm-hero-pill <?= htmlspecialchars($toneClass) ?>">
|
|
||||||
<i class="<?= htmlspecialchars($pill['icon']) ?>"></i>
|
|
||||||
<?= htmlspecialchars($pill['label']) ?> <?= htmlspecialchars($pill['value']) ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tm-hero-actions">
|
|
||||||
<a class="btn btn-primary tm-directory-cta" href="<?= htmlspecialchars($app_root) ?>?page=dashboard">
|
|
||||||
<i class="fas fa-arrow-left"></i> Back to dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="tm-admin tm-admin--dashboard">
|
|
||||||
<div class="tm-admin-tabs" role="tablist">
|
|
||||||
<?php foreach ($tabs as $sectionKey => $tabMeta):
|
|
||||||
$isActive = $activeSection === $sectionKey;
|
|
||||||
$tabUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey)));
|
|
||||||
?>
|
|
||||||
<a class="tm-admin-tab-button <?= $isActive ? 'active' : '' ?>"
|
|
||||||
href="<?= htmlspecialchars($tabUrl) ?>"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="<?= $isActive ? 'true' : 'false' ?>"
|
|
||||||
aria-controls="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>">
|
|
||||||
<?= htmlspecialchars($tabMeta['label'] ?? ucfirst($sectionKey)) ?>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php foreach ($tabs as $sectionKey => $tabMeta):
|
|
||||||
$panelUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey)));
|
|
||||||
$isActive = $activeSection === $sectionKey;
|
|
||||||
?>
|
|
||||||
<div class="tm-admin-tab-panel <?= $isActive ? 'active' : '' ?>" id="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>" role="tabpanel">
|
|
||||||
<?php if (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'overview'): ?>
|
|
||||||
<div class="tm-admin-grid tm-admin-grid--three">
|
|
||||||
<article class="tm-admin-card">
|
|
||||||
<header>
|
|
||||||
<h2 class="tm-admin-card-title">Current status</h2>
|
|
||||||
<p class="tm-admin-card-subtitle">High-level signals that require your attention.</p>
|
|
||||||
</header>
|
|
||||||
<ul class="tm-admin-status-list">
|
|
||||||
<?php foreach ($statusItems as $status):
|
|
||||||
$statusTone = 'status-' . preg_replace('/[^a-z0-9_-]/i', '', $status['tone'] ?? 'info');
|
|
||||||
?>
|
|
||||||
<li class="<?= htmlspecialchars($statusTone) ?>">
|
|
||||||
<div>
|
|
||||||
<strong><?= htmlspecialchars($status['label']) ?></strong>
|
|
||||||
<?php if (!empty($status['description'])): ?>
|
|
||||||
<p><?= htmlspecialchars($status['description']) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<span class="tm-admin-status-value <?= htmlspecialchars($statusTone) ?>">
|
|
||||||
<?= htmlspecialchars($status['value']) ?>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="tm-admin-card">
|
|
||||||
<header>
|
|
||||||
<h2 class="tm-admin-card-title">Maintenance</h2>
|
|
||||||
<p class="tm-admin-card-subtitle">Toggle maintenance or update visitor message.</p>
|
|
||||||
</header>
|
|
||||||
<form method="post" class="tm-admin-controls">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="maintenance_on">
|
|
||||||
<input type="hidden" name="section" value="overview">
|
|
||||||
<label for="maintenance_message_overview" class="form-label">Maintenance message</label>
|
|
||||||
<input type="text"
|
|
||||||
id="maintenance_message_overview"
|
|
||||||
name="maintenance_message"
|
|
||||||
class="form-control tm-admin-message-input"
|
|
||||||
value="<?= htmlspecialchars($maintenance_message) ?>"
|
|
||||||
placeholder="Custom message. Default is 'Please try again later.'">
|
|
||||||
<div class="tm-admin-inline-actions">
|
|
||||||
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?>
|
|
||||||
onclick="document.getElementById('maintenance-disable-form-overview').submit();">Disable</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form method="post" id="maintenance-disable-form-overview" class="d-none">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="maintenance_off">
|
|
||||||
<input type="hidden" name="section" value="overview">
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="tm-admin-card">
|
|
||||||
<header>
|
|
||||||
<h2 class="tm-admin-card-title">Next migration</h2>
|
|
||||||
<p class="tm-admin-card-subtitle">Peek at what will run when you apply updates.</p>
|
|
||||||
</header>
|
|
||||||
<?php if ($next_pending): ?>
|
|
||||||
<p class="text-muted mb-2">Next: <strong><?= htmlspecialchars($next_pending) ?></strong></p>
|
|
||||||
<button class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#migrationModal<?= md5($next_pending) ?>">
|
|
||||||
View SQL
|
|
||||||
</button>
|
|
||||||
<?php else: ?>
|
|
||||||
<p class="tm-admin-empty">No migrations queued.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
<hr>
|
|
||||||
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="migrate_up">
|
|
||||||
<input type="hidden" name="section" value="overview">
|
|
||||||
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'maintenance'): ?>
|
|
||||||
<div class="tm-admin-grid">
|
|
||||||
<article class="tm-admin-card">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h2 class="tm-admin-card-title">Maintenance mode</h2>
|
|
||||||
<p class="tm-admin-card-subtitle">Let your users know when maintenance is in progress.</p>
|
|
||||||
</div>
|
|
||||||
<span class="tm-hero-pill <?= $maintenance_enabled ? 'pill-danger' : 'pill-neutral' ?>">
|
|
||||||
<?= $maintenance_enabled ? 'ENABLED' : 'DISABLED' ?>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div class="tm-admin-section">
|
|
||||||
<p class="tm-admin-section-title">Message</p>
|
|
||||||
<form method="post" class="tm-admin-controls">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="maintenance_on">
|
|
||||||
<textarea id="maintenance_message"
|
|
||||||
name="maintenance_message"
|
|
||||||
class="form-control tm-admin-message-input"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Custom message. Default is 'Please try again later.'"><?= htmlspecialchars($maintenance_message) ?></textarea>
|
|
||||||
<div class="tm-admin-inline-actions">
|
|
||||||
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?> onclick="document.getElementById('maintenance-disable-form').submit();">Disable maintenance</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<form method="post" id="maintenance-disable-form" class="d-none">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="maintenance_off">
|
|
||||||
<input type="hidden" name="section" value="maintenance">
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'migrations'): ?>
|
|
||||||
<div class="tm-admin-grid">
|
|
||||||
<article class="tm-admin-card tm-admin-card--migrations">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h2 class="tm-admin-card-title">Database migrations</h2>
|
|
||||||
<p class="tm-admin-card-subtitle">Review pending SQL and apply with confidence.</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if (!empty($migration_error)): ?>
|
|
||||||
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="tm-admin-test-tools">
|
|
||||||
<p><strong>Test migration tools</strong></p>
|
|
||||||
<div class="tm-admin-inline-actions">
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="create_test_migration">
|
|
||||||
<input type="hidden" name="section" value="migrations">
|
|
||||||
<button type="submit" class="btn btn-outline-primary btn-sm" <?= !empty($test_migrations_exist) ? 'disabled' : '' ?>>Create test migration</button>
|
|
||||||
</form>
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="clear_test_migrations">
|
|
||||||
<input type="hidden" name="section" value="migrations">
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= empty($test_migrations_exist) ? 'disabled' : '' ?>>Clear test migrations</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tm-admin-section">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<p class="tm-admin-section-title mb-0">Pending migrations</p>
|
|
||||||
<?php if (!empty($next_pending)): ?>
|
|
||||||
<span class="badge bg-info text-dark">Next: <?= htmlspecialchars($next_pending) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<ul class="tm-admin-list">
|
|
||||||
<?php if (empty($pending)): ?>
|
|
||||||
<li class="tm-admin-empty">No pending migrations</li>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($pending as $fname): ?>
|
|
||||||
<li>
|
|
||||||
<div class="tm-admin-list-actions">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
|
|
||||||
</div>
|
|
||||||
<span><?= htmlspecialchars($fname) ?></span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tm-admin-section">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<p class="tm-admin-section-title mb-0">Applied migrations</p>
|
|
||||||
<span class="badge bg-secondary"><?= count($applied) ?></span>
|
|
||||||
</div>
|
|
||||||
<ul class="tm-admin-list">
|
|
||||||
<?php if (empty($applied)): ?>
|
|
||||||
<li class="tm-admin-empty">No applied migrations yet</li>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($applied as $fname):
|
|
||||||
if (strpos($fname, '_test_migration') !== false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<li>
|
|
||||||
<div class="tm-admin-list-actions">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
|
|
||||||
</div>
|
|
||||||
<span><?= htmlspecialchars($fname) ?></span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="migrate_up">
|
|
||||||
<input type="hidden" name="section" value="migrations">
|
|
||||||
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'plugins'): ?>
|
|
||||||
<?php
|
|
||||||
$pluginsState = $sectionState['plugins'] ?? [];
|
|
||||||
$pluginsList = $pluginsState['plugins'] ?? [];
|
|
||||||
$dependencyErrors = $pluginsState['dependency_errors'] ?? [];
|
|
||||||
$totalPlugins = count($pluginsList);
|
|
||||||
$enabledPlugins = count(array_filter($pluginsList, static function($plugin) {
|
|
||||||
return !empty($plugin['enabled']);
|
|
||||||
}));
|
|
||||||
$issuesPlugins = count(array_filter($pluginsList, static function($plugin) {
|
|
||||||
return !empty($plugin['dependency_errors']) || !$plugin['loaded'];
|
|
||||||
}));
|
|
||||||
?>
|
|
||||||
<div class="tm-admin-grid">
|
|
||||||
<article class="tm-admin-card">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h2 class="tm-admin-card-title">Plugin overview</h2>
|
|
||||||
<p class="tm-admin-card-subtitle">Enable or disable functionality and review dependency health.</p>
|
|
||||||
</div>
|
|
||||||
<div class="tm-hero-pill pill-primary">
|
|
||||||
<?= htmlspecialchars($enabledPlugins) ?> / <?= htmlspecialchars($totalPlugins) ?> enabled
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<?php if (!empty($dependencyErrors)): ?>
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<strong>Dependency issues detected.</strong> Resolve the following before enabling affected plugins:
|
|
||||||
<ul class="mb-0 mt-2">
|
|
||||||
<?php foreach ($dependencyErrors as $slug => $errors):
|
|
||||||
if (empty($errors)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<li><strong><?= htmlspecialchars($slug) ?>:</strong> <?= htmlspecialchars(implode('; ', $errors)) ?></li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (empty($pluginsList)): ?>
|
|
||||||
<p class="tm-admin-empty mb-0">No plugins detected in the plugins directory.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover tm-admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Plugin</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Depends on</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php
|
|
||||||
$pluginIndex = $pluginsState['plugin_index'] ?? [];
|
|
||||||
foreach ($pluginsList as $plugin):
|
|
||||||
$missingDeps = $plugin['missing_dependencies'] ?? [];
|
|
||||||
$depErrors = $plugin['dependency_errors'] ?? [];
|
|
||||||
$dependents = $plugin['dependents'] ?? [];
|
|
||||||
$enabledDependents = $plugin['enabled_dependents'] ?? [];
|
|
||||||
$statusBadges = [];
|
|
||||||
$statusBadges[] = $plugin['enabled']
|
|
||||||
? '<span class="badge text-uppercase" style="background-color:#198754;color:#fff;">Enabled</span>'
|
|
||||||
: '<span class="badge text-uppercase" style="background-color:#6c757d;color:#fff;">Disabled</span>';
|
|
||||||
if ($plugin['enabled'] && empty($depErrors) && $plugin['loaded']) {
|
|
||||||
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#0dcaf0;color:#052c65;">Loaded</span>';
|
|
||||||
}
|
|
||||||
if (!empty($missingDeps) || !empty($depErrors)) {
|
|
||||||
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#ffc107;color:#212529;">Issues</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong><?= htmlspecialchars($plugin['name']) ?></strong>
|
|
||||||
<?php if (!empty($plugin['version'])): ?>
|
|
||||||
<span class="text-muted">v<?= htmlspecialchars($plugin['version']) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($plugin['description'])): ?>
|
|
||||||
<p class="tm-admin-muted mb-0"><?= htmlspecialchars($plugin['description']) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?= implode(' ', $statusBadges) ?>
|
|
||||||
<?php if (!empty($depErrors)): ?>
|
|
||||||
<p class="tm-admin-muted text-warning mb-0"><?= htmlspecialchars(implode(' ', $depErrors)) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?php if (!empty($plugin['dependencies'])): ?>
|
|
||||||
<ul class="tm-admin-inline-list">
|
|
||||||
<?php foreach ($plugin['dependencies'] as $dep):
|
|
||||||
$depMeta = $pluginIndex[$dep] ?? null;
|
|
||||||
$depStatusBadge = '';
|
|
||||||
if ($depMeta) {
|
|
||||||
$depStatusBadge = $depMeta['enabled']
|
|
||||||
? '<span class="badge" style="background-color:#198754;color:#fff;">OK</span>'
|
|
||||||
: '<span class="badge" style="background-color:#ffc107;color:#212529;">Off</span>';
|
|
||||||
if (!empty($depMeta['dependency_errors'])) {
|
|
||||||
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Error</span>';
|
|
||||||
}
|
|
||||||
} elseif (in_array($dep, $missingDeps, true)) {
|
|
||||||
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Missing</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<li>
|
|
||||||
<?= htmlspecialchars($dep) ?>
|
|
||||||
<?php if ($depStatusBadge !== ''): ?>
|
|
||||||
<span class="tm-admin-dep-status">(<?= $depStatusBadge ?>)</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<form method="post" class="d-inline">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="section" value="plugins">
|
|
||||||
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
|
|
||||||
<?php if ($plugin['enabled']): ?>
|
|
||||||
<input type="hidden" name="action" value="plugin_disable">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" <?= $plugin['can_disable'] ? '' : 'disabled' ?>>Disable</button>
|
|
||||||
<?php else: ?>
|
|
||||||
<input type="hidden" name="action" value="plugin_enable">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-success" <?= $plugin['can_enable'] ? '' : 'disabled' ?>>Enable</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<?php elseif (!empty($tabMeta['hook'])): ?>
|
|
||||||
<?php
|
|
||||||
do_hook($tabMeta['hook'], [
|
|
||||||
'section' => $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,
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
<?php else: ?>
|
|
||||||
<article class="tm-admin-card">
|
|
||||||
<p class="tm-admin-empty mb-0">No renderer available for this section.</p>
|
|
||||||
</article>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<?php if (!empty($migration_contents)):
|
|
||||||
foreach ($migration_contents as $name => $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;
|
|
||||||
?>
|
|
||||||
<div class="modal fade" id="<?= $modalId ?>" tabindex="-1" aria-labelledby="<?= $modalId ?>Label" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="<?= $modalId ?>Label"><?= htmlspecialchars($name) ?></h5>
|
|
||||||
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body p-0">
|
|
||||||
<pre class="tm-admin-modal-code"><code style="border-radius: 0.5rem;"><?= htmlspecialchars($content) ?></code></pre>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<?php if ($isModalNext): ?>
|
|
||||||
<form method="post" class="me-auto tm-confirm" data-confirm="Apply migration <?= htmlspecialchars($name) ?>?">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="action" value="migrate_apply_one">
|
|
||||||
<input type="hidden" name="migration_name" value="<?= htmlspecialchars($name) ?>">
|
|
||||||
<input type="hidden" name="section" value="migrations">
|
|
||||||
<button type="submit" class="btn btn-danger">Apply migration</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($modalResult): ?>
|
|
||||||
<div class="alert alert-<?= $modalResult['status'] === 'success' ? 'success' : 'info' ?> mb-0 small">
|
|
||||||
<?= htmlspecialchars($modalResult['message']) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($appliedAtFormatted): ?>
|
|
||||||
<div class="tm-admin-modal-meta">
|
|
||||||
<span class="tm-admin-pill pill-success">
|
|
||||||
<i class="far fa-clock"></i>
|
|
||||||
Applied <?= htmlspecialchars($appliedAtFormatted) ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
endforeach;
|
|
||||||
endif; ?>
|
|
||||||
|
|
||||||
<form method="post" id="tm-admin-hidden-read-migration" class="d-none">
|
|
||||||
<input type="hidden" name="action" value="read_migration">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
|
||||||
<input type="hidden" name="filename" value="">
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
document.querySelectorAll('form.tm-confirm').forEach((form) => {
|
|
||||||
form.addEventListener('submit', (event) => {
|
|
||||||
const message = form.getAttribute('data-confirm') || 'Are you sure?';
|
|
||||||
if (!confirm(message)) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const preselectModal = <?= $preselectModalId ? '"#' . htmlspecialchars($preselectModalId) . '"' : 'null' ?>;
|
|
||||||
if (preselectModal) {
|
|
||||||
const el = document.querySelector(preselectModal);
|
|
||||||
if (el && window.$) {
|
|
||||||
window.$(el).modal('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -4,80 +4,80 @@
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="container mt-4">
|
||||||
<div class="action-card-header">
|
<div class="row justify-content-center">
|
||||||
<p class="action-eyebrow">Security</p>
|
<div class="col-md-8">
|
||||||
<h2 class="action-title">Set up two-factor authentication</h2>
|
<div class="card">
|
||||||
<p class="action-subtitle">Protect your account with an extra verification step whenever you sign in.</p>
|
<div class="card-header">
|
||||||
|
<h3>Set up two-factor authentication</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-card-body">
|
<div class="card-body">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
Two-factor authentication adds an extra layer of protection. After setup, you will sign in with both your password and a code from your authenticator app.
|
<p>Two-factor authentication adds an extra layer of security to your account. Once enabled, you'll need to enter both your password and a code from your authenticator app when signing in.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (isset($error)): ?>
|
<?php if (isset($error)): ?>
|
||||||
<div class="alert alert-danger mb-4">
|
<div class="alert alert-danger">
|
||||||
<?php echo htmlspecialchars($error); ?>
|
<?php echo htmlspecialchars($error); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (isset($setupData) && is_array($setupData)): ?>
|
<?php if (isset($setupData) && is_array($setupData)): ?>
|
||||||
<div class="tm-cred-steps">
|
<div class="setup-steps">
|
||||||
<div class="tm-cred-step">
|
<h4>1. Install an authenticator app</h4>
|
||||||
<h3>1. Install an authenticator app</h3>
|
<p>If you haven't already, install an authenticator app on your mobile device:</p>
|
||||||
<p>Use any TOTP-compatible app such as Google Authenticator, Microsoft Authenticator, or Authy.</p>
|
<ul>
|
||||||
</div>
|
<li>Google Authenticator</li>
|
||||||
|
<li>Microsoft Authenticator</li>
|
||||||
|
<li>Authy</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="tm-cred-step">
|
<h4 class="mt-4">2. Scan the QR code</h4>
|
||||||
<h3>2. Scan the QR code</h3>
|
<p>Open your authenticator app and scan this QR code:</p>
|
||||||
<p>Open your authenticator app and scan the QR code below.</p>
|
|
||||||
<div class="tm-cred-qr">
|
<div class="text-center my-4">
|
||||||
<div id="qrcode"></div>
|
<div id="qrcode"></div>
|
||||||
<div class="tm-cred-secret">
|
<div class="mt-2">
|
||||||
<small>Can't scan? Enter this code manually:</small>
|
<small class="text-muted">Can't scan? Use this code instead:</small><br>
|
||||||
<code><?php echo htmlspecialchars($setupData['secret']); ?></code>
|
<code class="secret-key"><?php echo htmlspecialchars($setupData['secret']); ?></code>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-cred-step">
|
<h4 class="mt-4">3. Verify setup</h4>
|
||||||
<h3>3. Verify setup</h3>
|
<p>Enter the 6-digit code from your authenticator app to verify the setup:</p>
|
||||||
<p>Enter the 6-digit code shown in your authenticator app.</p>
|
|
||||||
<form method="post" action="?page=credentials&item=2fa&action=setup" class="action-form" novalidate>
|
<form method="post" action="?page=credentials&item=2fa&action=setup" class="mt-3">
|
||||||
<div class="action-form-group">
|
<div class="form-group">
|
||||||
<label for="setup_code" class="action-form-label">One-time code</label>
|
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="setup_code"
|
|
||||||
name="code"
|
name="code"
|
||||||
class="form-control action-form-control"
|
class="form-control"
|
||||||
pattern="[0-9]{6}"
|
pattern="[0-9]{6}"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
required
|
required
|
||||||
placeholder="000000">
|
placeholder="Enter 6-digit code">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="secret" value="<?php echo htmlspecialchars($setupData['secret']); ?>">
|
<input type="hidden" name="secret" value="<?php echo htmlspecialchars($setupData['secret']); ?>">
|
||||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
||||||
|
|
||||||
<div class="action-actions">
|
<button type="submit" class="btn btn-primary mt-3">
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
Verify and enable 2FA
|
Verify and enable 2FA
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tm-cred-step">
|
<div class="mt-4">
|
||||||
<h3>Backup codes</h3>
|
<h4>Backup codes</h4>
|
||||||
<p class="text-danger mb-3">
|
<p class="text-warning">
|
||||||
Save these codes somewhere secure. Each code can be used once if you lose access to your authenticator app.
|
<strong>Important:</strong> Save these backup codes in a secure place.
|
||||||
|
If you lose access to your authenticator app, you can use these codes to sign in.
|
||||||
|
Each code can only be used once.
|
||||||
</p>
|
</p>
|
||||||
<div class="tm-cred-backup">
|
<div class="backup-codes bg-light p-3 rounded">
|
||||||
<?php foreach ($setupData['backupCodes'] as $code): ?>
|
<?php foreach ($setupData['backupCodes'] as $code): ?>
|
||||||
<code><?php echo htmlspecialchars($code); ?></code>
|
<code class="d-block"><?php echo htmlspecialchars($code); ?></code>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-secondary mt-3" onclick="window.print()">
|
<button class="btn btn-secondary mt-2" onclick="window.print()">
|
||||||
Print backup codes
|
Print backup codes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,11 +86,12 @@
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
Failed to generate 2FA setup data. Please try again.
|
Failed to generate 2FA setup data. Please try again.
|
||||||
</div>
|
</div>
|
||||||
<div class="action-actions">
|
|
||||||
<a href="?page=credentials" class="btn btn-primary">Back to credentials</a>
|
<a href="?page=credentials" class="btn btn-primary">Back to credentials</a>
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (isset($setupData) && is_array($setupData)): ?>
|
<?php if (isset($setupData) && is_array($setupData)): ?>
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,27 @@
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="container mt-4">
|
||||||
<div class="action-card-header">
|
<div class="row justify-content-center">
|
||||||
<p class="action-eyebrow">Security check</p>
|
<div class="col-md-6">
|
||||||
<h2 class="action-title">Two-factor authentication</h2>
|
<div class="card">
|
||||||
<p class="action-subtitle">Enter the 6-digit code from your authenticator app to continue.</p>
|
<div class="card-header">
|
||||||
|
<h3>Two-factor authentication</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-card-body">
|
<div class="card-body">
|
||||||
<?php if (isset($error)): ?>
|
<?php if (isset($error)): ?>
|
||||||
<div class="alert alert-danger mb-4">
|
<div class="alert alert-danger">
|
||||||
<?php echo htmlspecialchars($error); ?>
|
<?php echo htmlspecialchars($error); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="?page=login&action=verify" class="action-form" novalidate>
|
<p>Enter the 6-digit code from your authenticator app:</p>
|
||||||
<div class="action-form-group">
|
|
||||||
<label for="code" class="action-form-label">One-time code</label>
|
<form method="post" action="?page=login&action=verify" class="mt-3">
|
||||||
|
<div class="form-group">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="code"
|
|
||||||
name="code"
|
name="code"
|
||||||
class="form-control action-form-control text-center"
|
class="form-control form-control-lg text-center"
|
||||||
pattern="[0-9]{6}"
|
pattern="[0-9]{6}"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
|
|
@ -35,44 +36,44 @@
|
||||||
|
|
||||||
<input type="hidden" name="user_id" value="<?php echo htmlspecialchars($userId); ?>">
|
<input type="hidden" name="user_id" value="<?php echo htmlspecialchars($userId); ?>">
|
||||||
|
|
||||||
<div class="action-actions">
|
<button type="submit" class="btn btn-primary btn-block mt-4">
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
Verify code
|
Verify code
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4">
|
||||||
<p class="text-muted mb-2">Lost access to your authenticator app?</p>
|
<p class="text-muted text-center">
|
||||||
<button class="btn btn-link p-0" type="button" data-toggle="collapse" data-target="#backupCodeForm">
|
Lost access to your authenticator app?<br>
|
||||||
|
<a href="#" data-toggle="collapse" data-target="#backupCodeForm">
|
||||||
Use a backup code
|
Use a backup code
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div class="collapse mt-3" id="backupCodeForm">
|
<div class="collapse mt-3" id="backupCodeForm">
|
||||||
<form method="post" action="?page=login&action=verify" class="action-form" novalidate>
|
<form method="post" action="?page=login&action=verify" class="mt-3">
|
||||||
<div class="action-form-group">
|
<div class="form-group">
|
||||||
<label for="backup_code" class="action-form-label">Backup code</label>
|
<label>Enter backup code:</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="backup_code"
|
|
||||||
name="backup_code"
|
name="backup_code"
|
||||||
class="form-control action-form-control"
|
class="form-control"
|
||||||
pattern="[a-f0-9]{8}"
|
pattern="[a-f0-9]{8}"
|
||||||
maxlength="8"
|
maxlength="8"
|
||||||
required
|
required
|
||||||
placeholder="Enter 8-character code">
|
placeholder="Enter backup code">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="user_id" value="<?php echo htmlspecialchars($userId); ?>">
|
<input type="hidden" name="user_id" value="<?php echo htmlspecialchars($userId); ?>">
|
||||||
|
|
||||||
<div class="action-actions">
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
<button type="submit" class="btn btn-secondary">
|
|
||||||
Use backup code
|
Use backup code
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -5,86 +5,87 @@
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="container mt-4">
|
||||||
<div class="action-card-header">
|
<div class="row justify-content-center">
|
||||||
<p class="action-eyebrow">Security</p>
|
<div class="col-md-8">
|
||||||
<h2 class="action-title">Manage credentials</h2>
|
<!-- Password Management -->
|
||||||
<p class="action-subtitle">Update your password and keep two-factor authentication status in one place.</p>
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>change password</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-card-body">
|
<div class="card-body">
|
||||||
<div class="tm-cred-grid">
|
<form method="post" action="?page=credentials&item=password">
|
||||||
<section class="tm-cred-panel">
|
|
||||||
<div class="tm-cred-panel-head">
|
|
||||||
<div>
|
|
||||||
<h3>Change password</h3>
|
|
||||||
<p>Choose a strong password to keep your account safe.</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge bg-light text-dark">Required</span>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="?page=credentials&item=password" class="action-form" novalidate>
|
|
||||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
||||||
|
|
||||||
<div class="action-form-group">
|
<div class="form-group">
|
||||||
<label for="current_password" class="action-form-label">Current password</label>
|
<label for="current_password">current password</label>
|
||||||
<input type="password" class="form-control action-form-control" id="current_password" name="current_password" required>
|
<input type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="current_password"
|
||||||
|
name="current_password"
|
||||||
|
required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-form-group">
|
<div class="form-group mt-3">
|
||||||
<label for="new_password" class="action-form-label">New password</label>
|
<label for="new_password">new password</label>
|
||||||
<input type="password" class="form-control action-form-control" id="new_password" name="new_password" pattern=".{8,}" title="Password must be at least 8 characters long" required>
|
<input type="password"
|
||||||
<small class="form-text text-muted">Minimum 8 characters</small>
|
class="form-control"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
pattern=".{8,}"
|
||||||
|
title="Password must be at least 8 characters long"
|
||||||
|
required>
|
||||||
|
<small class="form-text text-muted">minimum 8 characters</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-form-group">
|
<div class="form-group mt-3">
|
||||||
<label for="confirm_password" class="action-form-label">Confirm new password</label>
|
<label for="confirm_password">confirm new password</label>
|
||||||
<input type="password" class="form-control action-form-control" id="confirm_password" name="confirm_password" pattern=".{8,}" required>
|
<input type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
pattern=".{8,}"
|
||||||
|
required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-actions">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-primary">Save new password</button>
|
<button type="submit" class="btn btn-primary">change password</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="tm-cred-panel">
|
<!-- 2FA Management -->
|
||||||
<div class="tm-cred-panel-head">
|
<div class="card">
|
||||||
<div>
|
<div class="card-header">
|
||||||
<h3>Two-factor authentication</h3>
|
<h3>two-factor authentication</h3>
|
||||||
<p>Strengthen security with a verification code from your authenticator app.</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge <?= $has2fa ? 'bg-success' : 'bg-warning text-dark' ?>">
|
|
||||||
<?= $has2fa ? 'Enabled' : 'Disabled' ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-4">Two-factor authentication adds an extra layer of security to your account. Once enabled, you'll need to enter both your password and a code from your authenticator app when signing in.</p>
|
||||||
|
|
||||||
<?php if ($has2fa): ?>
|
<?php if ($has2fa): ?>
|
||||||
<div class="alert alert-success d-flex align-items-center gap-2">
|
<div class="alert alert-success">
|
||||||
<i class="fas fa-shield-check"></i>
|
<i class="fas fa-check-circle"></i> two-factor authentication is enabled
|
||||||
<span>Two-factor authentication is currently enabled.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="?page=credentials&item=2fa&action=disable" class="action-form">
|
<form method="post" action="?page=credentials&item=2fa&action=disable">
|
||||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
||||||
<div class="action-actions">
|
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.')">
|
||||||
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Disable two-factor authentication? This will make your account less secure.')">
|
disable two-factor authentication
|
||||||
Disable 2FA
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="alert alert-warning d-flex align-items-center gap-2">
|
<div class="alert alert-warning">
|
||||||
<i class="fas fa-lock"></i>
|
<i class="fas fa-exclamation-triangle"></i> two-factor authentication is not enabled
|
||||||
<span>Two-factor authentication is not enabled yet.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="?page=credentials&item=2fa&action=setup" class="action-form">
|
<form method="post" action="?page=credentials&item=2fa&action=setup">
|
||||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
||||||
<div class="action-actions">
|
<button type="submit" class="btn btn-primary">
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
set up two-factor authentication
|
||||||
Set up 2FA
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,5 +97,4 @@ document.getElementById('confirm_password').addEventListener('input', function()
|
||||||
} else {
|
} else {
|
||||||
this.setCustomValidity('');
|
this.setCustomValidity('');
|
||||||
}
|
}
|
||||||
});
|
});</script>
|
||||||
</script>
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
|
||||||
|
<!-- Two-Factor Authentication -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Two-factor authentication</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if ($has2fa): ?>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-shield-alt text-success"></i>
|
||||||
|
Two-factor authentication is enabled
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
Your account is protected with an authenticator app
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="ml-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
||||||
|
<input type="hidden" name="item" value="2fa">
|
||||||
|
<input type="hidden" name="action" value="disable">
|
||||||
|
<button type="submit" class="btn btn-outline-danger"
|
||||||
|
onclick="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.')">
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-shield-alt text-muted"></i>
|
||||||
|
Two-factor authentication is not enabled
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
Add an extra layer of security to your account by requiring both your password and an authentication code
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="ml-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
|
||||||
|
<input type="hidden" name="item" value="2fa">
|
||||||
|
<input type="hidden" name="action" value="enable">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Enable 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<section class="tm-widget-card tm-call-widget">
|
|
||||||
<div class="tm-widget-header">
|
|
||||||
<div>
|
|
||||||
<p class="tm-widget-eyebrow">Conferences</p>
|
|
||||||
<h3 class="tm-widget-title">
|
|
||||||
<?= $widget['title'] ?>
|
|
||||||
</h3>
|
|
||||||
<?php if ($time_range_specified) { ?>
|
|
||||||
<p class="m-1 mb-0" style="font-size: 0.75rem;">time period:
|
|
||||||
<strong>
|
|
||||||
<?= $from_time == '0000-01-01' ? 'beginning' : date('d M Y', strtotime($from_time)) ?> - <?= $until_time == '9999-12-31' ? 'now' : date('d M Y', strtotime($until_time)) ?>
|
|
||||||
</strong>
|
|
||||||
</p>
|
|
||||||
<?php } ?>
|
|
||||||
</div>
|
|
||||||
<div class="tm-widget-tools">
|
|
||||||
<?php if ($widget['filter'] === true) { include '../app/templates/block-results-filter.php'; } ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- calls -->
|
|
||||||
<div class="tm-widget-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table tm-widget-table" style="font-size: 0.75rem;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col"></th>
|
|
||||||
<?php foreach ($widget['records'] as $record) { ?>
|
|
||||||
<th scope="col"><?= htmlspecialchars($record['table_headers']) ?></th>
|
|
||||||
<?php } ?>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (!empty($widget['records'])) { ?>
|
|
||||||
<tr>
|
|
||||||
<td>conferences</td>
|
|
||||||
<?php foreach ($widget['records'] as $record) { ?>
|
|
||||||
<td><?php if (!empty($record['conferences'])) { ?>
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['conferences']) ?></a> <?php } else { ?>
|
|
||||||
0<?php } ?>
|
|
||||||
</td>
|
|
||||||
<?php } ?>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>participants</td>
|
|
||||||
<?php foreach ($widget['records'] as $record) { ?>
|
|
||||||
<td><?php if (!empty($record['participants'])) { ?>
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=participants&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['participants']) ?></a> <?php } else { ?>
|
|
||||||
0<?php } ?>
|
|
||||||
</td>
|
|
||||||
<?php } ?>
|
|
||||||
</tr>
|
|
||||||
<?php } else { ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="6">
|
|
||||||
<p class="tm-widget-empty">No matching records found.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php } ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- /monthly conferences -->
|
|
||||||
</section>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
Dear user,
|
|
||||||
|
|
||||||
We received a request to reset your password for your {{site_name}} account.
|
|
||||||
|
|
||||||
To set a new password, please click the link below:
|
|
||||||
|
|
||||||
{{reset_link}}
|
|
||||||
|
|
||||||
This link will expire in 1 hour for security reasons.
|
|
||||||
|
|
||||||
If you did not request this password reset, please ignore this email. Your account remains secure.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
The {{site_name}} team
|
|
||||||
{{#site_slogan}}
|
|
||||||
:: {{site_slogan}} ::
|
|
||||||
{{/site_slogan}}
|
|
||||||
|
|
@ -1,54 +1,34 @@
|
||||||
<!-- login form -->
|
<!-- login form -->
|
||||||
<div class="action-card">
|
<div class="card text-center w-50 mx-auto">
|
||||||
<div class="action-card-header">
|
<h2 class="card-header">Login</h2>
|
||||||
<p class="action-eyebrow">Welcome back</p>
|
<div class="card-body">
|
||||||
<h2 class="action-title">Sign in</h2>
|
<p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p>
|
||||||
<p class="action-subtitle">Enter your credentials to continue to <?= htmlspecialchars($config['site_name']); ?></p>
|
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-card-body">
|
|
||||||
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login" class="action-form" novalidate>
|
|
||||||
<?php include CSRF_TOKEN_INCLUDE; ?>
|
<?php include CSRF_TOKEN_INCLUDE; ?>
|
||||||
<div class="action-form-group">
|
<div class="form-group mb-3">
|
||||||
<label for="username" class="action-form-label">Username</label>
|
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="fas fa-user"></i></span>
|
|
||||||
<input type="text" id="username" class="form-control action-form-control" name="username" placeholder="Username"
|
|
||||||
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
|
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
|
||||||
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
|
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group mb-3">
|
||||||
|
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
|
||||||
<div class="action-form-group">
|
|
||||||
<label for="password" class="action-form-label">Password</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="fas fa-lock"></i></span>
|
|
||||||
<input type="password" id="password" class="form-control action-form-control" name="password" placeholder="Password"
|
|
||||||
pattern=".{8,}" title="Eight or more characters"
|
pattern=".{8,}" title="Eight or more characters"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="remember_me">
|
||||||
|
<input type="checkbox" id="remember_me" name="remember_me" />
|
||||||
|
remember me
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($_GET['redirect'])): ?>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<input type="hidden" name="redirect" value="<?php echo htmlspecialchars($_GET['redirect']); ?>">
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" id="remember" name="remember" class="form-check-input" <?= isset($_POST['remember']) ? 'checked' : '' ?>>
|
|
||||||
<label for="remember" class="form-check-label">Remember me</label>
|
|
||||||
</div>
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?page=login&action=forgot" class="text-decoration-none">Forgot password?</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-actions">
|
|
||||||
<button type="submit" class="btn btn-primary w-100">
|
|
||||||
<i class="fas fa-sign-in-alt me-2"></i>Sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<?php if (isset($_GET['redirect'])):
|
|
||||||
$loginRawRedirect = $_GET['redirect'];
|
|
||||||
?>
|
|
||||||
<input type="hidden" name="redirect" value="<?= htmlspecialchars($loginRawRedirect, ENT_QUOTES, 'UTF-8'); ?>">
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<input type="submit" class="btn btn-primary" value="Login" />
|
||||||
</form>
|
</form>
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="?page=login&action=forgot">forgot password?</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /login form -->
|
<!-- /login form -->
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,32 @@
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="container">
|
||||||
<div class="action-card-header">
|
<div class="row justify-content-center">
|
||||||
<p class="action-eyebrow">Account recovery</p>
|
<div class="col-md-6">
|
||||||
<h2 class="action-title">Forgot password</h2>
|
<div class="card mt-5">
|
||||||
<p class="action-subtitle">Enter your email address and we'll send you reset instructions if it exists in our records</p>
|
<div class="card-body">
|
||||||
</div>
|
<h3 class="card-title mb-4">Reset password</h3>
|
||||||
|
<p>Enter your email address and we will send you<br />
|
||||||
<div class="action-card-body">
|
instructions to reset your password.</p>
|
||||||
<form method="post" action="?page=login&action=forgot" class="action-form" novalidate>
|
<form method="post" action="?page=login&action=forgot">
|
||||||
<?php include CSRF_TOKEN_INCLUDE; ?>
|
<?php include CSRF_TOKEN_INCLUDE; ?>
|
||||||
<div class="action-form-group">
|
<div class="form-group">
|
||||||
<label for="email" class="action-form-label">Email address</label>
|
<label for="email">email address:</label>
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
|
|
||||||
<input type="email"
|
<input type="email"
|
||||||
class="form-control action-form-control"
|
class="form-control"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@example.com"
|
|
||||||
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
|
|
||||||
required
|
required
|
||||||
autocomplete="email">
|
autocomplete="email">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button type="submit" class="btn btn-primary btn-block mt-4">
|
||||||
|
Send reset instructions
|
||||||
<div class="action-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="fas fa-paper-plane me-2"></i>Send reset instructions
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
<div class="mt-3 text-center">
|
||||||
<div class="mt-4 text-center">
|
<a href="?page=login">back to login</a>
|
||||||
<a href="?page=login" class="text-decoration-none">← Back to login</a>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,38 @@
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="container">
|
||||||
<div class="action-card-header">
|
<div class="row justify-content-center">
|
||||||
<p class="action-eyebrow">Account recovery</p>
|
<div class="col-md-6">
|
||||||
<h2 class="action-title">Reset password</h2>
|
<div class="card mt-5">
|
||||||
<p class="action-subtitle">Create a new password that is at least 8 characters long</p>
|
<div class="card-body">
|
||||||
</div>
|
<h3 class="card-title mb-4">Set new password</h3>
|
||||||
|
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>">
|
||||||
<div class="action-card-body">
|
|
||||||
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>" class="action-form" novalidate>
|
|
||||||
<?php include CSRF_TOKEN_INCLUDE; ?>
|
<?php include CSRF_TOKEN_INCLUDE; ?>
|
||||||
<div class="action-form-group">
|
<div class="form-group">
|
||||||
<label for="new_password" class="action-form-label">New password</label>
|
<label for="new_password">new password:</label>
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="fas fa-key"></i></span>
|
|
||||||
<input type="password"
|
<input type="password"
|
||||||
class="form-control action-form-control"
|
class="form-control"
|
||||||
id="new_password"
|
id="new_password"
|
||||||
name="new_password"
|
name="new_password"
|
||||||
placeholder="Enter new password"
|
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength="8"
|
||||||
autocomplete="new-password">
|
autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group mt-3">
|
||||||
|
<label for="confirm_password">confirm password:</label>
|
||||||
<div class="action-form-group">
|
|
||||||
<label for="confirm_password" class="action-form-label">Confirm password</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text"><i class="fas fa-check"></i></span>
|
|
||||||
<input type="password"
|
<input type="password"
|
||||||
class="form-control action-form-control"
|
class="form-control"
|
||||||
id="confirm_password"
|
id="confirm_password"
|
||||||
name="confirm_password"
|
name="confirm_password"
|
||||||
placeholder="Confirm new password"
|
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength="8"
|
||||||
autocomplete="new-password">
|
autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button type="submit" class="btn btn-primary btn-block mt-4">
|
||||||
|
Set new password
|
||||||
<div class="action-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="fas fa-lock me-2"></i>Update password
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
<div class="mt-4 text-center">
|
</div>
|
||||||
<a href="?page=login" class="text-decoration-none">← Back to login</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-adapter-moment.min.js"></script>
|
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-adapter-moment.min.js"></script>
|
||||||
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script>
|
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
<?php if ($page === 'admin') {
|
<?php if ($page === 'admin-tools') {
|
||||||
// Use local highlight.js assets if available
|
// Use local highlight.js assets if available
|
||||||
$hlBaseFs = __DIR__ . '/../../public_html/static/libs/highlightjs';
|
$hlBaseFs = __DIR__ . '/../../public_html/static/libs/highlightjs';
|
||||||
$hlBaseUrl = htmlspecialchars($app_root) . 'static/libs/highlightjs/';
|
$hlBaseUrl = htmlspecialchars($app_root) . 'static/libs/highlightjs/';
|
||||||
|
|
@ -53,16 +53,12 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
<?php
|
|
||||||
// hook for loading plugin assets (css, images, etc.)
|
|
||||||
do_hook('page_head_assets', ['page' => $page ?? null, 'action' => $_GET['action'] ?? null, 'app_root' => $app_root ?? '']);
|
|
||||||
?>
|
|
||||||
<title><?= htmlspecialchars($config['site_name']) ?></title>
|
<title><?= htmlspecialchars($config['site_name']) ?></title>
|
||||||
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($app_root) ?>static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($app_root) ?>static/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="messages-container" class="container-fluid"></div>
|
<div id="messages-container" class="container-fluid mt-2"></div>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,82 @@
|
||||||
|
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<!-- Modern Menu -->
|
<!-- Menu -->
|
||||||
<div class="menu-container">
|
<div class="menu-container">
|
||||||
<div class="modern-header-content">
|
<ul class="menu-left">
|
||||||
<div class="logo-section">
|
<div class="container">
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="modern-logo-link">
|
<div class="row">
|
||||||
<div class="modern-logo">
|
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="logo-link">
|
||||||
<img src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="<?= htmlspecialchars($config['site_name']); ?>"/>
|
<div class="col-4">
|
||||||
</div>
|
<img class="logo" src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="JILO"/>
|
||||||
<div class="brand-info">
|
|
||||||
<h1 class="brand-name"><?= htmlspecialchars($config['site_name']); ?></h1>
|
|
||||||
<?php if (!empty($config['site_slogan'])): ?>
|
|
||||||
<div class="brand-slogan"><?= htmlspecialchars($config['site_slogan']); ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<li class="font-weight-light text-uppercase" style="font-size: 0.5em; color: whitesmoke; margin-right: 70px; align-content: center;">
|
||||||
|
version <?= htmlspecialchars($config['version'] ?? '1.0.0') ?>
|
||||||
|
</li>
|
||||||
|
|
||||||
<?php if (Session::isValidSession()) { ?>
|
<?php if (Session::isValidSession()) { ?>
|
||||||
|
|
||||||
<?php foreach ($platformsAll as $platform) {
|
<?php foreach ($platformsAll as $platform) {
|
||||||
$platform_switch_url = switchPlatform($platform['id']);
|
$platform_switch_url = switchPlatform($platform['id']);
|
||||||
?>
|
?>
|
||||||
<div>
|
<li style="margin-right: 3px;">
|
||||||
<?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?>
|
<?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?>
|
||||||
Jitsi platforms:
|
<span style="background-color: #fff; border: 1px solid #111; color: #111; border-bottom-color: #fff; padding-bottom: 12px;">
|
||||||
<button class="btn modern-header-btn" type="button" aria-expanded="false">
|
|
||||||
<?= htmlspecialchars($platform['name']) ?>
|
<?= htmlspecialchars($platform['name']) ?>
|
||||||
</button>
|
</span>
|
||||||
<?php } else { ?>
|
<?php } else { ?>
|
||||||
<a href="<?= htmlspecialchars($platform_switch_url) ?>">
|
<a href="<?= htmlspecialchars($platform_switch_url) ?>">
|
||||||
<?= htmlspecialchars($platform['name']) ?>
|
<?= htmlspecialchars($platform['name']) ?>
|
||||||
</a>
|
</a>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
</div>
|
</li>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="header-actions">
|
<ul class="menu-right">
|
||||||
<?php if (Session::isValidSession()) { ?>
|
<?php if (Session::isValidSession()) { ?>
|
||||||
<div class="dropdown">
|
<li class="dropdown">
|
||||||
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
|
||||||
<i class="fas fa-user-circle me-2"></i><?= htmlspecialchars($currentUser) ?>
|
<i class="fas fa-user"></i>
|
||||||
</button>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
|
<div class="dropdown-menu dropdown-menu-right">
|
||||||
<h6 class="dropdown-header modern-dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
|
<h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme">
|
||||||
<i class="fas fa-paint-brush"></i>Change theme
|
<i class="fas fa-paint-brush"></i>Change theme
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
|
||||||
<i class="fas fa-id-card"></i>Profile details
|
<i class="fas fa-id-card"></i>Profile details
|
||||||
</a>
|
</a>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
|
||||||
<i class="fas fa-shield-alt"></i>Login credentials
|
<i class="fas fa-shield-alt"></i>Login credentials
|
||||||
</a>
|
</a>
|
||||||
<?php do_hook('account_menu', ['app_root' => $app_root]); ?>
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
|
||||||
<i class="fas fa-sign-out-alt"></i>Logout
|
<i class="fas fa-sign-out-alt"></i>Logout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
<div class="dropdown">
|
<li class="dropdown">
|
||||||
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
</button>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
|
<div class="dropdown-menu dropdown-menu-right">
|
||||||
<h6 class="dropdown-header modern-dropdown-header">settings</h6>
|
<h6 class="dropdown-header">system</h6>
|
||||||
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
|
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin-tools">
|
||||||
<i class="fas fa-toolbox"></i>Admin
|
<i class="fas fa-toolbox"></i>Admin tools
|
||||||
</a>
|
</a>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
<?php if ($userObject->hasRight($userId, 'superuser') ||
|
<?php if ($userObject->hasRight($userId, 'superuser') ||
|
||||||
$userObject->hasRight($userId, 'view config file')) {?>
|
$userObject->hasRight($userId, 'view config file')) {?>
|
||||||
<a class="dropdown-item modern-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>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
|
@ -87,32 +86,31 @@
|
||||||
$userObject->hasRight($userId, 'edit whitelist') ||
|
$userObject->hasRight($userId, 'edit whitelist') ||
|
||||||
$userObject->hasRight($userId, 'edit blacklist') ||
|
$userObject->hasRight($userId, 'edit blacklist') ||
|
||||||
$userObject->hasRight($userId, 'edit ratelimiting')) { ?>
|
$userObject->hasRight($userId, 'edit ratelimiting')) { ?>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=security">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=security">
|
||||||
<i class="fas fa-shield-alt"></i>Security
|
<i class="fas fa-shield-alt"></i>Security
|
||||||
</a>
|
</a>
|
||||||
<?php do_hook('main_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
<?php if ($userObject->hasRight($userId, 'view app logs')) {?>
|
||||||
|
<?php do_hook('main_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
|
||||||
|
<?php } ?>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<?php } else { ?>
|
<?php } else { ?>
|
||||||
<button class="btn modern-header-btn" onclick="window.location.href='<?= htmlspecialchars($app_root) ?>?page=login'">
|
<li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li>
|
||||||
<i class="fas fa-sign-in-alt me-2"></i>Login
|
|
||||||
</button>
|
|
||||||
<?php do_hook('main_public_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
|
<?php do_hook('main_public_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
|
<li class="dropdown">
|
||||||
<div class="dropdown">
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
|
||||||
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
</button>
|
</a>
|
||||||
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
|
<div class="dropdown-menu dropdown-menu-right">
|
||||||
<h6 class="dropdown-header modern-dropdown-header">resources</h6>
|
<h6 class="dropdown-header">resources</h6>
|
||||||
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=help">
|
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=help">
|
||||||
<i class="fas fa-question-circle"></i>Help
|
<i class="fas fa-question-circle"></i>Help
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- /Menu -->
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- /Modern Menu -->
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
<div class="row" style="padding-right: 0.75rem;">
|
<div class="row">
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="col-md-3 sidebar-wrapper" id="sidebar">
|
<div class="col-md-3 mb-5 sidebar-wrapper bg-light" id="sidebar">
|
||||||
<div class="text-center" id="time_now">
|
<div class="text-center" style="border: 1px solid #0dcaf0; height: 22px;" id="time_now">
|
||||||
<?php
|
<?php
|
||||||
$timeNow = new DateTime('now', new DateTimeZone($userTimezone));
|
$timeNow = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
?>
|
?>
|
||||||
<span><?= htmlspecialchars($timeNow->format('H:i')) ?> <?= htmlspecialchars($userTimezone) ?></span>
|
<span style="vertical-align: top; font-size: 12px;"><?= htmlspecialchars($timeNow->format('H:i')) ?> <?= htmlspecialchars($userTimezone) ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-4"><button class="btn btn-sm btn-info toggle-sidebar-button" type="button" id="toggleSidebarButton" value=">>"></button></div>
|
<div class="col-4"><button class="btn btn-sm btn-info toggle-sidebar-button" type="button" id="toggleSidebarButton" value=">>"></button></div>
|
||||||
<div class="sidebar-content card mt-0">
|
<div class="sidebar-content card ml-3 mt-3">
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=dashboard">
|
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=dashboard">
|
||||||
|
|
@ -19,7 +19,7 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<li class="list-group-item sidebar-section-title-first">logs statistics</li>
|
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>logs statistics</small></p></li>
|
||||||
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences">
|
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences">
|
||||||
<li class="list-group-item<?php if ($page === 'conferences') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
|
<li class="list-group-item<?php if ($page === 'conferences') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
|
||||||
|
|
@ -37,7 +37,7 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<li class="list-group-item sidebar-section-title">live data</li>
|
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>live data</small></p></li>
|
||||||
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=graphs">
|
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=graphs">
|
||||||
<li class="list-group-item<?php if ($page === 'graphs') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
|
<li class="list-group-item<?php if ($page === 'graphs') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
|
||||||
|
|
@ -65,7 +65,7 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<li class="list-group-item sidebar-section-title">jitsi platforms settings</li>
|
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>jitsi platforms settings</small></p></li>
|
||||||
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?page=settings">
|
<a href="<?= htmlspecialchars($app_root) ?>?page=settings">
|
||||||
<li class="list-group-item<?php if ($page === 'settings') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
|
<li class="list-group-item<?php if ($page === 'settings') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,70 @@
|
||||||
<!-- user profile -->
|
|
||||||
<div class="action-card">
|
<!-- user profile -->
|
||||||
<div class="action-card-header">
|
<div class="card text-center w-50 mx-auto">
|
||||||
<p class="action-eyebrow">Account</p>
|
|
||||||
<h2 class="action-title">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></h2>
|
<p class="h4 card-header">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></p>
|
||||||
<p class="action-subtitle">Update your personal details, avatar, and access rights in one streamlined view.</p>
|
<div class="card-body">
|
||||||
</div>
|
|
||||||
<div class="action-card-body">
|
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile" enctype="multipart/form-data">
|
||||||
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile" enctype="multipart/form-data" class="action-form" novalidate>
|
|
||||||
<?php include CSRF_TOKEN_INCLUDE; ?>
|
<?php include CSRF_TOKEN_INCLUDE; ?>
|
||||||
<div class="row g-4 align-items-start">
|
<div class="row">
|
||||||
<div class="col-lg-4">
|
<p class="border rounded bg-light mb-4"><small>edit the profile fields</small></p>
|
||||||
<div class="tm-profile-avatar card h-100">
|
<div class="col-md-4 avatar-container">
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
|
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
|
||||||
</div>
|
<div class="avatar-btn-container">
|
||||||
<div class="avatar-btn-group">
|
|
||||||
<label for="avatar-upload" class="btn btn-outline-primary w-100">
|
<label for="avatar-upload" class="avatar-btn avatar-btn-select btn btn-primary">
|
||||||
<i class="fas fa-upload me-2"></i>Upload new
|
<i class="fas fa-folder" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="select new avatar"></i>
|
||||||
</label>
|
</label>
|
||||||
<input type="file" id="avatar-upload" name="avatar_file" accept="image/*" style="display:none;">
|
<input type="file" id="avatar-upload" name="avatar_file" accept="image/*" style="display:none;">
|
||||||
|
|
||||||
<?php if ($default_avatar) { ?>
|
<?php if ($default_avatar) { ?>
|
||||||
<button type="button" class="btn btn-outline-secondary w-100" data-toggle="modal" data-target="#confirmDeleteModal" disabled>
|
<button type="button" class="avatar-btn avatar-btn-remove btn btn-secondary" data-toggle="modal" data-target="#confirmDeleteModal" disabled>
|
||||||
<?php } else { ?>
|
<?php } else { ?>
|
||||||
<button type="button" class="btn btn-outline-danger w-100" data-toggle="modal" data-target="#confirmDeleteModal">
|
<button type="button" class="avatar-btn avatar-btn-remove btn btn-danger" data-toggle="modal" data-target="#confirmDeleteModal">
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
<i class="fas fa-trash me-2"></i>Remove avatar
|
<i class="fas fa-trash" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="remove current avatar"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="avatar-hint">PNG, JPG up to 500 KB.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-8">
|
<div class="col-md-8">
|
||||||
<div class="tm-profile-section">
|
<!--div class="row mb-3">
|
||||||
<h3 class="tm-profile-section-title">Personal info</h3>
|
<div class="col-md-4 text-end">
|
||||||
<div class="row g-3">
|
<label for="username" class="form-label"><small>username:</small></label>
|
||||||
<div class="col-md-6">
|
<span class="text-danger" style="margin-right: -12px;">*</span>
|
||||||
<div class="action-form-group">
|
|
||||||
<label for="name" class="action-form-label">Full name</label>
|
|
||||||
<input class="form-control action-form-control" type="text" name="name" id="name" value="<?= htmlspecialchars($userDetails[0]['name'] ?? '') ?>" autofocus />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<input class="form-control" type="text" name="username" value="<?= htmlspecialchars($userDetails[0]['username']) ?>" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
</div-->
|
||||||
<div class="action-form-group">
|
|
||||||
<label for="email" class="action-form-label">Email address</label>
|
<div class="row mb-3">
|
||||||
<input class="form-control action-form-control" type="text" name="email" id="email" value="<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>" />
|
<div class="col-md-4 text-end">
|
||||||
</div>
|
<label for="name" class="form-label"><small>name:</small></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<input class="form-control" type="text" name="name" value="<?= htmlspecialchars($userDetails[0]['name'] ?? '') ?>" autofocus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-profile-section">
|
<div class="row mb-3">
|
||||||
<h3 class="tm-profile-section-title">Timezone</h3>
|
<div class="col-md-4 text-end">
|
||||||
<div class="action-form-group">
|
<label for="email" class="form-label"><small>email:</small></label>
|
||||||
<label for="timezone" class="action-form-label">Preferred timezone</label>
|
</div>
|
||||||
<select class="form-control action-form-control" name="timezone" id="timezone">
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<input class="form-control" type="text" name="email" value="<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<label for="timezone" class="form-label"><small>timezone:</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<select class="form-control" name="timezone" id="timezone">
|
||||||
<?php foreach ($allTimezones as $timezone) { ?>
|
<?php foreach ($allTimezones as $timezone) { ?>
|
||||||
<option value="<?= htmlspecialchars($timezone) ?>" <?= $timezone === $userTimezone ? 'selected' : '' ?>>
|
<option value="<?= htmlspecialchars($timezone) ?>" <?= $timezone === $userTimezone ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($timezone) ?> (<?= htmlspecialchars(getUTCOffset($timezone)) ?>)
|
<?= htmlspecialchars($timezone) ?> (<?= htmlspecialchars(getUTCOffset($timezone)) ?>)
|
||||||
|
|
@ -64,18 +74,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-profile-section">
|
<div class="row mb-3">
|
||||||
<h3 class="tm-profile-section-title">Bio</h3>
|
<div class="col-md-4 text-end">
|
||||||
<div class="action-form-group">
|
<label for="bio" class="form-label"><small>bio:</small></label>
|
||||||
<textarea class="form-control action-form-control" name="bio" rows="6" placeholder="Share something about yourself, your role, or preferences."><?= htmlspecialchars($userDetails[0]['bio'] ?? '') ?></textarea>
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<textarea class="form-control" name="bio" rows="10"><?= htmlspecialchars($userDetails[0]['bio'] ?? '') ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-profile-section">
|
<div class="row mb-3">
|
||||||
<h3 class="tm-profile-section-title">Rights</h3>
|
<div class="col-md-4 text-end">
|
||||||
<p class="tm-profile-section-helper">Toggle the permissions that should be associated with this user.</p>
|
<label for="rights" class="form-label"><small>rights:</small></label>
|
||||||
<div class="tm-rights-grid">
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
<?php foreach ($allRights as $right) {
|
<?php foreach ($allRights as $right) {
|
||||||
|
// Check if the current right exists in $userRights
|
||||||
$isChecked = false;
|
$isChecked = false;
|
||||||
foreach ($userRights as $userRight) {
|
foreach ($userRights as $userRight) {
|
||||||
if ($userRight['right_id'] === $right['right_id']) {
|
if ($userRight['right_id'] === $right['right_id']) {
|
||||||
|
|
@ -83,7 +97,7 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} ?>
|
} ?>
|
||||||
<div class="form-check tm-right-item">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="rights[]" value="<?= htmlspecialchars($right['right_id']) ?>" id="right_<?= htmlspecialchars($right['right_id']) ?>" <?= $isChecked ? 'checked' : '' ?> />
|
<input class="form-check-input" type="checkbox" name="rights[]" value="<?= htmlspecialchars($right['right_id']) ?>" id="right_<?= htmlspecialchars($right['right_id']) ?>" <?= $isChecked ? 'checked' : '' ?> />
|
||||||
<label class="form-check-label" for="right_<?= htmlspecialchars($right['right_id']) ?>"><?= htmlspecialchars($right['right_name']) ?></label>
|
<label class="form-check-label" for="right_<?= htmlspecialchars($right['right_id']) ?>"><?= htmlspecialchars($right['right_name']) ?></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -91,11 +105,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-actions">
|
|
||||||
<a href="<?= htmlspecialchars($app_root) ?>?page=profile" class="btn btn-light">Cancel</a>
|
|
||||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="<?= htmlspecialchars($app_root) ?>?page=profile" class="btn btn-secondary">Cancel</a>
|
||||||
|
<input type="submit" class="btn btn-primary" value="Save" />
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -107,9 +123,12 @@
|
||||||
<h5 class="modal-title" id="confirmDeleteModalLabel">Confirm Avatar Deletion</h5>
|
<h5 class="modal-title" id="confirmDeleteModalLabel">Confirm Avatar Deletion</h5>
|
||||||
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body text-center">
|
<div class="modal-body">
|
||||||
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
|
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
|
||||||
<p class="mt-3 mb-0">Are you sure you want to delete your avatar?<br />This action cannot be undone.</p>
|
<br />
|
||||||
|
Are you sure you want to delete your avatar?
|
||||||
|
<br />
|
||||||
|
This action cannot be undone.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||||
|
|
@ -121,23 +140,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /user profile -->
|
<!-- /user profile -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// Preview the uploaded avatar
|
||||||
// Preview the uploaded avatar
|
document.getElementById('avatar-upload').addEventListener('change', function(event) {
|
||||||
document.getElementById('avatar-upload').addEventListener('change', function(event) {
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function() {
|
reader.onload = function() {
|
||||||
document.querySelector('.avatar-img').src = reader.result;
|
document.querySelector('.avatar-img').src = reader.result;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(event.target.files[0]);
|
reader.readAsDataURL(event.target.files[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Avatar file size and type control
|
// Avatar file size and type control
|
||||||
document.getElementById('avatar-upload').addEventListener('change', function() {
|
document.getElementById('avatar-upload').addEventListener('change', function() {
|
||||||
const maxFileSize = 500 * 1024; // 500 KB in bytes
|
const maxFileSize = 500 * 1024; // 500 KB in bytes
|
||||||
const currentAvatar = '<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>'; // current avatar
|
const currentAvatar = '<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>'; // current avatar
|
||||||
const file = this.files[0];
|
const file = this.files[0];
|
||||||
|
|
@ -150,13 +169,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
document.querySelector('.avatar-img').src = currentAvatar;
|
document.querySelector('.avatar-img').src = currentAvatar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submitting the avatar deletion confirmation modal form
|
// Submitting the avatar deletion confirmation modal form
|
||||||
document.getElementById('confirm-delete').addEventListener('click', function(event) {
|
document.getElementById('confirm-delete').addEventListener('click', function(event) {
|
||||||
event.preventDefault(); // Prevent the outer form from submitting
|
event.preventDefault(); // Prevent the outer form from submitting
|
||||||
document.getElementById('remove-avatar-form').submit();
|
document.getElementById('remove-avatar-form').submit();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to detect user's timezone and select it in the dropdown
|
// Function to detect user's timezone and select it in the dropdown
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,87 @@
|
||||||
|
|
||||||
<?php
|
<!-- user profile -->
|
||||||
$user = $userDetails[0] ?? [];
|
<div class="card text-center w-50 mx-auto">
|
||||||
$username = $user['username'] ?? '';
|
|
||||||
$name = $user['name'] ?? '';
|
|
||||||
$email = $user['email'] ?? '';
|
|
||||||
$timezoneName = $user['timezone'] ?? '';
|
|
||||||
$timezoneOffset = $timezoneName ? getUTCOffset($timezoneName) : '';
|
|
||||||
$bio = trim($user['bio'] ?? '');
|
|
||||||
$rightsNames = array_map(function ($right) {
|
|
||||||
return trim($right['right_name'] ?? '');
|
|
||||||
}, $userRights);
|
|
||||||
$rightsNames = array_filter($rightsNames, function ($label) {
|
|
||||||
return $label !== '';
|
|
||||||
});
|
|
||||||
$rightsCount = count($rightsNames);
|
|
||||||
$displayName = $name ?: $username ?: 'User profile';
|
|
||||||
$timezoneDisplay = '';
|
|
||||||
if ($timezoneName) {
|
|
||||||
if ($timezoneOffset !== '') {
|
|
||||||
$offsetLabel = stripos($timezoneOffset, 'UTC') === 0 ? $timezoneOffset : 'UTC' . $timezoneOffset;
|
|
||||||
$timezoneDisplay = sprintf('%s (%s)', $timezoneName, $offsetLabel);
|
|
||||||
} else {
|
|
||||||
$timezoneDisplay = $timezoneName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
<p class="h4 card-header">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></p>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
<section class="tm-directory tm-profile-view">
|
<div class="row">
|
||||||
<div class="tm-hero-card tm-hero-card--stacked tm-profile-hero">
|
|
||||||
<div class="tm-profile-hero-main">
|
<div class="col-md-4 avatar-container">
|
||||||
<div class="tm-profile-avatar-frame">
|
<div>
|
||||||
<img src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="Avatar of <?= htmlspecialchars($displayName) ?>" />
|
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
|
||||||
</div>
|
|
||||||
<div class="tm-profile-hero-body">
|
|
||||||
<h1 class="tm-profile-title"><?= htmlspecialchars($displayName) ?></h1>
|
|
||||||
<p class="tm-profile-subtitle">Personal details and access summary for this TotalMeet account.</p>
|
|
||||||
<div class="tm-profile-hero-meta">
|
|
||||||
<?php if ($username): ?>
|
|
||||||
<span class="tm-hero-pill pill-neutral">
|
|
||||||
<i class="fas fa-user"></i>
|
|
||||||
@<?= htmlspecialchars($username) ?>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($timezoneDisplay): ?>
|
|
||||||
<span class="tm-hero-pill pill-primary">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
<?= htmlspecialchars($timezoneDisplay) ?>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="tm-hero-pill pill-accent">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
<?= $rightsCount ?> <?= $rightsCount === 1 ? 'Right' : 'Rights' ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tm-profile-hero-actions">
|
|
||||||
<a class="btn btn-primary" href="<?= htmlspecialchars($app_root) ?>?page=profile&action=edit">
|
|
||||||
<i class="fas fa-edit"></i> Edit profile
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-profile-panels">
|
<div class="col-md-8">
|
||||||
<article class="tm-profile-panel">
|
|
||||||
<header>
|
|
||||||
<h3>Account details</h3>
|
|
||||||
</header>
|
|
||||||
<dl class="tm-profile-detail-list">
|
|
||||||
<div class="tm-profile-detail-item">
|
|
||||||
<dt>Full name</dt>
|
|
||||||
<dd><?= $name ? htmlspecialchars($name) : '<span class="tm-profile-placeholder">Not provided</span>' ?></dd>
|
|
||||||
</div>
|
|
||||||
<div class="tm-profile-detail-item">
|
|
||||||
<dt>Email</dt>
|
|
||||||
<dd><?= $email ? htmlspecialchars($email) : '<span class="tm-profile-placeholder">Not provided</span>' ?></dd>
|
|
||||||
</div>
|
|
||||||
<div class="tm-profile-detail-item">
|
|
||||||
<dt>Username</dt>
|
|
||||||
<dd><?= $username ? htmlspecialchars($username) : '<span class="tm-profile-placeholder">Not provided</span>' ?></dd>
|
|
||||||
</div>
|
|
||||||
<div class="tm-profile-detail-item">
|
|
||||||
<dt>Timezone</dt>
|
|
||||||
<dd><?= $timezoneDisplay ? htmlspecialchars($timezoneDisplay) : '<span class="tm-profile-placeholder">Not set</span>' ?></dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="tm-profile-panel">
|
<!--div class="row mb-3">
|
||||||
<header>
|
<div class="col-md-4 text-end">
|
||||||
<h3>Bio</h3>
|
<label class="form-label"><small>username:</small></label>
|
||||||
</header>
|
|
||||||
<?php if ($bio !== ''): ?>
|
|
||||||
<p class="tm-profile-bio"><?= nl2br(htmlspecialchars($bio)) ?></p>
|
|
||||||
<?php else: ?>
|
|
||||||
<p class="tm-profile-placeholder">This user hasn’t added a bio yet.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="tm-profile-panel">
|
|
||||||
<header>
|
|
||||||
<h3>User rights</h3>
|
|
||||||
</header>
|
|
||||||
<?php if ($rightsCount): ?>
|
|
||||||
<ul class="tm-profile-rights">
|
|
||||||
<?php foreach ($rightsNames as $rightLabel): ?>
|
|
||||||
<li>
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
<?= htmlspecialchars($rightLabel) ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php else: ?>
|
|
||||||
<p class="tm-profile-placeholder">No rights assigned yet.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<?php do_hook('profile.additional_panels', [
|
|
||||||
'subscription' => $subscription ?? null,
|
|
||||||
'app_root' => $app_root,
|
|
||||||
'userId' => $user['id'] ?? null,
|
|
||||||
]); ?>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<?= htmlspecialchars($userDetails[0]['username']) ?>
|
||||||
|
</div>
|
||||||
|
</div-->
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<label class="form-label"><small>name:</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<?= htmlspecialchars($userDetails[0]['name'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<label class="form-label"><small>email:</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<label class="form-label"><small>timezone:</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<?php if (!empty($userDetails[0]['timezone'])) { ?>
|
||||||
|
<?= htmlspecialchars($userDetails[0]['timezone']) ?> <span style="font-size: 0.66em;">(<?= htmlspecialchars(getUTCOffset($userDetails[0]['timezone'])) ?>)</span>
|
||||||
|
<?php } ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<label class="form-label"><small>bio:</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<textarea class="scroll-box" rows="10" readonly><?= htmlspecialchars($userDetails[0]['bio'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<label class="form-label"><small>rights:</small></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 text-start bg-light">
|
||||||
|
<?php foreach ($userRights as $right) { ?>
|
||||||
|
<?= htmlspecialchars($right['right_name'] ?? '') ?>
|
||||||
|
<br />
|
||||||
|
<?php } ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="<?= htmlspecialchars($app_root) ?>?page=profile&action=edit" class="btn btn-primary">Edit</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /user profile -->
|
||||||
|
|
|
||||||
|
|
@ -10,124 +10,35 @@
|
||||||
* - isActive: Whether this is the current theme
|
* - isActive: Whether this is the current theme
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<?php
|
<div class="container mt-4">
|
||||||
$activeThemeName = 'Default';
|
<h2>Theme switcher</h2>
|
||||||
foreach ($themes as $themeData) {
|
<p class="text-muted">Select a theme to change the appearance of the application.</p>
|
||||||
if (!empty($themeData['isActive'])) {
|
<div class="row mt-4">
|
||||||
$activeThemeName = $themeData['name'];
|
<?php foreach ($themes as $themeId => $theme): ?>
|
||||||
break;
|
<div class="col-md-4 mb-4">
|
||||||
}
|
<div class="card h-100 <?= $theme['isActive'] ? 'border-primary' : '' ?>">
|
||||||
}
|
<!-- Theme screenshot -->
|
||||||
$totalThemes = count($themes);
|
<div class="theme-screenshot" style="height: 150px; background-size: cover; background-position: center; background-color: #f8f9fa; <?= $theme['screenshotUrl'] ? 'background-image: url(' . htmlspecialchars($theme['screenshotUrl']) . ')' : '' ?>">
|
||||||
?>
|
<?php if (!$theme['screenshotUrl']): ?>
|
||||||
|
<div class="h-100 d-flex align-items-center justify-content-center text-muted">No preview available</div>
|
||||||
<section class="tm-directory tm-theme-directory">
|
<?php endif; ?>
|
||||||
<div class="tm-hero-card tm-hero-card--stacked">
|
|
||||||
<div class="tm-hero-head">
|
|
||||||
<div class="tm-hero-body">
|
|
||||||
<div class="tm-hero-heading">
|
|
||||||
<h1 class="tm-hero-title">Themes</h1>
|
|
||||||
<p class="tm-hero-subtitle">Personalize <?= htmlspecialchars($config['site_name']); ?> with custom visual styles.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tm-hero-meta">
|
<?php if ($theme['isActive']): ?>
|
||||||
<span class="tm-hero-pill pill-neutral">
|
<div class="card-header bg-primary text-white">Current theme</div>
|
||||||
<i class="fas fa-layer-group"></i>
|
<?php endif; ?>
|
||||||
<?= $totalThemes ?> available
|
<div class="card-body d-flex flex-column">
|
||||||
</span>
|
<h5 class="card-title"><?= htmlspecialchars($theme['name']) ?></h5>
|
||||||
<span class="tm-hero-pill pill-primary">
|
<p class="card-text text-muted">Theme ID: <code><?= htmlspecialchars($themeId) ?></code></p>
|
||||||
<i class="fas fa-check-circle"></i>
|
<div class="mt-auto">
|
||||||
Active: <?= htmlspecialchars($activeThemeName) ?>
|
<?php if (!$theme['isActive']): ?>
|
||||||
</span>
|
<a href="?page=theme&switch_to=<?= urlencode($themeId) ?>&csrf_token=<?= $csrf_token ?>" class="btn btn-primary">Switch to this theme</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<button class="btn btn-outline-secondary" disabled>Currently active</button>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
<div class="tm-theme-gallery">
|
|
||||||
<div class="tm-theme-grid">
|
|
||||||
<?php foreach ($themes as $themeId => $theme):
|
|
||||||
$isActive = !empty($theme['isActive']);
|
|
||||||
$screenshot = $theme['screenshotUrl'];
|
|
||||||
?>
|
|
||||||
<article class="tm-theme-card<?= $isActive ? ' is-active' : '' ?>">
|
|
||||||
<div class="tm-theme-preview" style="<?= $screenshot ? 'background-image: url(' . htmlspecialchars($screenshot) . ')' : '' ?>">
|
|
||||||
<?php if (!$screenshot): ?>
|
|
||||||
<span>No preview available</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="tm-theme-body">
|
|
||||||
<div class="tm-theme-heading">
|
|
||||||
<p class="tm-theme-id">ID: <code><?= htmlspecialchars($themeId) ?></code></p>
|
|
||||||
<h3 class="tm-theme-name"><?= htmlspecialchars($theme['name']) ?></h3>
|
|
||||||
</div>
|
|
||||||
<?php if (!empty($theme['description'])): ?>
|
|
||||||
<p class="tm-theme-description">
|
|
||||||
<?= htmlspecialchars($theme['description']) ?>
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
<dl class="tm-theme-meta">
|
|
||||||
<?php if (!empty($theme['version'])): ?>
|
|
||||||
<div class="tm-theme-meta-item">
|
|
||||||
<dt>Version</dt>
|
|
||||||
<dd><?= htmlspecialchars($theme['version']) ?></dd>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($theme['author'])): ?>
|
|
||||||
<div class="tm-theme-meta-item">
|
|
||||||
<dt>Author</dt>
|
|
||||||
<dd><?= htmlspecialchars($theme['author']) ?></dd>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</dl>
|
|
||||||
<?php if (!empty($theme['tags'])): ?>
|
|
||||||
<ul class="tm-theme-tags">
|
|
||||||
<?php foreach ($theme['tags'] as $tag): $tagLabel = trim((string)$tag); if ($tagLabel === '') { continue; } ?>
|
|
||||||
<li><?= htmlspecialchars($tagLabel) ?></li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
<dl class="tm-theme-stats">
|
|
||||||
<?php if (!empty($theme['type'])): ?>
|
|
||||||
<div class="tm-theme-stat">
|
|
||||||
<dt>Type</dt>
|
|
||||||
<dd><?= htmlspecialchars($theme['type']) ?></dd>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($theme['file_count'])): ?>
|
|
||||||
<div class="tm-theme-stat">
|
|
||||||
<dt>Files</dt>
|
|
||||||
<dd><?= number_format((int)$theme['file_count']) ?></dd>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($theme['path'])): ?>
|
|
||||||
<div class="tm-theme-stat">
|
|
||||||
<dt>Location</dt>
|
|
||||||
<dd><code><?= htmlspecialchars($theme['path']) ?></code></dd>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($theme['last_modified'])):
|
|
||||||
$lastEdited = is_numeric($theme['last_modified']) ? date('M j, Y', (int)$theme['last_modified']) : $theme['last_modified'];
|
|
||||||
?>
|
|
||||||
<div class="tm-theme-stat">
|
|
||||||
<dt>Last edit</dt>
|
|
||||||
<dd><?= htmlspecialchars($lastEdited) ?></dd>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</dl>
|
|
||||||
<div class="tm-theme-actions">
|
|
||||||
<?php if ($isActive): ?>
|
|
||||||
<button class="btn btn-outline-secondary" disabled>
|
|
||||||
<i class="fas fa-check"></i> Active
|
|
||||||
</button>
|
|
||||||
<?php else: ?>
|
|
||||||
<a class="btn btn-primary" href="?page=theme&switch_to=<?= urlencode($themeId) ?>&csrf_token=<?= $csrf_token ?>">
|
|
||||||
<i class="fas fa-paint-brush"></i> Apply
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<?php if ($widget['collapsible'] === true) { ?>
|
||||||
|
<a style="text-decoration: none;" data-toggle="collapse" href="#collapse<?= htmlspecialchars($widget['name']) ?>" role="button" aria-expanded="true" aria-controls="collapse<?= htmlspecialchars($widget['name']) ?>">
|
||||||
|
<div class="card w-auto bg-light card-body" style="flex-direction: row;"><?= $widget['title'] ?></div>
|
||||||
|
<?php } else { ?>
|
||||||
|
<div class="card w-auto bg-light border-light card-body" style="flex-direction: row;"><?= $widget['title'] ?></div>
|
||||||
|
<?php } ?>
|
||||||
|
<?php if ($widget['filter'] === true) {
|
||||||
|
include '../app/templates/block-results-filter.php'; } ?>
|
||||||
|
<?php if ($widget['collapsible'] === true) { ?>
|
||||||
|
</a>
|
||||||
|
<?php } ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- widget "<?= htmlspecialchars($widget['name']) ?>" -->
|
||||||
|
<div class="collapse show" id="collapse<?= htmlspecialchars($widget['name']) ?>">
|
||||||
|
<?php if ($time_range_specified) { ?>
|
||||||
|
<p class="m-3">time period:
|
||||||
|
<strong>
|
||||||
|
<?= $from_time == '0000-01-01' ? 'beginning' : date('d M Y', strtotime($from_time)) ?> - <?= $until_time == '9999-12-31' ? 'now' : date('d M Y', strtotime($until_time)) ?>
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<?php } ?>
|
||||||
|
<div class="mb-5">
|
||||||
|
<?php if ($widget['full'] === true) { ?>
|
||||||
|
<table class="table table-results table-striped table-hover table-bordered">
|
||||||
|
<thead class="thead-dark">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"></th>
|
||||||
|
<?php foreach ($widget['records'] as $record) { ?>
|
||||||
|
<th scope="col"><?= htmlspecialchars($record['table_headers']) ?></th>
|
||||||
|
<?php } ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>conferences</td>
|
||||||
|
<?php foreach ($widget['records'] as $record) { ?>
|
||||||
|
<td><?php if (!empty($record['conferences'])) { ?>
|
||||||
|
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['conferences']) ?></a> <?php } else { ?>
|
||||||
|
0<?php } ?>
|
||||||
|
</td>
|
||||||
|
<?php } ?>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>participants</td>
|
||||||
|
<?php foreach ($widget['records'] as $record) { ?>
|
||||||
|
<td><?php if (!empty($record['participants'])) { ?>
|
||||||
|
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=participants&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['participants']) ?></a> <?php } else { ?>
|
||||||
|
0<?php } ?>
|
||||||
|
</td>
|
||||||
|
<?php } ?>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php } else { ?>
|
||||||
|
<p class="m-3">No matching records found.</p>
|
||||||
|
<?php } ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /widget "<?= htmlspecialchars($widget['name']) ?>" -->
|
||||||
|
|
@ -20,8 +20,5 @@ define('REGISTRATIONS_MAIN_MENU_POSITION', 30);
|
||||||
register_hook('main_public_menu', function($ctx) {
|
register_hook('main_public_menu', function($ctx) {
|
||||||
$section = defined('REGISTRATIONS_MAIN_MENU_SECTION') ? REGISTRATIONS_MAIN_MENU_SECTION : 'main';
|
$section = defined('REGISTRATIONS_MAIN_MENU_SECTION') ? REGISTRATIONS_MAIN_MENU_SECTION : 'main';
|
||||||
$position = defined('REGISTRATIONS_MAIN_MENU_POSITION') ? REGISTRATIONS_MAIN_MENU_POSITION : 100;
|
$position = defined('REGISTRATIONS_MAIN_MENU_POSITION') ? REGISTRATIONS_MAIN_MENU_POSITION : 100;
|
||||||
$appRoot = isset($ctx['app_root']) ? htmlspecialchars($ctx['app_root'], ENT_QUOTES, 'UTF-8') : '';
|
echo '<li><a href="?page=register">register</a></li>';
|
||||||
echo " <button class=\"btn modern-header-btn\" onclick=\"window.location.href='" . $appRoot . "?page=register'\">
|
|
||||||
<i class=\"fas fa-user-edit me-2\"></i>Register
|
|
||||||
</button>";
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,37 @@
|
||||||
<!-- registration form -->
|
<!-- registration form -->
|
||||||
<div class="action-card">
|
<div class="card text-center w-50 mx-auto">
|
||||||
<div class="action-card-header">
|
<h2 class="card-header">Register</h2>
|
||||||
<p class="action-eyebrow">Create account</p>
|
<div class="card-body">
|
||||||
<h2 class="action-title">Register</h2>
|
<p class="card-text">Enter credentials for registration:</p>
|
||||||
<p class="action-subtitle">Enter your credentials to create a new account</p>
|
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=register">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-card-body">
|
|
||||||
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=register" class="action-form">
|
|
||||||
<?php include CSRF_TOKEN_INCLUDE; ?>
|
<?php include CSRF_TOKEN_INCLUDE; ?>
|
||||||
<div class="action-form-group">
|
<div class="form-group mb-3">
|
||||||
<label for="username" class="action-form-label">Username</label>
|
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
|
||||||
<input type="text" class="form-control action-form-control" name="username" placeholder="Username"
|
|
||||||
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
|
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
<div class="action-form-group">
|
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
|
||||||
<label for="password" class="action-form-label">Password</label>
|
|
||||||
<input type="password" class="form-control action-form-control" name="password" placeholder="Password"
|
|
||||||
pattern=".{8,}" title="Eight or more characters"
|
pattern=".{8,}" title="Eight or more characters"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
<div class="action-form-group">
|
<input type="password" class="form-control w-50 mx-auto" name="confirm_password" placeholder="Confirm password"
|
||||||
<label for="confirm_password" class="action-form-label">Confirm Password</label>
|
|
||||||
<input type="password" class="form-control action-form-control" name="confirm_password" placeholder="Confirm password"
|
|
||||||
pattern=".{8,}" title="Eight or more characters"
|
pattern=".{8,}" title="Eight or more characters"
|
||||||
required />
|
required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
<div class="action-form-group">
|
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="terms" name="terms" required>
|
|
||||||
<label class="form-check-label" for="terms">
|
<label class="form-check-label" for="terms">
|
||||||
|
<input type="checkbox" class="form-check-input" id="terms" name="terms" required>
|
||||||
I agree to the <a href="<?= htmlspecialchars($app_root) ?>?page=terms" target="_blank">terms & conditions</a> and <a href="<?= htmlspecialchars($app_root) ?>?page=privacy" target="_blank">privacy policy</a>
|
I agree to the <a href="<?= htmlspecialchars($app_root) ?>?page=terms" target="_blank">terms & conditions</a> and <a href="<?= htmlspecialchars($app_root) ?>?page=privacy" target="_blank">privacy policy</a>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted mt-2 d-block">
|
<small class="text-muted mt-2">
|
||||||
We use cookies to improve your experience. See our <a href="<?= htmlspecialchars($app_root) ?>?page=cookies" target="_blank">cookies policy</a>
|
We use cookies to improve your experience. See our <a href="<?= htmlspecialchars($app_root) ?>?page=cookies" target="_blank">cookies policy</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="submit" class="btn btn-primary" value="Register" />
|
||||||
<div class="action-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="fas fa-user-plus me-2"></i>Create account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,13 @@
|
||||||
//ini_set('display_startup_errors', 1);
|
//ini_set('display_startup_errors', 1);
|
||||||
//error_reporting(E_ALL);
|
//error_reporting(E_ALL);
|
||||||
|
|
||||||
// Define main app path
|
|
||||||
define('APP_PATH', __DIR__ . '/../app/');
|
|
||||||
|
|
||||||
// Prepare config loader
|
// Prepare config loader
|
||||||
require_once APP_PATH . 'core/ConfigLoader.php';
|
require_once __DIR__ . '/../app/core/ConfigLoader.php';
|
||||||
use App\Core\ConfigLoader;
|
use App\Core\ConfigLoader;
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
$config = ConfigLoader::loadConfig([
|
$config = ConfigLoader::loadConfig([
|
||||||
APP_PATH . 'config/jilo-web.conf.php',
|
__DIR__ . '/../app/config/jilo-web.conf.php',
|
||||||
__DIR__ . '/../jilo-web.conf.php',
|
__DIR__ . '/../jilo-web.conf.php',
|
||||||
'/srv/jilo-web/jilo-web.conf.php',
|
'/srv/jilo-web/jilo-web.conf.php',
|
||||||
'/opt/jilo-web/jilo-web.conf.php',
|
'/opt/jilo-web/jilo-web.conf.php',
|
||||||
|
|
@ -43,8 +40,8 @@ $app_root = $config['folder'] ?? '/';
|
||||||
|
|
||||||
// Preparing plugins and hooks
|
// Preparing plugins and hooks
|
||||||
// Initialize HookDispatcher and plugin system
|
// Initialize HookDispatcher and plugin system
|
||||||
require_once APP_PATH . 'core/HookDispatcher.php';
|
require_once __DIR__ . '/../app/core/HookDispatcher.php';
|
||||||
require_once APP_PATH . 'core/PluginManager.php';
|
require_once __DIR__ . '/../app/core/PluginManager.php';
|
||||||
use App\Core\HookDispatcher;
|
use App\Core\HookDispatcher;
|
||||||
use App\Core\PluginManager;
|
use App\Core\PluginManager;
|
||||||
|
|
||||||
|
|
@ -81,57 +78,68 @@ $GLOBALS['enabled_plugins'] = $enabled_plugins;
|
||||||
|
|
||||||
// Define CSRF token include path globally
|
// Define CSRF token include path globally
|
||||||
if (!defined('CSRF_TOKEN_INCLUDE')) {
|
if (!defined('CSRF_TOKEN_INCLUDE')) {
|
||||||
define('CSRF_TOKEN_INCLUDE', APP_PATH . 'includes/csrf_token.php');
|
define('CSRF_TOKEN_INCLUDE', dirname(__DIR__) . '/app/includes/csrf_token.php');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global cnstants
|
// Global cnstants
|
||||||
require_once APP_PATH . 'includes/constants.php';
|
require_once '../app/includes/constants.php';
|
||||||
|
|
||||||
// we start output buffering and
|
// we start output buffering and
|
||||||
// flush it later only when there is no redirect
|
// flush it later only when there is no redirect
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
// Start session before any session-dependent code
|
// Start session before any session-dependent code
|
||||||
require_once APP_PATH . 'classes/session.php';
|
require_once '../app/classes/session.php';
|
||||||
|
|
||||||
// Initialize themes system after session is started
|
// Initialize themes system after session is started
|
||||||
require_once APP_PATH . 'helpers/theme.php';
|
require_once __DIR__ . '/../app/helpers/theme.php';
|
||||||
use app\Helpers\Theme;
|
use app\Helpers\Theme;
|
||||||
|
|
||||||
Session::startSession();
|
Session::startSession();
|
||||||
|
|
||||||
|
// Reset flash messages display flag for new page load
|
||||||
|
$_SESSION['flash_messages_displayed'] = false;
|
||||||
|
|
||||||
// Define page variable early via sanitize
|
// Define page variable early via sanitize
|
||||||
require_once APP_PATH . 'includes/sanitize.php';
|
require_once __DIR__ . '/../app/includes/sanitize.php';
|
||||||
// Ensure $page is defined to avoid undefined variable
|
// Ensure $page is defined to avoid undefined variable
|
||||||
if (!isset($page)) {
|
if (!isset($page)) {
|
||||||
$page = 'dashboard';
|
$page = 'dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
// List of pages that don't require authentication
|
// List of pages that don't require authentication
|
||||||
$public_pages = ['login', 'help', 'about', 'theme-asset', 'plugin-asset'];
|
$public_pages = ['login', 'help', 'about'];
|
||||||
|
|
||||||
// Let plugins filter/extend public_pages
|
// Let plugins filter/extend public_pages
|
||||||
$public_pages = filter_public_pages($public_pages);
|
$public_pages = filter_public_pages($public_pages);
|
||||||
|
|
||||||
// Middleware pipeline for security, sanitization & CSRF
|
// Middleware pipeline for security, sanitization & CSRF
|
||||||
require_once APP_PATH . 'core/MiddlewarePipeline.php';
|
require_once __DIR__ . '/../app/core/MiddlewarePipeline.php';
|
||||||
$pipeline = new \App\Core\MiddlewarePipeline();
|
$pipeline = new \App\Core\MiddlewarePipeline();
|
||||||
$pipeline->add(function() {
|
$pipeline->add(function() {
|
||||||
// Apply security headers
|
// Apply security headers
|
||||||
require_once APP_PATH . 'includes/security_headers_middleware.php';
|
require_once __DIR__ . '/../app/includes/security_headers_middleware.php';
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always detect authenticated session so templates shared
|
// For public pages, we don't need to validate the session
|
||||||
// between public and private pages behave consistently.
|
// The Router will handle authentication for protected pages
|
||||||
$validSession = Session::isValidSession(true);
|
$validSession = false;
|
||||||
$userId = $validSession ? Session::getUserId() : null;
|
$userId = null;
|
||||||
|
|
||||||
|
// Only check session for non-public pages
|
||||||
|
if (!in_array($page, $public_pages)) {
|
||||||
|
$validSession = Session::isValidSession(true);
|
||||||
|
if ($validSession) {
|
||||||
|
$userId = Session::getUserId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize feedback message system
|
// Initialize feedback message system
|
||||||
require_once APP_PATH . 'classes/feedback.php';
|
require_once '../app/classes/feedback.php';
|
||||||
$system_messages = [];
|
$system_messages = [];
|
||||||
|
|
||||||
require APP_PATH . 'includes/errors.php';
|
require '../app/includes/errors.php';
|
||||||
|
|
||||||
// list of available pages
|
// list of available pages
|
||||||
// edit accordingly, add 'pages/PAGE.php'
|
// edit accordingly, add 'pages/PAGE.php'
|
||||||
|
|
@ -140,8 +148,9 @@ $allowed_urls = [
|
||||||
'conferences','participants','components',
|
'conferences','participants','components',
|
||||||
'graphs','latest','livejs','agents',
|
'graphs','latest','livejs','agents',
|
||||||
'profile','credentials','config','security',
|
'profile','credentials','config','security',
|
||||||
'settings','theme','theme-asset','plugin-asset',
|
'settings','theme','theme-asset',
|
||||||
'admin','status',
|
'admin-tools',
|
||||||
|
'status',
|
||||||
'help','about',
|
'help','about',
|
||||||
'login','logout',
|
'login','logout',
|
||||||
];
|
];
|
||||||
|
|
@ -150,30 +159,21 @@ $allowed_urls = [
|
||||||
$allowed_urls = filter_allowed_urls($allowed_urls);
|
$allowed_urls = filter_allowed_urls($allowed_urls);
|
||||||
|
|
||||||
// Dispatch routing and auth
|
// Dispatch routing and auth
|
||||||
require_once APP_PATH . 'core/Router.php';
|
require_once __DIR__ . '/../app/core/Router.php';
|
||||||
use App\Core\Router;
|
use App\Core\Router;
|
||||||
$currentUser = Router::checkAuth($config, $app_root, $public_pages, $page);
|
$currentUser = Router::checkAuth($config, $app_root, $public_pages, $page);
|
||||||
if ($currentUser === null && $validSession) {
|
|
||||||
$currentUser = Session::getUsername();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to DB via DatabaseConnector
|
// Connect to DB via DatabaseConnector
|
||||||
require_once APP_PATH . 'core/DatabaseConnector.php';
|
require_once __DIR__ . '/../app/core/DatabaseConnector.php';
|
||||||
use App\Core\DatabaseConnector;
|
use App\Core\DatabaseConnector;
|
||||||
$db = DatabaseConnector::connect($config);
|
$db = DatabaseConnector::connect($config);
|
||||||
|
|
||||||
// Initialize Log throttler
|
|
||||||
require_once APP_PATH . 'core/LogThrottler.php';
|
|
||||||
use App\Core\LogThrottler;
|
|
||||||
|
|
||||||
// Logging: default to NullLogger, plugin can override
|
// Logging: default to NullLogger, plugin can override
|
||||||
require_once APP_PATH . 'core/NullLogger.php';
|
require_once __DIR__ . '/../app/core/NullLogger.php';
|
||||||
use App\Core\NullLogger;
|
use App\Core\NullLogger;
|
||||||
$logObject = new NullLogger();
|
$logObject = new NullLogger();
|
||||||
|
|
||||||
require_once APP_PATH . 'helpers/logger_loader.php';
|
|
||||||
// Get the user IP
|
// Get the user IP
|
||||||
require_once APP_PATH . 'helpers/ip_helper.php';
|
require_once __DIR__ . '/../app/helpers/ip_helper.php';
|
||||||
$user_IP = '';
|
$user_IP = '';
|
||||||
|
|
||||||
// Plugin: initialize logging system plugin if available
|
// Plugin: initialize logging system plugin if available
|
||||||
|
|
@ -190,13 +190,13 @@ if (isset($GLOBALS['user_IP'])) {
|
||||||
// Check for pending DB migrations (non-intrusive: warn only)
|
// Check for pending DB migrations (non-intrusive: warn only)
|
||||||
// Only show for authenticated users and not on login page
|
// Only show for authenticated users and not on login page
|
||||||
try {
|
try {
|
||||||
$migrationsDir = APP_PATH . '../doc/database/migrations';
|
$migrationsDir = __DIR__ . '/../doc/database/migrations';
|
||||||
if (is_dir($migrationsDir) && $userId !== null && $page !== 'login') {
|
if (is_dir($migrationsDir) && $userId !== null && $page !== 'login') {
|
||||||
require_once APP_PATH . 'core/MigrationRunner.php';
|
require_once __DIR__ . '/../app/core/MigrationRunner.php';
|
||||||
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
|
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
|
||||||
if ($runner->hasPendingMigrations()) {
|
if ($runner->hasPendingMigrations()) {
|
||||||
$pending = $runner->listPendingMigrations();
|
$pending = $runner->listPendingMigrations();
|
||||||
$msg = 'Database schema is out of date. There are pending migrations. Run "<code>php scripts/migrate.php up</code>" or use the <a href="?page=admin">Admin center</a>';
|
$msg = 'Database schema is out of date. There are pending migrations. Run "<code>php scripts/migrate.php up</code>" or use the <a href="?page=admin-tools">Admin tools</a>';
|
||||||
// Check if migration message already exists to prevent duplicates
|
// Check if migration message already exists to prevent duplicates
|
||||||
$hasMigrationMessage = false;
|
$hasMigrationMessage = false;
|
||||||
if (isset($_SESSION['flash_messages'])) {
|
if (isset($_SESSION['flash_messages'])) {
|
||||||
|
|
@ -207,25 +207,23 @@ try {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Log (throttled) and show as a system message only if not already added
|
// Log and show as a system message only if not already added
|
||||||
if (!$hasMigrationMessage) {
|
if (!$hasMigrationMessage) {
|
||||||
LogThrottler::logThrottled($logObject, $db, 'migrations_pending', 86400, 'warning', $msg, ['scope' => 'system']);
|
$logObject->log('warning', $msg, ['scope' => 'system']);
|
||||||
Feedback::flash('SYSTEM', 'MIGRATIONS_PENDING', $msg, false, true, false);
|
Feedback::flash('SYSTEM', 'MIGRATIONS_PENDING', $msg, false, true, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Do not break the app; log only
|
// Do not break the app; log only
|
||||||
app_log('error', 'Migration check failed: ' . $e->getMessage(), [
|
error_log('Migration check failed: ' . $e->getMessage());
|
||||||
'scope' => 'system',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF middleware and run pipeline
|
// CSRF middleware and run pipeline
|
||||||
$pipeline->add(function() {
|
$pipeline->add(function() {
|
||||||
// Initialize security middleware
|
// Initialize security middleware
|
||||||
require_once APP_PATH . 'includes/csrf_middleware.php';
|
require_once __DIR__ . '/../app/includes/csrf_middleware.php';
|
||||||
require_once APP_PATH . 'helpers/security.php';
|
require_once __DIR__ . '/../app/helpers/security.php';
|
||||||
$security = SecurityHelper::getInstance();
|
$security = SecurityHelper::getInstance();
|
||||||
// Verify CSRF token for POST requests
|
// Verify CSRF token for POST requests
|
||||||
return applyCsrfMiddleware();
|
return applyCsrfMiddleware();
|
||||||
|
|
@ -233,14 +231,14 @@ $pipeline->add(function() {
|
||||||
$pipeline->add(function() {
|
$pipeline->add(function() {
|
||||||
// Init rate limiter
|
// Init rate limiter
|
||||||
global $db, $page, $userId;
|
global $db, $page, $userId;
|
||||||
require_once APP_PATH . 'includes/rate_limit_middleware.php';
|
require_once __DIR__ . '/../app/includes/rate_limit_middleware.php';
|
||||||
return checkRateLimit($db, $page, $userId);
|
return checkRateLimit($db, $page, $userId);
|
||||||
});
|
});
|
||||||
$pipeline->add(function() {
|
$pipeline->add(function() {
|
||||||
// Init user functions
|
// Init user functions
|
||||||
global $db, $userObject;
|
global $db, $userObject;
|
||||||
require_once APP_PATH . 'classes/user.php';
|
require_once __DIR__ . '/../app/classes/user.php';
|
||||||
include APP_PATH . 'helpers/profile.php';
|
include __DIR__ . '/../app/helpers/profile.php';
|
||||||
$userObject = new User($db);
|
$userObject = new User($db);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -250,7 +248,7 @@ if (!$pipeline->run()) {
|
||||||
|
|
||||||
// Maintenance mode: show maintenance page to non-superusers
|
// Maintenance mode: show maintenance page to non-superusers
|
||||||
try {
|
try {
|
||||||
require_once APP_PATH . 'core/Maintenance.php';
|
require_once __DIR__ . '/../app/core/Maintenance.php';
|
||||||
if (\App\Core\Maintenance::isEnabled()) {
|
if (\App\Core\Maintenance::isEnabled()) {
|
||||||
$isSuperuser = false;
|
$isSuperuser = false;
|
||||||
if ($validSession && isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
|
if ($validSession && isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
|
||||||
|
|
@ -264,7 +262,7 @@ try {
|
||||||
// Show themed maintenance page
|
// Show themed maintenance page
|
||||||
\App\Helpers\Theme::include('page-header');
|
\App\Helpers\Theme::include('page-header');
|
||||||
\App\Helpers\Theme::include('page-menu');
|
\App\Helpers\Theme::include('page-menu');
|
||||||
include APP_PATH . 'templates/maintenance.php';
|
include __DIR__ . '/../app/templates/maintenance.php';
|
||||||
\App\Helpers\Theme::include('page-footer');
|
\App\Helpers\Theme::include('page-footer');
|
||||||
ob_end_flush();
|
ob_end_flush();
|
||||||
exit;
|
exit;
|
||||||
|
|
@ -275,7 +273,7 @@ try {
|
||||||
if (!empty($maintMsg)) {
|
if (!empty($maintMsg)) {
|
||||||
$custom .= ' <em>' . htmlspecialchars($maintMsg) . '</em>';
|
$custom .= ' <em>' . htmlspecialchars($maintMsg) . '</em>';
|
||||||
}
|
}
|
||||||
$custom .= ' Control it in <a href="' . htmlspecialchars($app_root) . '?page=admin">Admin center</a>';
|
$custom .= ' Control it in <a href="' . htmlspecialchars($app_root) . '?page=admin-tools">Admin tools</a>';
|
||||||
// Non-dismissible and small, do not sanitize to allow link and <em>
|
// Non-dismissible and small, do not sanitize to allow link and <em>
|
||||||
Feedback::flash('SYSTEM', 'MAINTENANCE_ON', $custom, false, true, false);
|
Feedback::flash('SYSTEM', 'MAINTENANCE_ON', $custom, false, true, false);
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +295,7 @@ if ($validSession && isset($userId) && isset($userObject) && is_object($userObje
|
||||||
}
|
}
|
||||||
|
|
||||||
// get platforms details
|
// get platforms details
|
||||||
require APP_PATH . 'classes/platform.php';
|
require '../app/classes/platform.php';
|
||||||
$platformObject = new Platform($db);
|
$platformObject = new Platform($db);
|
||||||
$platformsAll = $platformObject->getPlatformDetails();
|
$platformsAll = $platformObject->getPlatformDetails();
|
||||||
|
|
||||||
|
|
@ -334,7 +332,7 @@ if ($page == 'logout') {
|
||||||
// Use theme helper to include templates
|
// Use theme helper to include templates
|
||||||
\App\Helpers\Theme::include('page-header');
|
\App\Helpers\Theme::include('page-header');
|
||||||
\App\Helpers\Theme::include('page-menu');
|
\App\Helpers\Theme::include('page-menu');
|
||||||
include APP_PATH . 'pages/login.php';
|
include '../app/pages/login.php';
|
||||||
\App\Helpers\Theme::include('page-footer');
|
\App\Helpers\Theme::include('page-footer');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -350,7 +348,7 @@ if ($page == 'logout') {
|
||||||
$userTimezone = (!empty($userDetails[0]['timezone'])) ? $userDetails[0]['timezone'] : 'UTC'; // Default to UTC if no timezone is set (or is missing)
|
$userTimezone = (!empty($userDetails[0]['timezone'])) ? $userDetails[0]['timezone'] : 'UTC'; // Default to UTC if no timezone is set (or is missing)
|
||||||
|
|
||||||
// check if the Jilo Server is running
|
// check if the Jilo Server is running
|
||||||
require APP_PATH . 'classes/server.php';
|
require '../app/classes/server.php';
|
||||||
$serverObject = new Server($db);
|
$serverObject = new Server($db);
|
||||||
|
|
||||||
$server_host = '127.0.0.1';
|
$server_host = '127.0.0.1';
|
||||||
|
|
@ -409,10 +407,10 @@ if ($page == 'logout') {
|
||||||
if ($validSession) {
|
if ($validSession) {
|
||||||
\App\Helpers\Theme::include('page-sidebar');
|
\App\Helpers\Theme::include('page-sidebar');
|
||||||
}
|
}
|
||||||
if (file_exists(APP_PATH . "pages/{$page}.php")) {
|
if (file_exists("../app/pages/{$page}.php")) {
|
||||||
include APP_PATH . "pages/{$page}.php";
|
include "../app/pages/{$page}.php";
|
||||||
} else {
|
} else {
|
||||||
include APP_PATH . 'templates/error-notfound.php';
|
include '../app/templates/error-notfound.php';
|
||||||
}
|
}
|
||||||
\App\Helpers\Theme::include('page-footer');
|
\App\Helpers\Theme::include('page-footer');
|
||||||
}
|
}
|
||||||
|
|
@ -423,7 +421,7 @@ if ($page == 'logout') {
|
||||||
if ($validSession) {
|
if ($validSession) {
|
||||||
\App\Helpers\Theme::include('page-sidebar');
|
\App\Helpers\Theme::include('page-sidebar');
|
||||||
}
|
}
|
||||||
include APP_PATH . 'templates/error-notfound.php';
|
include '../app/templates/error-notfound.php';
|
||||||
\App\Helpers\Theme::include('page-footer');
|
\App\Helpers\Theme::include('page-footer');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,13 +19,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
toggleButton.textContent = ">>";
|
toggleButton.textContent = ">>";
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
mainContent.classList.add('expanded');
|
mainContent.classList.add('expanded');
|
||||||
timeNow.style.opacity = '0';
|
timeNow.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
toggleButton.value = "<<";
|
toggleButton.value = "<<";
|
||||||
toggleButton.textContent = "<<";
|
toggleButton.textContent = "<<";
|
||||||
sidebar.classList.remove('collapsed');
|
sidebar.classList.remove('collapsed');
|
||||||
mainContent.classList.remove('expanded');
|
mainContent.classList.remove('expanded');
|
||||||
timeNow.style.opacity = '1';
|
timeNow.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,15 +37,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
sidebar.classList.toggle('collapsed');
|
sidebar.classList.toggle('collapsed');
|
||||||
document.documentElement.classList.toggle('sidebar-collapsed');
|
document.documentElement.classList.toggle('sidebar-collapsed');
|
||||||
mainContent.classList.toggle('expanded');
|
mainContent.classList.toggle('expanded');
|
||||||
// Toggle the value between ">>" and "<<" and fade time box
|
// Toggle the value between ">>" and "<<"
|
||||||
if (toggleButton.value === ">>") {
|
if (toggleButton.value === ">>") {
|
||||||
toggleButton.value = "<<";
|
toggleButton.value = "<<";
|
||||||
toggleButton.textContent = "<<";
|
toggleButton.textContent = "<<";
|
||||||
timeNow.style.opacity = '1';
|
timeNow.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
toggleButton.value = ">>";
|
toggleButton.value = ">>";
|
||||||
toggleButton.textContent = ">>";
|
toggleButton.textContent = ">>";
|
||||||
timeNow.style.opacity = '0';
|
timeNow.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update with the new state
|
// Update with the new state
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ function printUsage()
|
||||||
echo "Usage:\n";
|
echo "Usage:\n";
|
||||||
echo " php scripts/migrate.php status # Show pending and applied migrations\n";
|
echo " php scripts/migrate.php status # Show pending and applied migrations\n";
|
||||||
echo " php scripts/migrate.php up # Apply all pending migrations\n";
|
echo " php scripts/migrate.php up # Apply all pending migrations\n";
|
||||||
echo " php scripts/migrate.php next # Apply only the next pending migration\n";
|
|
||||||
echo "\n";
|
echo "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,21 +64,6 @@ try {
|
||||||
$applied = $runner->applyPendingMigrations();
|
$applied = $runner->applyPendingMigrations();
|
||||||
echo "\nApplied successfully: " . count($applied) . "\n";
|
echo "\nApplied successfully: " . count($applied) . "\n";
|
||||||
exit(0);
|
exit(0);
|
||||||
} elseif ($action === 'next') {
|
|
||||||
$pending = $runner->listPendingMigrations();
|
|
||||||
if (empty($pending)) {
|
|
||||||
echo "No pending migrations.\n";
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
$next = reset($pending);
|
|
||||||
echo "Applying next migration: {$next}\n";
|
|
||||||
$applied = $runner->applyNextMigration();
|
|
||||||
if (!empty($applied)) {
|
|
||||||
echo "Done.\n";
|
|
||||||
} else {
|
|
||||||
echo "Nothing applied.\n";
|
|
||||||
}
|
|
||||||
exit(0);
|
|
||||||
} else {
|
} else {
|
||||||
printUsage();
|
printUsage();
|
||||||
exit(1);
|
exit(1);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue