Compare commits

..

46 Commits
v0.4.1 ... HEAD

Author SHA1 Message Date
Yasen Pramatarov 0bb5fc2dc4 Removes admin-tools page 2025-12-24 09:21:15 +02:00
Yasen Pramatarov a1c585ed05 Updates CHANGELOD 2025-12-23 21:45:06 +02:00
Yasen Pramatarov 2a97539093 Adds plugin management section, part of the admin page 2025-12-23 18:39:07 +02:00
Yasen Pramatarov 1ca0515ee1 Updates CHANGELOG 2025-12-23 17:39:13 +02:00
Yasen Pramatarov 5d62380c8b Adds "admin" page for all admin tasks 2025-12-23 16:47:37 +02:00
Yasen Pramatarov b609aca2cc Loads email templates from "emails" views, available to plugins too. 2025-12-23 14:34:11 +02:00
Yasen Pramatarov 20cc575792 Updates index.php to use global APP_PATH var 2025-12-23 13:26:23 +02:00
Yasen Pramatarov 4639baeef9 Adds "admin" dashboard page 2025-12-21 11:05:39 +02:00
Yasen Pramatarov a272294fc0 Encodes correctly the login regirect URL parameters 2025-12-15 18:27:47 +02:00
Yasen Pramatarov b239b73689 Fixes public pages that are also authenticated pages. 2025-12-15 17:58:42 +02:00
Yasen Pramatarov 7031acd46d Replaces error_log with app_log in 2FA 2025-12-15 17:53:35 +02:00
Yasen Pramatarov c1d71fba77 Adds logger helper to index 2025-12-15 17:51:51 +02:00
Yasen Pramatarov 167bb2c075 Enhances logger helper with fallback if there is no log plugin 2025-12-15 17:36:30 +02:00
Yasen Pramatarov dbd0ab5f0e Adds password reset email template 2025-12-14 17:22:48 +02:00
Yasen Pramatarov cfefb8cc56 Adds email helper for sending emails 2025-12-14 17:21:48 +02:00
Yasen Pramatarov 31bc4d60e4 Switches profile edit to action-card design 2025-12-14 16:43:51 +02:00
Yasen Pramatarov 817782a766 Fixes profile avatar uploads 2025-12-14 16:43:26 +02:00
Yasen Pramatarov 8280f66b6d Changes credentials pages to action-card. Deletes unused credentials.php 2025-12-14 16:10:15 +02:00
Yasen Pramatarov 19521b432d Changes action pages to uniform action-card design 2025-12-14 15:40:30 +02:00
Yasen Pramatarov 2639a0f60a Fixes collapsing sidebar design 2025-12-14 15:15:29 +02:00
Yasen Pramatarov 9485cd0769 Adds a hook to load plugin assets 2025-11-28 18:44:39 +02:00
Yasen Pramatarov 06ddd768aa Adds plugin asset page 2025-11-28 18:39:48 +02:00
Yasen Pramatarov 25037008de Adds a plugin hook to the profile page 2025-11-27 21:27:51 +02:00
Yasen Pramatarov 9d93106d00 Adds a hook in account menu for plugins 2025-11-27 13:02:51 +02:00
Yasen Pramatarov 1252c421bc Redesigns profile page 2025-11-27 12:44:15 +02:00
Yasen Pramatarov 969875460f Redesigns themes page 2025-11-26 19:28:25 +02:00
Yasen Pramatarov 251cfa35f3 Fixes typo 2025-11-26 19:27:55 +02:00
Yasen Pramatarov 014ef05d05 Fix in register plugin 2025-11-25 12:25:42 +02:00
Yasen Pramatarov 8eae3cf124 Redesigns admin-tools page 2025-11-23 22:48:54 +02:00
Yasen Pramatarov 35def007ca Updates CSS and redesigns pagination 2025-11-23 22:47:20 +02:00
Yasen Pramatarov 82fb01384f Fixes for the db migration routine 2025-11-21 21:09:14 +02:00
Yasen Pramatarov 65a4dc7f18 Introduces Log Throtter to prevent log flooding 2025-11-21 20:58:19 +02:00
Yasen Pramatarov c38e5ef4a6 Refactoring the DB migration and Admin Tools functionality 2025-11-21 20:44:37 +02:00
Yasen Pramatarov 4b330dff6c Adds the option to run next DB migration(s) one by one 2025-11-21 11:03:58 +02:00
Yasen Pramatarov b94a3df731 Stores applied db migrations in the DB to keep track of 2025-11-21 11:00:56 +02:00
Yasen Pramatarov 785e9a84eb Monthly dashboard stats redesign 2025-11-20 12:45:18 +02:00
Yasen Pramatarov f853cf137b Reorganizes dashboard 2025-11-20 12:19:18 +02:00
Yasen Pramatarov bcbffb62aa Adds CSS for dashboard widgets 2025-11-20 12:09:41 +02:00
Yasen Pramatarov 1b01a0a0eb Redesigns credentials/2FA pages 2025-11-19 22:54:16 +02:00
Yasen Pramatarov 5422d63d83 Redesigns profile edit page 2025-11-19 22:28:15 +02:00
Yasen Pramatarov b90d8099c1 Redesigns password-forgot and password-reset pages 2025-11-19 21:54:51 +02:00
Yasen Pramatarov 85ea44c1e3 Redesigns register button 2025-11-19 21:43:30 +02:00
Yasen Pramatarov 67cc2a67e8 Redesigns login page 2025-11-19 19:54:16 +02:00
Yasen Pramatarov 9908f555b2 Fixes validator to accept "0" as valid value 2025-11-19 19:39:00 +02:00
Yasen Pramatarov 76385b78d5 Redesigns the sidebar 2025-11-19 19:32:17 +02:00
Yasen Pramatarov 0f5f7a03e0 Redesigns main elements, menu, and CSS 2025-11-19 19:10:23 +02:00
47 changed files with 7210 additions and 1360 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ jilo.db
jilo-web.db jilo-web.db
packaging/deb-package/ packaging/deb-package/
packaging/rpm-package/ packaging/rpm-package/
/public_html/uploads/avatars/

View File

@ -4,6 +4,49 @@ 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
@ -201,8 +244,6 @@ 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

View File

@ -67,28 +67,23 @@ class PasswordReset {
// Send email with reset link // Send email with reset link
$to = $user['email']; $to = $user['email'];
$subject = "{$config['site_name']} - Password reset request"; // Load email helper
$message = "Dear user,\n\n"; require_once __DIR__ . '/../helpers/email_helper.php';
$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']} ::";
}
}
$headers = [ $subject = "{$config['site_name']} - Password reset request";
'From' => "noreply@{$config['domain']}",
'Reply-To' => "noreply@{$config['domain']}", $variables = [
'X-Mailer' => 'PHP/' . phpversion() 'site_name' => $config['site_name'],
'reset_link' => $resetLink,
'site_slogan' => $config['site_slogan'] ?? ''
]; ];
if (!mail($to, $subject, $message, $headers)) { $additionalHeaders = [
'From' => "noreply@{$config['domain']}",
'Reply-To' => "noreply@{$config['domain']}"
];
if (!sendTemplateEmail($to, $subject, 'password_reset', $variables, $config, $additionalHeaders)) {
return ['success' => false, 'message' => 'Failed to send reset email']; return ['success' => false, 'message' => 'Failed to send reset email'];
} }

View File

@ -1,5 +1,9 @@
<?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
* *
@ -12,7 +16,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 = 'TotalMeet'; private $issuer = 'Jilo';
private $window = 1; // Time window of 1 step before/after private $window = 1; // Time window of 1 step before/after
/** /**
@ -98,7 +102,10 @@ 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)) {
error_log("Code verification failed"); app_log('warning', '2FA setup code verification failed', [
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
@ -117,7 +124,10 @@ class TwoFactorAuthentication {
if ($this->db->inTransaction()) { if ($this->db->inTransaction()) {
$this->db->rollBack(); $this->db->rollBack();
} }
error_log('2FA enable error: ' . $e->getMessage()); app_log('error', '2FA enable error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -157,7 +167,10 @@ class TwoFactorAuthentication {
return false; return false;
} catch (Exception $e) { } catch (Exception $e) {
error_log('2FA verification error: ' . $e->getMessage()); app_log('error', '2FA verification error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -351,7 +364,10 @@ class TwoFactorAuthentication {
return false; return false;
} catch (Exception $e) { } catch (Exception $e) {
error_log('Backup code verification error: ' . $e->getMessage()); app_log('error', 'Backup code verification error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -378,7 +394,10 @@ class TwoFactorAuthentication {
return $stmt->execute([$userId]); return $stmt->execute([$userId]);
} catch (Exception $e) { } catch (Exception $e) {
error_log('2FA disable error: ' . $e->getMessage()); app_log('error', '2FA disable error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -397,7 +416,10 @@ class TwoFactorAuthentication {
return $result && $result['enabled']; return $result && $result['enabled'];
} catch (Exception $e) { } catch (Exception $e) {
error_log('2FA status check error: ' . $e->getMessage()); app_log('error', '2FA status check error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -413,7 +435,10 @@ class TwoFactorAuthentication {
return $stmt->fetch(PDO::FETCH_ASSOC); return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (Exception $e) { } catch (Exception $e) {
error_log('Failed to get user 2FA settings: ' . $e->getMessage()); app_log('error', 'Failed to get user 2FA settings: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return null; return null;
} }
} }

View File

@ -473,6 +473,20 @@ 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 {
@ -486,24 +500,50 @@ 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. '; $_SESSION['error'] = 'Error moving the uploaded file. Please check directory permissions. ';
} }
} else { } else {
$_SESSION['error'] .= 'Invalid avatar file type. '; $_SESSION['error'] = 'Invalid avatar file type. Only JPG, PNG, and JPEG are allowed. ';
} }
} else { } else {
$_SESSION['error'] .= 'Error uploading the avatar file. '; // Handle different upload errors
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;
} }
/** /**

View File

@ -21,9 +21,25 @@ 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)) {
$this->addError($field, "Field is required"); $label = $this->formatFieldLabel($field);
$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':
@ -92,6 +108,10 @@ 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;
} }

View File

@ -0,0 +1,50 @@
<?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'));
}
}
}
}

View File

@ -0,0 +1,21 @@
<?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;
}
}

View File

@ -2,6 +2,9 @@
namespace App\Core; namespace App\Core;
require_once __DIR__ . '/NullLogger.php';
require_once __DIR__ . '/MigrationException.php';
use PDO; use PDO;
use Exception; use Exception;
@ -9,6 +12,10 @@ 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
@ -33,28 +40,77 @@ 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
{ {
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); if ($this->isSqlite) {
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');
@ -72,7 +128,8 @@ class MigrationRunner
{ {
$all = $this->listAllMigrations(); $all = $this->listAllMigrations();
$applied = $this->listAppliedMigrations(); $applied = $this->listAppliedMigrations();
return array_values(array_diff($all, $applied)); $pending = array_values(array_diff($all, $applied));
return $this->sortMigrations($pending);
} }
public function hasPendingMigrations(): bool public function hasPendingMigrations(): bool
@ -81,39 +138,219 @@ 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();
$appliedNow = [];
if (empty($pending)) { 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 = [];
if (empty($migrations)) {
return $appliedNow; return $appliedNow;
} }
$this->lastResults = [];
try { try {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
foreach ($pending as $migration) { foreach ($migrations 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}");
} }
// Split on ; at line ends, but allow inside procedures? Keep simple for our use-cases $trimmedSql = trim($sql);
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql))); $hash = hash('sha256', $trimmedSql);
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 === '') continue; if ($stmtSql === '') {
continue;
}
$this->pdo->exec($stmtSql); $this->pdo->exec($stmtSql);
} }
$ins = $this->pdo->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())'); $statementCount = count($statements);
$ins->execute([':m' => $migration]); $resultMessage = sprintf('Migration "%s" applied successfully (%d statement%s).', $migration, $statementCount, $statementCount === 1 ? '' : 's');
$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;
}
} }

View File

@ -4,34 +4,189 @@ 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
{ {
$enabled = []; self::$catalog = self::scanCatalog($pluginsDir);
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginPath) { self::$loaded = [];
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 (empty($meta['enabled'])) { if (!is_array($meta)) {
continue; $meta = [];
} }
$name = basename($pluginPath); $name = basename($pluginPath);
$enabled[$name] = [ $catalog[$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;
} }
} }

View File

@ -0,0 +1,93 @@
<?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;
}
}

View File

@ -13,3 +13,25 @@ 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);
}
}

View File

@ -1,81 +1,96 @@
<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 = '';
if (isset($_REQUEST['id'])) { // calls
$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['ip'])) { if (isset($_REQUEST['invitees'])) {
$param .= '&ip=' . htmlspecialchars($_REQUEST['ip']); $param .= '&invitees=' . htmlspecialchars($_REQUEST['invitees']);
} }
if (isset($_REQUEST['event'])) { if (isset($_REQUEST['description'])) {
$param .= '&event=' . htmlspecialchars($_REQUEST['event']); $param .= '&description=' . htmlspecialchars($_REQUEST['description']);
} }
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($from_time); $param .= '&from_time=' . htmlspecialchars($_REQUEST['from_time']);
} }
if (isset($_REQUEST['until_time'])) { if (isset($_REQUEST['until_time'])) {
$param .= '&until_time=' . htmlspecialchars($until_time); $param .= '&until_time=' . htmlspecialchars($_REQUEST['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 '<span><a href="' . htmlspecialchars($url) . '&p=1">first</a></span>'; echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=1' . $param . '">first</a>';
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page - 1) . $param . '">&laquo;</a>';
} else { } else {
echo '<span>first</span>'; echo '<span class="pagination-link disabled">first</span>';
echo '<span class="pagination-link disabled">&laquo;</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 the pages close to the current one // and pages around current page
if ( if ($i == 1 || $i == $page_count ||
$i === 1 || // first page $i % $step_pages == 0 ||
$i === $page_count || // last page abs($i - $browse_page) < $max_visible_pages / 2) {
$i === $browse_page || // current page
$i === $browse_page -1 ||
$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 {
echo '<span><<</span>';
}
echo '[' . htmlspecialchars($i) . ']';
if ($browse_page < $page_count) { if ($i == $browse_page) {
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) +1) . '">>></a></span>'; echo '<span class="pagination-link active">' . $i . '</span>';
} else { } else {
echo '<span>>></span>'; echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $i . $param . '">' . $i . '</a>';
} }
} else { } elseif ($i == 2 || $i == $page_count - 1 ||
// other pages ($i > $browse_page + $max_visible_pages / 2 && $i % $step_pages == 1) ||
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . htmlspecialchars($i) . '">[' . htmlspecialchars($i) . ']</a></span>'; ($i < $browse_page - $max_visible_pages / 2 && $i % $step_pages == $step_pages - 1)) {
} echo '<span class="pagination-link pagination-ellipsis disabled">...</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 '<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=' . ($browse_page + 1) . $param . '">&raquo;</a>';
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $page_count . $param . '">last</a>';
} else { } else {
echo '<span>last</span>'; echo '<span class="pagination-link disabled">&raquo;</span>';
echo '<span class="pagination-link disabled">last</span>';
} }
?>
</div> echo '</div></div>';
</div> }

View File

@ -284,6 +284,115 @@ 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
* *

View File

@ -1,209 +0,0 @@
<?php
/**
* Admin tools controller
*
* Allows superusers to:
* - Enable/disable maintenance mode
* - Run database migrations
*/
// Security and CSRF
require_once __DIR__ . '/../helpers/security.php';
$security = SecurityHelper::getInstance();
// Must be logged in
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Must be superuser
$canAdmin = false;
if (isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
$canAdmin = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser');
}
if (!$canAdmin) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
header('Location: ' . $app_root);
exit;
}
// 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';

461
app/pages/admin.php 100644
View File

@ -0,0 +1,461 @@
<?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' ? ('&section=' . urlencode($section)) : '';
return $app_root . '?page=admin' . $suffix;
};
$sectionUrls = [];
foreach (array_keys($sectionRegistry) as $sectionKey) {
$sectionUrls[$sectionKey] = $buildAdminUrl($sectionKey);
}
$requestedSection = strtolower(trim($_GET['section'] ?? 'overview'));
if (!isset($sectionRegistry[$requestedSection])) {
$requestedSection = array_key_first($sectionRegistry) ?? 'overview';
}
$activeSection = $requestedSection;
$adminTabs = [];
foreach ($sectionRegistry as $key => $meta) {
$adminTabs[$key] = [
'label' => $meta['label'],
'url' => $sectionUrls[$key],
'hook' => $meta['hook'],
'type' => $meta['type'],
'position' => $meta['position'],
];
}
$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';

View File

@ -23,8 +23,11 @@ $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");

View File

@ -92,7 +92,7 @@ if ($response['db'] === null) {
// display the widget // display the widget
include '../app/templates/widget-monthly.php'; include '../app/templates/dashboard-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/widget.php'; include '../app/templates/dashboard-conferences.php';
/** /**
@ -224,6 +224,6 @@ if ($response['db'] === null) {
} }
// display the widget // display the widget
include '../app/templates/widget.php'; include '../app/templates/dashboard-conferences.php';
} }

View File

@ -300,6 +300,6 @@ function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $app_ro
) { ) {
$redirect = $candidate; $redirect = $candidate;
} }
header('Location: ' . htmlspecialchars($redirect)); header('Location: ' . $redirect);
exit(); exit();
} }

View File

@ -0,0 +1,74 @@
<?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);

View File

@ -14,6 +14,20 @@
$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') {

View File

@ -51,11 +51,20 @@ 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 for the view // Prepare theme data with screenshot URLs and metadata 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' => $name, 'name' => $meta['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
]; ];

View File

@ -1,155 +0,0 @@
<?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; ?>

View File

@ -0,0 +1,586 @@
<?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&section=' . 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&section=' . 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>

View File

@ -4,80 +4,80 @@
*/ */
?> ?>
<div class="container mt-4"> <div class="action-card">
<div class="row justify-content-center"> <div class="action-card-header">
<div class="col-md-8"> <p class="action-eyebrow">Security</p>
<div class="card"> <h2 class="action-title">Set up two-factor authentication</h2>
<div class="card-header"> <p class="action-subtitle">Protect your account with an extra verification step whenever you sign in.</p>
<h3>Set up two-factor authentication</h3>
</div> </div>
<div class="card-body"> <div class="action-card-body">
<div class="alert alert-info"> <div class="alert alert-info">
<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> 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.
</div> </div>
<?php if (isset($error)): ?> <?php if (isset($error)): ?>
<div class="alert alert-danger"> <div class="alert alert-danger mb-4">
<?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="setup-steps"> <div class="tm-cred-steps">
<h4>1. Install an authenticator app</h4> <div class="tm-cred-step">
<p>If you haven't already, install an authenticator app on your mobile device:</p> <h3>1. Install an authenticator app</h3>
<ul> <p>Use any TOTP-compatible app such as Google Authenticator, Microsoft Authenticator, or Authy.</p>
<li>Google Authenticator</li> </div>
<li>Microsoft Authenticator</li>
<li>Authy</li>
</ul>
<h4 class="mt-4">2. Scan the QR code</h4> <div class="tm-cred-step">
<p>Open your authenticator app and scan this QR code:</p> <h3>2. Scan the QR code</h3>
<p>Open your authenticator app and scan the QR code below.</p>
<div class="text-center my-4"> <div class="tm-cred-qr">
<div id="qrcode"></div> <div id="qrcode"></div>
<div class="mt-2"> <div class="tm-cred-secret">
<small class="text-muted">Can't scan? Use this code instead:</small><br> <small>Can&apos;t scan? Enter this code manually:</small>
<code class="secret-key"><?php echo htmlspecialchars($setupData['secret']); ?></code> <code><?php echo htmlspecialchars($setupData['secret']); ?></code>
</div>
</div> </div>
</div> </div>
<h4 class="mt-4">3. Verify setup</h4> <div class="tm-cred-step">
<p>Enter the 6-digit code from your authenticator app to verify the setup:</p> <h3>3. Verify setup</h3>
<p>Enter the 6-digit code shown in your authenticator app.</p>
<form method="post" action="?page=credentials&item=2fa&action=setup" class="mt-3"> <form method="post" action="?page=credentials&item=2fa&action=setup" class="action-form" novalidate>
<div class="form-group"> <div class="action-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" class="form-control action-form-control"
pattern="[0-9]{6}" pattern="[0-9]{6}"
maxlength="6" maxlength="6"
required required
placeholder="Enter 6-digit code"> placeholder="000000">
</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']); ?>">
<button type="submit" class="btn btn-primary mt-3"> <div class="action-actions">
<button type="submit" class="btn btn-primary">
Verify and enable 2FA Verify and enable 2FA
</button> </button>
</div>
</form> </form>
</div>
<div class="mt-4"> <div class="tm-cred-step">
<h4>Backup codes</h4> <h3>Backup codes</h3>
<p class="text-warning"> <p class="text-danger mb-3">
<strong>Important:</strong> Save these backup codes in a secure place. Save these codes somewhere secure. Each code can be used once if you lose access to your authenticator app.
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="backup-codes bg-light p-3 rounded"> <div class="tm-cred-backup">
<?php foreach ($setupData['backupCodes'] as $code): ?> <?php foreach ($setupData['backupCodes'] as $code): ?>
<code class="d-block"><?php echo htmlspecialchars($code); ?></code> <code><?php echo htmlspecialchars($code); ?></code>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button class="btn btn-secondary mt-2" onclick="window.print()"> <button class="btn btn-outline-secondary mt-3" onclick="window.print()">
Print backup codes Print backup codes
</button> </button>
</div> </div>
@ -86,12 +86,11 @@
<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)): ?>

View File

@ -4,27 +4,26 @@
*/ */
?> ?>
<div class="container mt-4"> <div class="action-card">
<div class="row justify-content-center"> <div class="action-card-header">
<div class="col-md-6"> <p class="action-eyebrow">Security check</p>
<div class="card"> <h2 class="action-title">Two-factor authentication</h2>
<div class="card-header"> <p class="action-subtitle">Enter the 6-digit code from your authenticator app to continue.</p>
<h3>Two-factor authentication</h3>
</div> </div>
<div class="card-body"> <div class="action-card-body">
<?php if (isset($error)): ?> <?php if (isset($error)): ?>
<div class="alert alert-danger"> <div class="alert alert-danger mb-4">
<?php echo htmlspecialchars($error); ?> <?php echo htmlspecialchars($error); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<p>Enter the 6-digit code from your authenticator app:</p> <form method="post" action="?page=login&action=verify" class="action-form" novalidate>
<div class="action-form-group">
<form method="post" action="?page=login&action=verify" class="mt-3"> <label for="code" class="action-form-label">One-time code</label>
<div class="form-group">
<input type="text" <input type="text"
id="code"
name="code" name="code"
class="form-control form-control-lg text-center" class="form-control action-form-control text-center"
pattern="[0-9]{6}" pattern="[0-9]{6}"
maxlength="6" maxlength="6"
inputmode="numeric" inputmode="numeric"
@ -36,44 +35,44 @@
<input type="hidden" name="user_id" value="<?php echo htmlspecialchars($userId); ?>"> <input type="hidden" name="user_id" value="<?php echo htmlspecialchars($userId); ?>">
<button type="submit" class="btn btn-primary btn-block mt-4"> <div class="action-actions">
<button type="submit" class="btn btn-primary">
Verify code Verify code
</button> </button>
</div>
</form> </form>
<div class="mt-4"> <div class="mt-4 text-center">
<p class="text-muted text-center"> <p class="text-muted mb-2">Lost access to your authenticator app?</p>
Lost access to your authenticator app?<br> <button class="btn btn-link p-0" type="button" data-toggle="collapse" data-target="#backupCodeForm">
<a href="#" data-toggle="collapse" data-target="#backupCodeForm">
Use a backup code Use a backup code
</a> </button>
</p> </div>
<div class="collapse mt-3" id="backupCodeForm"> <div class="collapse mt-3" id="backupCodeForm">
<form method="post" action="?page=login&action=verify" class="mt-3"> <form method="post" action="?page=login&action=verify" class="action-form" novalidate>
<div class="form-group"> <div class="action-form-group">
<label>Enter backup code:</label> <label for="backup_code" class="action-form-label">Backup code</label>
<input type="text" <input type="text"
id="backup_code"
name="backup_code" name="backup_code"
class="form-control" class="form-control action-form-control"
pattern="[a-f0-9]{8}" pattern="[a-f0-9]{8}"
maxlength="8" maxlength="8"
required required
placeholder="Enter backup code"> placeholder="Enter 8-character 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); ?>">
<button type="submit" class="btn btn-secondary btn-block"> <div class="action-actions">
<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>

View File

@ -5,87 +5,86 @@
*/ */
?> ?>
<div class="container mt-4"> <div class="action-card">
<div class="row justify-content-center"> <div class="action-card-header">
<div class="col-md-8"> <p class="action-eyebrow">Security</p>
<!-- Password Management --> <h2 class="action-title">Manage credentials</h2>
<div class="card mb-4"> <p class="action-subtitle">Update your password and keep two-factor authentication status in one place.</p>
<div class="card-header">
<h3>change password</h3>
</div> </div>
<div class="card-body"> <div class="action-card-body">
<form method="post" action="?page=credentials&item=password"> <div class="tm-cred-grid">
<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="form-group"> <div class="action-form-group">
<label for="current_password">current password</label> <label for="current_password" class="action-form-label">Current password</label>
<input type="password" <input type="password" class="form-control action-form-control" id="current_password" name="current_password" required>
class="form-control"
id="current_password"
name="current_password"
required>
</div> </div>
<div class="form-group mt-3"> <div class="action-form-group">
<label for="new_password">new password</label> <label for="new_password" class="action-form-label">New password</label>
<input type="password" <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>
class="form-control" <small class="form-text text-muted">Minimum 8 characters</small>
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="form-group mt-3"> <div class="action-form-group">
<label for="confirm_password">confirm new password</label> <label for="confirm_password" class="action-form-label">Confirm new password</label>
<input type="password" <input type="password" class="form-control action-form-control" id="confirm_password" name="confirm_password" pattern=".{8,}" required>
class="form-control"
id="confirm_password"
name="confirm_password"
pattern=".{8,}"
required>
</div> </div>
<div class="mt-4"> <div class="action-actions">
<button type="submit" class="btn btn-primary">change password</button> <button type="submit" class="btn btn-primary">Save new password</button>
</div> </div>
</form> </form>
</div> </section>
</div>
<!-- 2FA Management --> <section class="tm-cred-panel">
<div class="card"> <div class="tm-cred-panel-head">
<div class="card-header"> <div>
<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"> <div class="alert alert-success d-flex align-items-center gap-2">
<i class="fas fa-check-circle"></i> two-factor authentication is enabled <i class="fas fa-shield-check"></i>
<span>Two-factor authentication is currently enabled.</span>
</div> </div>
<form method="post" action="?page=credentials&item=2fa&action=disable"> <form method="post" action="?page=credentials&item=2fa&action=disable" class="action-form">
<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']); ?>">
<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.')"> <div class="action-actions">
disable two-factor authentication <button type="submit" class="btn btn-outline-danger" onclick="return confirm('Disable two-factor authentication? This will make your account less secure.')">
Disable 2FA
</button> </button>
</div>
</form> </form>
<?php else: ?> <?php else: ?>
<div class="alert alert-warning"> <div class="alert alert-warning d-flex align-items-center gap-2">
<i class="fas fa-exclamation-triangle"></i> two-factor authentication is not enabled <i class="fas fa-lock"></i>
<span>Two-factor authentication is not enabled yet.</span>
</div> </div>
<form method="post" action="?page=credentials&item=2fa&action=setup"> <form method="post" action="?page=credentials&item=2fa&action=setup" class="action-form">
<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']); ?>">
<button type="submit" class="btn btn-primary"> <div class="action-actions">
set up two-factor authentication <button type="submit" class="btn btn-outline-primary">
Set up 2FA
</button> </button>
</div>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> </section>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -97,4 +96,5 @@ document.getElementById('confirm_password').addEventListener('input', function()
} else { } else {
this.setCustomValidity(''); this.setCustomValidity('');
} }
});</script> });
</script>

View File

@ -1,51 +0,0 @@
<!-- 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>

View File

@ -0,0 +1,65 @@
<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>

View File

@ -0,0 +1,17 @@
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}}

View File

@ -1,34 +1,54 @@
<!-- login form --> <!-- login form -->
<div class="card text-center w-50 mx-auto"> <div class="action-card">
<h2 class="card-header">Login</h2> <div class="action-card-header">
<div class="card-body"> <p class="action-eyebrow">Welcome back</p>
<p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p> <h2 class="action-title">Sign in</h2>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login"> <p class="action-subtitle">Enter your credentials to continue to <?= htmlspecialchars($config['site_name']); ?></p>
</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="form-group mb-3"> <div class="action-form-group">
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username" <label for="username" class="action-form-label">Username</label>
<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 class="form-group mb-3"> </div>
<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'])): ?>
<input type="hidden" name="redirect" value="<?php echo htmlspecialchars($_GET['redirect']); ?>"> <div class="d-flex justify-content-between align-items-center mb-4">
<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 -->

View File

@ -1,32 +1,39 @@
<div class="container"> <div class="action-card">
<div class="row justify-content-center"> <div class="action-card-header">
<div class="col-md-6"> <p class="action-eyebrow">Account recovery</p>
<div class="card mt-5"> <h2 class="action-title">Forgot password</h2>
<div class="card-body"> <p class="action-subtitle">Enter your email address and we'll send you reset instructions if it exists in our records</p>
<h3 class="card-title mb-4">Reset password</h3> </div>
<p>Enter your email address and we will send you<br />
instructions to reset your password.</p> <div class="action-card-body">
<form method="post" action="?page=login&action=forgot"> <form method="post" action="?page=login&action=forgot" class="action-form" novalidate>
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include CSRF_TOKEN_INCLUDE; ?>
<div class="form-group"> <div class="action-form-group">
<label for="email">email address:</label> <label for="email" class="action-form-label">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" class="form-control action-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>
<button type="submit" class="btn btn-primary btn-block mt-4"> </div>
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">
<a href="?page=login">back to login</a> <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> </div>
</div>

View File

@ -1,38 +1,53 @@
<div class="container"> <div class="action-card">
<div class="row justify-content-center"> <div class="action-card-header">
<div class="col-md-6"> <p class="action-eyebrow">Account recovery</p>
<div class="card mt-5"> <h2 class="action-title">Reset password</h2>
<div class="card-body"> <p class="action-subtitle">Create a new password that is at least 8 characters long</p>
<h3 class="card-title mb-4">Set new password</h3> </div>
<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="form-group"> <div class="action-form-group">
<label for="new_password">new password:</label> <label for="new_password" class="action-form-label">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" class="form-control action-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 class="form-group mt-3"> </div>
<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" class="form-control action-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>
<button type="submit" class="btn btn-primary btn-block mt-4"> </div>
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> <div class="mt-4 text-center">
<a href="?page=login" class="text-decoration-none"> Back to login</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -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-tools') { <?php if ($page === 'admin') {
// 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,12 +53,16 @@
}); });
</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 mt-2"></div> <div id="messages-container" class="container-fluid"></div>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col"> <div class="col">

View File

@ -1,82 +1,83 @@
<div class="container-fluid"> <div class="container-fluid p-0">
<!-- Menu --> <!-- Modern Menu -->
<div class="menu-container"> <div class="menu-container">
<ul class="menu-left"> <div class="modern-header-content">
<div class="container"> <div class="logo-section">
<div class="row"> <a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="modern-logo-link">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="logo-link"> <div class="modern-logo">
<div class="col-4"> <img src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="<?= htmlspecialchars($config['site_name']); ?>"/>
<img class="logo" src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="JILO"/> </div>
<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&nbsp;<?= 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']);
?> ?>
<li style="margin-right: 3px;"> <div>
<?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) { ?>
<span style="background-color: #fff; border: 1px solid #111; color: #111; border-bottom-color: #fff; padding-bottom: 12px;"> Jitsi platforms:
<button class="btn modern-header-btn" type="button" aria-expanded="false">
<?= htmlspecialchars($platform['name']) ?> <?= htmlspecialchars($platform['name']) ?>
</span> </button>
<?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 } ?>
</li> </div>
<?php } ?> <?php } ?>
<?php } ?> <?php } ?>
</ul>
<ul class="menu-right"> <div class="header-actions">
<?php if (Session::isValidSession()) { ?> <?php if (Session::isValidSession()) { ?>
<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-user"></i> <i class="fas fa-user-circle me-2"></i><?= htmlspecialchars($currentUser) ?>
</a> </button>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6> <h6 class="dropdown-header modern-dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme"> <a class="dropdown-item modern-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" href="<?= htmlspecialchars($app_root) ?>?page=profile"> <a class="dropdown-item modern-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" href="<?= htmlspecialchars($app_root) ?>?page=credentials"> <a class="dropdown-item modern-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" href="<?= htmlspecialchars($app_root) ?>?page=logout"> <a class="dropdown-item modern-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>
</li> </div>
<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-cog"></i> <i class="fas fa-cog"></i>
</a> </button>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header">system</h6> <h6 class="dropdown-header modern-dropdown-header">settings</h6>
<?php if ($userObject->hasRight($userId, 'superuser')) {?> <?php if ($userObject->hasRight($userId, 'superuser')) {?>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin-tools"> <a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin">
<i class="fas fa-toolbox"></i>Admin tools <i class="fas fa-toolbox"></i>Admin
</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" href="<?= htmlspecialchars($app_root) ?>?page=config"> <a class="dropdown-item modern-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 } ?>
@ -86,31 +87,32 @@
$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" href="<?= htmlspecialchars($app_root) ?>?page=security"> <a class="dropdown-item modern-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 } ?>
<?php if ($userObject->hasRight($userId, 'view app logs')) {?>
<?php do_hook('main_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?> <?php do_hook('main_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
<?php } ?>
</div> </div>
</li> </div>
<?php } ?>
<?php } else { ?> <?php } else { ?>
<li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li> <button class="btn modern-header-btn" onclick="window.location.href='<?= htmlspecialchars($app_root) ?>?page=login'">
<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">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false"> <div class="dropdown">
<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>
</a> </button>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header">resources</h6> <h6 class="dropdown-header modern-dropdown-header">resources</h6>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=help"> <a class="dropdown-item modern-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>
<!-- /Menu --> </div>
</div>
</div>
<!-- /Modern Menu -->

View File

@ -1,16 +1,16 @@
<div class="row"> <div class="row" style="padding-right: 0.75rem;">
<!-- Sidebar --> <!-- Sidebar -->
<div class="col-md-3 mb-5 sidebar-wrapper bg-light" id="sidebar"> <div class="col-md-3 sidebar-wrapper" id="sidebar">
<div class="text-center" style="border: 1px solid #0dcaf0; height: 22px;" id="time_now"> <div class="text-center" id="time_now">
<?php <?php
$timeNow = new DateTime('now', new DateTimeZone($userTimezone)); $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
?> ?>
<span style="vertical-align: top; font-size: 12px;"><?= htmlspecialchars($timeNow->format('H:i')) ?>&nbsp;&nbsp;<?= htmlspecialchars($userTimezone) ?></span> <span><?= htmlspecialchars($timeNow->format('H:i')) ?>&nbsp;&nbsp;<?= 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 ml-3 mt-3"> <div class="sidebar-content card mt-0">
<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 bg-light" style="border: none;"><p class="text-end mb-0"><small>logs statistics</small></p></li> <li class="list-group-item sidebar-section-title-first">logs statistics</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 bg-light" style="border: none;"><p class="text-end mb-0"><small>live data</small></p></li> <li class="list-group-item sidebar-section-title">live data</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 bg-light" style="border: none;"><p class="text-end mb-0"><small>jitsi platforms settings</small></p></li> <li class="list-group-item sidebar-section-title">jitsi platforms settings</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'; ?>">

View File

@ -1,70 +1,60 @@
<!-- user profile -->
<!-- user profile --> <div class="action-card">
<div class="card text-center w-50 mx-auto"> <div class="action-card-header">
<p class="action-eyebrow">Account</p>
<p class="h4 card-header">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></p> <h2 class="action-title">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></h2>
<div class="card-body"> <p class="action-subtitle">Update your personal details, avatar, and access rights in one streamlined view.</p>
</div>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile" enctype="multipart/form-data"> <div class="action-card-body">
<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"> <div class="row g-4 align-items-start">
<p class="border rounded bg-light mb-4"><small>edit the profile fields</small></p> <div class="col-lg-4">
<div class="col-md-4 avatar-container"> <div class="tm-profile-avatar card h-100">
<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 class="avatar-btn-container"> </div>
<div class="avatar-btn-group">
<label for="avatar-upload" class="avatar-btn avatar-btn-select btn btn-primary"> <label for="avatar-upload" class="btn btn-outline-primary w-100">
<i class="fas fa-folder" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="select new avatar"></i> <i class="fas fa-upload me-2"></i>Upload new
</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="avatar-btn avatar-btn-remove btn btn-secondary" data-toggle="modal" data-target="#confirmDeleteModal" disabled> <button type="button" class="btn btn-outline-secondary w-100" data-toggle="modal" data-target="#confirmDeleteModal" disabled>
<?php } else { ?> <?php } else { ?>
<button type="button" class="avatar-btn avatar-btn-remove btn btn-danger" data-toggle="modal" data-target="#confirmDeleteModal"> <button type="button" class="btn btn-outline-danger w-100" data-toggle="modal" data-target="#confirmDeleteModal">
<?php } ?> <?php } ?>
<i class="fas fa-trash" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="remove current avatar"></i> <i class="fas fa-trash me-2"></i>Remove avatar
</button> </button>
</div> </div>
<p class="avatar-hint">PNG, JPG up to 500 KB.</p>
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="col-lg-8">
<!--div class="row mb-3"> <div class="tm-profile-section">
<div class="col-md-4 text-end"> <h3 class="tm-profile-section-title">Personal info</h3>
<label for="username" class="form-label"><small>username:</small></label> <div class="row g-3">
<span class="text-danger" style="margin-right: -12px;">*</span> <div class="col-md-6">
<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--> <div class="col-md-6">
<div class="action-form-group">
<div class="row mb-3"> <label for="email" class="action-form-label">Email address</label>
<div class="col-md-4 text-end"> <input class="form-control action-form-control" type="text" name="email" id="email" value="<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>" />
<label for="name" class="form-label"><small>name:</small></label> </div>
</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="row mb-3"> <div class="tm-profile-section">
<div class="col-md-4 text-end"> <h3 class="tm-profile-section-title">Timezone</h3>
<label for="email" class="form-label"><small>email:</small></label> <div class="action-form-group">
</div> <label for="timezone" class="action-form-label">Preferred timezone</label>
<div class="col-md-8 text-start bg-light"> <select class="form-control action-form-control" name="timezone" id="timezone">
<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) ?>&nbsp;&nbsp;(<?= htmlspecialchars(getUTCOffset($timezone)) ?>) <?= htmlspecialchars($timezone) ?>&nbsp;&nbsp;(<?= htmlspecialchars(getUTCOffset($timezone)) ?>)
@ -74,22 +64,18 @@
</div> </div>
</div> </div>
<div class="row mb-3"> <div class="tm-profile-section">
<div class="col-md-4 text-end"> <h3 class="tm-profile-section-title">Bio</h3>
<label for="bio" class="form-label"><small>bio:</small></label> <div class="action-form-group">
</div> <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 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="row mb-3"> <div class="tm-profile-section">
<div class="col-md-4 text-end"> <h3 class="tm-profile-section-title">Rights</h3>
<label for="rights" class="form-label"><small>rights:</small></label> <p class="tm-profile-section-helper">Toggle the permissions that should be associated with this user.</p>
</div> <div class="tm-rights-grid">
<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']) {
@ -97,7 +83,7 @@
break; break;
} }
} ?> } ?>
<div class="form-check"> <div class="form-check tm-right-item">
<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>
@ -105,13 +91,11 @@
</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>
@ -123,12 +107,9 @@
<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"> <div class="modal-body text-center">
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" /> <img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
<br /> <p class="mt-3 mb-0">Are you sure you want to delete your avatar?<br />This action cannot be undone.</p>
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>
@ -140,23 +121,23 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- /user profile --> <!-- /user profile -->
<script> <script>
// Preview the uploaded avatar document.addEventListener('DOMContentLoaded', function() {
document.getElementById('avatar-upload').addEventListener('change', function(event) { // Preview the uploaded avatar
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];
@ -169,12 +150,13 @@ document.getElementById('avatar-upload').addEventListener('change', 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

View File

@ -1,87 +1,126 @@
<!-- user profile --> <?php
<div class="card text-center w-50 mx-auto"> $user = $userDetails[0] ?? [];
$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">
<div class="row"> <section class="tm-directory tm-profile-view">
<div class="tm-hero-card tm-hero-card--stacked tm-profile-hero">
<div class="col-md-4 avatar-container"> <div class="tm-profile-hero-main">
<div> <div class="tm-profile-avatar-frame">
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" /> <img src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="Avatar of <?= htmlspecialchars($displayName) ?>" />
</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&amp;action=edit">
<i class="fas fa-edit"></i> Edit profile
</a>
</div>
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="tm-profile-panels">
<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>
<!--div class="row mb-3"> <article class="tm-profile-panel">
<div class="col-md-4 text-end"> <header>
<label class="form-label"><small>username:</small></label> <h3>Bio</h3>
</div> </header>
<div class="col-md-8 text-start bg-light"> <?php if ($bio !== ''): ?>
<?= htmlspecialchars($userDetails[0]['username']) ?> <p class="tm-profile-bio"><?= nl2br(htmlspecialchars($bio)) ?></p>
</div> <?php else: ?>
</div--> <p class="tm-profile-placeholder">This user hasnt added a bio yet.</p>
<?php endif; ?>
</article>
<div class="row mb-3"> <article class="tm-profile-panel">
<div class="col-md-4 text-end"> <header>
<label class="form-label"><small>name:</small></label> <h3>User rights</h3>
</div> </header>
<div class="col-md-8 text-start bg-light"> <?php if ($rightsCount): ?>
<?= htmlspecialchars($userDetails[0]['name'] ?? '') ?> <ul class="tm-profile-rights">
</div> <?php foreach ($rightsNames as $rightLabel): ?>
</div> <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>
<div class="row mb-3"> <?php do_hook('profile.additional_panels', [
<div class="col-md-4 text-end"> 'subscription' => $subscription ?? null,
<label class="form-label"><small>email:</small></label> 'app_root' => $app_root,
'userId' => $user['id'] ?? null,
]); ?>
</div> </div>
<div class="col-md-8 text-start bg-light"> </section>
<?= 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']) ?>&nbsp;&nbsp;<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 -->

View File

@ -10,35 +10,124 @@
* - isActive: Whether this is the current theme * - isActive: Whether this is the current theme
*/ */
?> ?>
<div class="container mt-4"> <?php
<h2>Theme switcher</h2> $activeThemeName = 'Default';
<p class="text-muted">Select a theme to change the appearance of the application.</p> foreach ($themes as $themeData) {
<div class="row mt-4"> if (!empty($themeData['isActive'])) {
<?php foreach ($themes as $themeId => $theme): ?> $activeThemeName = $themeData['name'];
<div class="col-md-4 mb-4"> break;
<div class="card h-100 <?= $theme['isActive'] ? 'border-primary' : '' ?>"> }
<!-- Theme screenshot --> }
<div class="theme-screenshot" style="height: 150px; background-size: cover; background-position: center; background-color: #f8f9fa; <?= $theme['screenshotUrl'] ? 'background-image: url(' . htmlspecialchars($theme['screenshotUrl']) . ')' : '' ?>"> $totalThemes = count($themes);
<?php if (!$theme['screenshotUrl']): ?> ?>
<div class="h-100 d-flex align-items-center justify-content-center text-muted">No preview available</div>
<?php endif; ?> <section class="tm-directory tm-theme-directory">
<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>
<?php if ($theme['isActive']): ?> <div class="tm-hero-meta">
<div class="card-header bg-primary text-white">Current theme</div> <span class="tm-hero-pill pill-neutral">
<?php endif; ?> <i class="fas fa-layer-group"></i>
<div class="card-body d-flex flex-column"> <?= $totalThemes ?> available
<h5 class="card-title"><?= htmlspecialchars($theme['name']) ?></h5> </span>
<p class="card-text text-muted">Theme ID: <code><?= htmlspecialchars($themeId) ?></code></p> <span class="tm-hero-pill pill-primary">
<div class="mt-auto"> <i class="fas fa-check-circle"></i>
<?php if (!$theme['isActive']): ?> Active: <?= htmlspecialchars($activeThemeName) ?>
<a href="?page=theme&switch_to=<?= urlencode($themeId) ?>&csrf_token=<?= $csrf_token ?>" class="btn btn-primary">Switch to this theme</a> </span>
<?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&amp;switch_to=<?= urlencode($themeId) ?>&amp;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>

View File

@ -1,64 +0,0 @@
<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']) ?>" -->

View File

@ -20,5 +20,8 @@ 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;
echo '<li><a href="?page=register">register</a></li>'; $appRoot = isset($ctx['app_root']) ? htmlspecialchars($ctx['app_root'], ENT_QUOTES, 'UTF-8') : '';
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>";
}); });

View File

@ -1,37 +1,52 @@
<!-- registration form --> <!-- registration form -->
<div class="card text-center w-50 mx-auto"> <div class="action-card">
<h2 class="card-header">Register</h2> <div class="action-card-header">
<div class="card-body"> <p class="action-eyebrow">Create account</p>
<p class="card-text">Enter credentials for registration:</p> <h2 class="action-title">Register</h2>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=register"> <p class="action-subtitle">Enter your credentials to create a new account</p>
</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="form-group mb-3"> <div class="action-form-group">
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username" <label for="username" class="action-form-label">Username</label>
<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">
<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>
<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">
<input type="password" class="form-control w-50 mx-auto" name="confirm_password" placeholder="Confirm password" <div class="action-form-group">
<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">
<label class="form-check-label" for="terms">
<input type="checkbox" class="form-check-input" id="terms" name="terms" required> <input type="checkbox" class="form-check-input" id="terms" name="terms" required>
<label class="form-check-label" for="terms">
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"> <small class="text-muted mt-2 d-block">
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>

View File

@ -16,13 +16,16 @@
//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 __DIR__ . '/../app/core/ConfigLoader.php'; require_once APP_PATH . 'core/ConfigLoader.php';
use App\Core\ConfigLoader; use App\Core\ConfigLoader;
// Load configuration // Load configuration
$config = ConfigLoader::loadConfig([ $config = ConfigLoader::loadConfig([
__DIR__ . '/../app/config/jilo-web.conf.php', APP_PATH . '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',
@ -40,8 +43,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 __DIR__ . '/../app/core/HookDispatcher.php'; require_once APP_PATH . 'core/HookDispatcher.php';
require_once __DIR__ . '/../app/core/PluginManager.php'; require_once APP_PATH . 'core/PluginManager.php';
use App\Core\HookDispatcher; use App\Core\HookDispatcher;
use App\Core\PluginManager; use App\Core\PluginManager;
@ -78,68 +81,57 @@ $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', dirname(__DIR__) . '/app/includes/csrf_token.php'); define('CSRF_TOKEN_INCLUDE', APP_PATH . 'includes/csrf_token.php');
} }
// Global cnstants // Global cnstants
require_once '../app/includes/constants.php'; require_once APP_PATH . '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/classes/session.php'; require_once APP_PATH . 'classes/session.php';
// Initialize themes system after session is started // Initialize themes system after session is started
require_once __DIR__ . '/../app/helpers/theme.php'; require_once APP_PATH . '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 __DIR__ . '/../app/includes/sanitize.php'; require_once APP_PATH . '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']; $public_pages = ['login', 'help', 'about', 'theme-asset', 'plugin-asset'];
// 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 __DIR__ . '/../app/core/MiddlewarePipeline.php'; require_once APP_PATH . '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 __DIR__ . '/../app/includes/security_headers_middleware.php'; require_once APP_PATH . 'includes/security_headers_middleware.php';
return true; return true;
}); });
// For public pages, we don't need to validate the session // Always detect authenticated session so templates shared
// The Router will handle authentication for protected pages // between public and private pages behave consistently.
$validSession = false; $validSession = Session::isValidSession(true);
$userId = null; $userId = $validSession ? Session::getUserId() : 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/classes/feedback.php'; require_once APP_PATH . 'classes/feedback.php';
$system_messages = []; $system_messages = [];
require '../app/includes/errors.php'; require APP_PATH . 'includes/errors.php';
// list of available pages // list of available pages
// edit accordingly, add 'pages/PAGE.php' // edit accordingly, add 'pages/PAGE.php'
@ -148,9 +140,8 @@ $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', 'settings','theme','theme-asset','plugin-asset',
'admin-tools', 'admin','status',
'status',
'help','about', 'help','about',
'login','logout', 'login','logout',
]; ];
@ -159,21 +150,30 @@ $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 __DIR__ . '/../app/core/Router.php'; require_once APP_PATH . '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 __DIR__ . '/../app/core/DatabaseConnector.php'; require_once APP_PATH . '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 __DIR__ . '/../app/core/NullLogger.php'; require_once APP_PATH . '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 __DIR__ . '/../app/helpers/ip_helper.php'; require_once APP_PATH . '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 = __DIR__ . '/../doc/database/migrations'; $migrationsDir = APP_PATH . '../doc/database/migrations';
if (is_dir($migrationsDir) && $userId !== null && $page !== 'login') { if (is_dir($migrationsDir) && $userId !== null && $page !== 'login') {
require_once __DIR__ . '/../app/core/MigrationRunner.php'; require_once APP_PATH . '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-tools">Admin tools</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">Admin center</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,23 +207,25 @@ try {
} }
} }
} }
// Log and show as a system message only if not already added // Log (throttled) and show as a system message only if not already added
if (!$hasMigrationMessage) { if (!$hasMigrationMessage) {
$logObject->log('warning', $msg, ['scope' => 'system']); LogThrottler::logThrottled($logObject, $db, 'migrations_pending', 86400, '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
error_log('Migration check failed: ' . $e->getMessage()); app_log('error', '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 __DIR__ . '/../app/includes/csrf_middleware.php'; require_once APP_PATH . 'includes/csrf_middleware.php';
require_once __DIR__ . '/../app/helpers/security.php'; require_once APP_PATH . '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();
@ -231,14 +233,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 __DIR__ . '/../app/includes/rate_limit_middleware.php'; require_once APP_PATH . '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 __DIR__ . '/../app/classes/user.php'; require_once APP_PATH . 'classes/user.php';
include __DIR__ . '/../app/helpers/profile.php'; include APP_PATH . 'helpers/profile.php';
$userObject = new User($db); $userObject = new User($db);
return true; return true;
}); });
@ -248,7 +250,7 @@ if (!$pipeline->run()) {
// Maintenance mode: show maintenance page to non-superusers // Maintenance mode: show maintenance page to non-superusers
try { try {
require_once __DIR__ . '/../app/core/Maintenance.php'; require_once APP_PATH . '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')) {
@ -262,7 +264,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 __DIR__ . '/../app/templates/maintenance.php'; include APP_PATH . 'templates/maintenance.php';
\App\Helpers\Theme::include('page-footer'); \App\Helpers\Theme::include('page-footer');
ob_end_flush(); ob_end_flush();
exit; exit;
@ -273,7 +275,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-tools">Admin tools</a>'; $custom .= ' Control it in <a href="' . htmlspecialchars($app_root) . '?page=admin">Admin center</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);
} }
@ -295,7 +297,7 @@ if ($validSession && isset($userId) && isset($userObject) && is_object($userObje
} }
// get platforms details // get platforms details
require '../app/classes/platform.php'; require APP_PATH . 'classes/platform.php';
$platformObject = new Platform($db); $platformObject = new Platform($db);
$platformsAll = $platformObject->getPlatformDetails(); $platformsAll = $platformObject->getPlatformDetails();
@ -332,7 +334,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/pages/login.php'; include APP_PATH . 'pages/login.php';
\App\Helpers\Theme::include('page-footer'); \App\Helpers\Theme::include('page-footer');
} else { } else {
@ -348,7 +350,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/classes/server.php'; require APP_PATH . 'classes/server.php';
$serverObject = new Server($db); $serverObject = new Server($db);
$server_host = '127.0.0.1'; $server_host = '127.0.0.1';
@ -407,10 +409,10 @@ if ($page == 'logout') {
if ($validSession) { if ($validSession) {
\App\Helpers\Theme::include('page-sidebar'); \App\Helpers\Theme::include('page-sidebar');
} }
if (file_exists("../app/pages/{$page}.php")) { if (file_exists(APP_PATH . "pages/{$page}.php")) {
include "../app/pages/{$page}.php"; include APP_PATH . "pages/{$page}.php";
} else { } else {
include '../app/templates/error-notfound.php'; include APP_PATH . 'templates/error-notfound.php';
} }
\App\Helpers\Theme::include('page-footer'); \App\Helpers\Theme::include('page-footer');
} }
@ -421,7 +423,7 @@ if ($page == 'logout') {
if ($validSession) { if ($validSession) {
\App\Helpers\Theme::include('page-sidebar'); \App\Helpers\Theme::include('page-sidebar');
} }
include '../app/templates/error-notfound.php'; include APP_PATH . '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

View File

@ -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.display = 'none'; timeNow.style.opacity = '0';
} 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.display = 'block'; timeNow.style.opacity = '1';
} }
} }
@ -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 "<<" // Toggle the value between ">>" and "<<" and fade time box
if (toggleButton.value === ">>") { if (toggleButton.value === ">>") {
toggleButton.value = "<<"; toggleButton.value = "<<";
toggleButton.textContent = "<<"; toggleButton.textContent = "<<";
timeNow.style.display = 'block'; timeNow.style.opacity = '1';
} else { } else {
toggleButton.value = ">>"; toggleButton.value = ">>";
toggleButton.textContent = ">>"; toggleButton.textContent = ">>";
timeNow.style.display = 'none'; timeNow.style.opacity = '0';
} }
// Update with the new state // Update with the new state

View File

@ -17,6 +17,7 @@ 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";
} }
@ -64,6 +65,21 @@ 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);