Compare commits

..

No commits in common. "HEAD" and "v0.4" have entirely different histories.
HEAD ... v0.4

149 changed files with 2419 additions and 13519 deletions

View File

@ -10,104 +10,21 @@ jobs:
test:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: jilo_test
MARIADB_USER: test_jilo
MARIADB_PASSWORD: test_password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h127.0.0.1 -P3306 -uroot -proot"
--health-interval=10s
--health-timeout=10s
--health-retries=5
--health-start-period=30s
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo, pdo_mysql, xdebug
coverage: xdebug
- name: Wait for MariaDB
run: |
sudo apt-get update
sudo apt-get install -y mariadb-client
while ! mysqladmin ping -h"127.0.0.1" -P"3306" -uroot -proot --silent; do
echo "Waiting for database connection..."
sleep 2
done
php-version: '8.1'
extensions: pdo, pdo_sqlite
- name: Install dependencies
run: |
cd tests
composer install
- name: Test database connection
run: |
mysql -h127.0.0.1 -P3306 -uroot -proot -e "SHOW DATABASES;"
mysql -h127.0.0.1 -P3306 -uroot -proot -e "SELECT User,Host FROM mysql.user;"
mysql -h127.0.0.1 -P3306 -uroot -proot -e "GRANT ALL PRIVILEGES ON jilo_test.* TO 'test_jilo'@'%';"
mysql -h127.0.0.1 -P3306 -uroot -proot -e "FLUSH PRIVILEGES;"
- name: Update database config for CI
run: |
# Create temporary test config
mkdir -p tests/config
cat > tests/config/ci-config.php << 'EOF'
<?php
define('CI_DB_PASSWORD', 'test_password');
define('CI_DB_HOST', '127.0.0.1');
EOF
# Verify config file was created
echo "Config file contents:"
cat tests/config/ci-config.php
echo "\nConfig file location:"
ls -la tests/config/ci-config.php
# Grant access from Docker network
mysql -h127.0.0.1 -P3306 -uroot -proot -e "
DROP USER IF EXISTS 'test_jilo'@'%';
CREATE USER 'test_jilo'@'%' IDENTIFIED BY 'test_password';
GRANT ALL PRIVILEGES ON jilo_test.* TO 'test_jilo'@'%';
CREATE DATABASE IF NOT EXISTS jilo_test;
FLUSH PRIVILEGES;
"
# Update test files to require the config (using absolute path)
CONFIG_PATH=$(realpath tests/config/ci-config.php)
echo "\nConfig path: $CONFIG_PATH"
# Add require statement at the very start
for file in tests/Unit/Classes/{DatabaseTest,UserTest}.php; do
echo "<?php" > "$file.tmp"
echo "require_once '$CONFIG_PATH';" >> "$file.tmp"
tail -n +2 "$file" >> "$file.tmp"
mv "$file.tmp" "$file"
echo "\nFirst 5 lines of $file:"
head -n 5 "$file"
done
# Test database connection directly
echo "\nTesting database connection:"
mysql -h127.0.0.1 -P3306 -utest_jilo -ptest_password -e "SELECT 'Database connection successful!'" || echo "Database connection failed"
- name: Run test suite
run: |
cd tests
./vendor/bin/phpunit
# FIXME
# XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage
# env:
# COMPOSER_PROCESS_TIMEOUT: 0
# COMPOSER_NO_INTERACTION: 1
# COMPOSER_NO_AUDIT: 1

1
.gitignore vendored
View File

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

View File

@ -7,84 +7,16 @@ 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
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...HEAD
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...HEAD
- github: https://github.com/lindeas/jilo-web/compare/v0.4...HEAD
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...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"
- 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
### 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
#### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...v0.4.1
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...v0.4.1
- github: https://github.com/lindeas/jilo-web/compare/v0.4...v0.4.1
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...v0.4.1
### Added
- Added the ability to have non-sanitized feedback messages
- Added a notice for maintenance mode for superusers
- Added initial support for maintenance mode
- Added initial support for database upgrades and migrations
- Added CSRF protection to the theme switcher functionality
- Added a helper to manage all static assets of a theme
- Added theme data to be passed correctly to the views
- Added "modern" and "retro" theme screenshots
- Added "alternative retro" theme
- Added CSS and JS to the default theme
- Added change theme menu entry
### Changed
- Moved away from SQLite to MariaDB/MySQL for the main Jilo website
- Integrated Highlight.js library into the SQL view modal for better code highlighting
- Moved the migration flag to the database with a fallback to a file
- Made the theme setting configuration per-user instead of global
- Refactored the session class and added a random session name generator if not configured
- Moved session variables to the configuration file
### Fixed
- Fixed flash messages to show up only once per page
- Fixed database migration functionality and associated feedback notices
- Fixed theme folder structure, helpers, and display logic to work correctly with new asset management
- Fixed index routing to work with the latest session and config changes
- Fixed the router class and several bugs within the session class and theme switcher functionality
### Removed
- Removed getScreenshotUrl function, using the generic getAssetUrl for all assets instead
---

View File

@ -26,7 +26,7 @@ To see a demo install, go to https://work.lindeas.com/jilo-web-demo/
## version
Current version: **0.4.1** released on **2025-11-13**
Current version: **0.3** released on **2025-01-15**
## license
@ -38,8 +38,6 @@ JQuery is used in this project and is licensed under the MIT License. See licens
Chart.js is used in this project and is licensed under the MIT License. See license-chartjs file.
Highlight.js is used in this project and is licensed under the BSD 3-clause License. See license-highlightjs file.
## requirements
- web server (deb: apache | nginx)

View File

@ -43,11 +43,11 @@ class Agent {
jat.endpoint AS agent_endpoint,
h.platform_id
FROM
jilo_agent ja
jilo_agents ja
JOIN
jilo_agent_type jat ON ja.agent_type_id = jat.id
jilo_agent_types jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
hosts h ON ja.host_id = h.id
WHERE
ja.host_id = :host_id';
@ -87,11 +87,11 @@ class Agent {
jat.endpoint AS agent_endpoint,
h.platform_id
FROM
jilo_agent ja
jilo_agents ja
JOIN
jilo_agent_type jat ON ja.agent_type_id = jat.id
jilo_agent_types jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
hosts h ON ja.host_id = h.id
WHERE
ja.id = :agent_id';
@ -110,7 +110,7 @@ class Agent {
*/
public function getAgentTypes() {
$sql = 'SELECT *
FROM jilo_agent_type
FROM jilo_agent_types
ORDER BY id';
$query = $this->db->prepare($sql);
$query->execute();
@ -131,7 +131,7 @@ class Agent {
id,
agent_type_id
FROM
jilo_agent
jilo_agents
WHERE
host_id = :host_id';
$query = $this->db->prepare($sql);
@ -152,7 +152,7 @@ class Agent {
*/
public function addAgent($host_id, $newAgent) {
try {
$sql = 'INSERT INTO jilo_agent
$sql = 'INSERT INTO jilo_agents
(host_id, agent_type_id, url, secret_key, check_period)
VALUES
(:host_id, :agent_type_id, :url, :secret_key, :check_period)';
@ -184,7 +184,7 @@ class Agent {
*/
public function editAgent($agent_id, $updatedAgent) {
try {
$sql = 'UPDATE jilo_agent
$sql = 'UPDATE jilo_agents
SET
agent_type_id = :agent_type_id,
url = :url,
@ -222,7 +222,7 @@ class Agent {
*/
public function deleteAgent($agent_id) {
try {
$sql = 'DELETE FROM jilo_agent
$sql = 'DELETE FROM jilo_agents
WHERE
id = :agent_id';
@ -420,13 +420,13 @@ class Agent {
jac.agent_id,
jat.description
FROM
jilo_agent_check jac
jilo_agent_checks jac
JOIN
jilo_agent ja ON jac.agent_id = ja.id
jilo_agents ja ON jac.agent_id = ja.id
JOIN
jilo_agent_type jat ON ja.agent_type_id = jat.id
jilo_agent_types jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
hosts h ON ja.host_id = h.id
WHERE
h.id = :host_id
AND jat.description = :agent_type
@ -520,13 +520,13 @@ class Agent {
jac.response_content,
COUNT(*) as checks_count
FROM
jilo_agent_check jac
jilo_agent_checks jac
JOIN
jilo_agent ja ON jac.agent_id = ja.id
jilo_agents ja ON jac.agent_id = ja.id
JOIN
jilo_agent_type jat ON ja.agent_type_id = jat.id
jilo_agent_types jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
hosts h ON ja.host_id = h.id
WHERE
h.id = :host_id
AND jat.description = :agent_type
@ -591,13 +591,13 @@ class Agent {
jac.timestamp,
jac.response_content
FROM
jilo_agent_check jac
jilo_agent_checks jac
JOIN
jilo_agent ja ON jac.agent_id = ja.id
jilo_agents ja ON jac.agent_id = ja.id
JOIN
jilo_agent_type jat ON ja.agent_type_id = jat.id
jilo_agent_types jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
hosts h ON ja.host_id = h.id
WHERE
h.id = :host_id
AND jat.description = :agent_type

View File

@ -94,11 +94,11 @@ class Component {
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($result)) {
$logObject->log('info', "Retrieved " . count($result) . " Jitsi component events", ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog(0, "Retrieved " . count($result) . " Jitsi component events");
}
return $result;
} catch (PDOException $e) {
$logObject->log('error', "Failed to retrieve Jitsi component events: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog(0, "Failed to retrieve Jitsi component events: " . $e->getMessage());
return [];
}
}
@ -162,7 +162,7 @@ class Component {
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return (int)$result['total'];
} catch (PDOException $e) {
$logObject->log('error', "Failed to retrieve component events count: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog(0, "Failed to retrieve component events count: " . $e->getMessage());
return 0;
}
}

View File

@ -16,7 +16,7 @@ class Config {
* @return array Returns an array with 'success' and 'updated' keys on success, or 'success' and 'error' keys on failure.
*/
public function editConfigFile($updatedConfig, $config_file) {
global $logObject, $userId;
global $logObject, $user_id;
$allLogs = [];
$updated = [];
@ -140,7 +140,7 @@ class Config {
}
if (!empty($allLogs)) {
$logObject->log('info', implode("\n", $allLogs), ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog($user_id, implode("\n", $allLogs), 'system');
}
return [
@ -148,7 +148,7 @@ class Config {
'updated' => $updated
];
} catch (Exception $e) {
$logObject->log('error', "Config update error: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog($user_id, "Config update error: " . $e->getMessage(), 'system');
return [
'success' => false,
'error' => $e->getMessage()

View File

@ -35,10 +35,6 @@ class Feedback {
'type' => self::TYPE_SUCCESS,
'dismissible' => true
],
'SESSION_TIMEOUT' => [
'type' => self::TYPE_ERROR,
'dismissible' => true
],
'IP_BLACKLISTED' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
@ -53,21 +49,6 @@ class Feedback {
]
];
const REGISTER = [
'SUCCESS' => [
'type' => self::TYPE_SUCCESS,
'dismissible' => true
],
'FAILED' => [
'type' => self::TYPE_ERROR,
'dismissible' => true
],
'DISABLED' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
],
];
const SECURITY = [
'WHITELIST_ADD_SUCCESS' => [
'type' => self::TYPE_SUCCESS,
@ -115,15 +96,19 @@ class Feedback {
]
];
const THEME = [
'THEME_CHANGE_SUCCESS' => [
const REGISTER = [
'SUCCESS' => [
'type' => self::TYPE_SUCCESS,
'dismissible' => true
],
'THEME_CHANGE_FAILED' => [
'FAILED' => [
'type' => self::TYPE_ERROR,
'dismissible' => true
]
],
'DISABLED' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
],
];
const SYSTEM = [
@ -139,14 +124,6 @@ class Feedback {
'type' => self::TYPE_ERROR,
'dismissible' => false
],
'MIGRATIONS_PENDING' => [
'type' => self::TYPE_WARNING,
'dismissible' => true
],
'MAINTENANCE_ON' => [
'type' => self::TYPE_WARNING,
'dismissible' => false
],
];
private static $strings = null;
@ -217,7 +194,7 @@ class Feedback {
* Store feedback message in session for display after redirect
*/
// Usage: Feedback::flash('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]);
public static function flash($category, $key, $customMessage = null, $dismissible = null, $small = false, $sanitize = true) {
public static function flash($category, $key, $customMessage = null, $dismissible = null, $small = false) {
if (!isset($_SESSION['flash_messages'])) {
$_SESSION['flash_messages'] = [];
}
@ -231,8 +208,7 @@ class Feedback {
'key' => $key,
'custom_message' => $customMessage,
'dismissible' => $isDismissible,
'small' => $small,
'sanitize' => $sanitize
'small' => $small
];
}

View File

@ -37,7 +37,7 @@ class Host {
platform_id,
name
FROM
host';
hosts';
if ($platform_id !== '' && $host_id !== '') {
$sql .= ' WHERE platform_id = :platform_id AND id = :host_id';
@ -71,7 +71,7 @@ class Host {
*/
public function addHost($newHost) {
try {
$sql = 'INSERT INTO host
$sql = 'INSERT INTO hosts
(address, platform_id, name)
VALUES
(:address, :platform_id, :name)';
@ -101,7 +101,7 @@ class Host {
*/
public function editHost($platform_id, $updatedHost) {
try {
$sql = 'UPDATE host SET
$sql = 'UPDATE hosts SET
address = :address,
name = :name
WHERE
@ -140,13 +140,13 @@ class Host {
$this->db->beginTransaction();
// First delete all agents associated with this host
$sql = 'DELETE FROM jilo_agent WHERE host_id = :host_id';
$sql = 'DELETE FROM jilo_agents WHERE host_id = :host_id';
$query = $this->db->prepare($sql);
$query->bindParam(':host_id', $host_id);
$query->execute();
// Then delete the host
$sql = 'DELETE FROM host WHERE id = :host_id';
$sql = 'DELETE FROM hosts WHERE id = :host_id';
$query = $this->db->prepare($sql);
$query->bindParam(':host_id', $host_id);
$query->execute();

View File

@ -1,40 +1,122 @@
<?php
/**
* Log wrapper that delegates to plugin Log or NullLogger fallback.
* Used when code does require_once '../app/classes/log.php'.
* class Log
*
* Handles logging events into a database and reading log entries.
*/
// If there is already a Log plugin loaded
if (class_exists('Log')) {
return;
}
// Load fallback NullLogger
require_once __DIR__ . '/../core/NullLogger.php';
class Log {
private $logger;
/**
* @var PDO|null $db The database connection instance.
*/
private $db;
/**
* @param mixed $database Database or DatabaseConnector instance
* Logs constructor.
* Initializes the database connection.
*
* @param object $database The database object to initialize the connection.
*/
public function __construct($database) {
global $logObject;
if (isset($logObject) && method_exists($logObject, 'insertLog')) {
$this->logger = $logObject;
} else {
$this->logger = new \App\Core\NullLogger();
$this->db = $database->getConnection();
}
/**
* Insert a log event into the database.
*
* @param int $user_id The ID of the user associated with the log event.
* @param string $message The log message to insert.
* @param string $scope The scope of the log event (e.g., 'user', 'system'). Default is 'user'.
*
* @return bool|string True on success, or an error message on failure.
*/
public function insertLog($user_id, $message, $scope='user') {
try {
$sql = 'INSERT INTO logs
(user_id, scope, message)
VALUES
(:user_id, :scope, :message)';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $user_id,
':scope' => $scope,
':message' => $message,
]);
return true;
} catch (Exception $e) {
return $e->getMessage();
}
}
/**
* PSR-3 compatible log method
* @param string $level
* @param string $message
* @param array $context
* Retrieve log entries from the database.
*
* @param int $user_id The ID of the user whose logs are being retrieved.
* @param string $scope The scope of the logs ('user' or 'system').
* @param int $offset The offset for pagination. Default is 0.
* @param int $items_per_page The number of log entries to retrieve per page. Default is no limit.
* @param array $filters Optional array of filters (from_time, until_time, message, id)
*
* @return array An array of log entries.
*/
public function log(string $level, string $message, array $context = []): void {
$this->logger->log($level, $message, $context);
public function readLog($user_id, $scope, $offset=0, $items_per_page='', $filters=[]) {
$params = [];
$where_clauses = [];
// Base query with user join
$base_sql = 'SELECT l.*, u.username
FROM logs l
LEFT JOIN users u ON l.user_id = u.id';
// Add scope condition
if ($scope === 'user') {
$where_clauses[] = 'l.user_id = :user_id';
$params[':user_id'] = $user_id;
}
// Add time range filters if specified
if (!empty($filters['from_time'])) {
$where_clauses[] = 'l.time >= :from_time';
$params[':from_time'] = $filters['from_time'] . ' 00:00:00';
}
if (!empty($filters['until_time'])) {
$where_clauses[] = 'l.time <= :until_time';
$params[':until_time'] = $filters['until_time'] . ' 23:59:59';
}
// Add message search if specified
if (!empty($filters['message'])) {
$where_clauses[] = 'l.message LIKE :message';
$params[':message'] = '%' . $filters['message'] . '%';
}
// Add user ID search if specified
if (!empty($filters['id'])) {
$where_clauses[] = 'l.user_id = :search_user_id';
$params[':search_user_id'] = $filters['id'];
}
// Combine WHERE clauses
$sql = $base_sql;
if (!empty($where_clauses)) {
$sql .= ' WHERE ' . implode(' AND ', $where_clauses);
}
// Add ordering
$sql .= ' ORDER BY l.time DESC';
// Add pagination
if ($items_per_page) {
$items_per_page = (int)$items_per_page;
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
}
$query = $this->db->prepare($sql);
$query->execute($params);
return $query->fetchAll(PDO::FETCH_ASSOC);
}
}

View File

@ -26,8 +26,8 @@ class PasswordReset {
// Check if email exists
$query = $this->db->prepare("
SELECT u.id, um.email
FROM user u
JOIN user_meta um ON u.id = um.user_id
FROM users u
JOIN users_meta um ON u.id = um.user_id
WHERE um.email = :email"
);
$query->bindParam(':email', $email);
@ -67,23 +67,28 @@ class PasswordReset {
// Send email with reset link
$to = $user['email'];
// Load email helper
require_once __DIR__ . '/../helpers/email_helper.php';
$subject = "{$config['site_name']} - Password reset request";
$message = "Dear user,\n\n";
$message .= "We received a request to reset your password for your {$config['site_name']} account.\n\n";
$message .= "To set a new password, please click the link below:\n\n";
$message .= $resetLink . "\n\n";
$message .= "This link will expire in 1 hour for security reasons.\n\n";
$message .= "If you did not request this password reset, please ignore this email. Your account remains secure.\n\n";
if (!empty($config['site_name'])) {
$message .= "Best regards,\n";
$message .= "The {$config['site_name']} team\n";
if (!empty($config['site_slogan'])) {
$message .= ":: {$config['site_slogan']} ::";
}
}
$variables = [
'site_name' => $config['site_name'],
'reset_link' => $resetLink,
'site_slogan' => $config['site_slogan'] ?? ''
];
$additionalHeaders = [
$headers = [
'From' => "noreply@{$config['domain']}",
'Reply-To' => "noreply@{$config['domain']}"
'Reply-To' => "noreply@{$config['domain']}",
'X-Mailer' => 'PHP/' . phpversion()
];
if (!sendTemplateEmail($to, $subject, 'password_reset', $variables, $config, $additionalHeaders)) {
if (!mail($to, $subject, $message, $headers)) {
return ['success' => false, 'message' => 'Failed to send reset email'];
}

View File

@ -30,7 +30,7 @@ class Platform {
* @return array An associative array containing platform details.
*/
public function getPlatformDetails($platform_id = '') {
$sql = 'SELECT * FROM platform';
$sql = 'SELECT * FROM platforms';
if ($platform_id !== '') {
$sql .= ' WHERE id = :platform_id';
$query = $this->db->prepare($sql);
@ -57,7 +57,7 @@ class Platform {
*/
public function addPlatform($newPlatform) {
try {
$sql = 'INSERT INTO platform
$sql = 'INSERT INTO platforms
(name, jitsi_url, jilo_database)
VALUES
(:name, :jitsi_url, :jilo_database)';
@ -90,7 +90,7 @@ class Platform {
*/
public function editPlatform($platform_id, $updatedPlatform) {
try {
$sql = 'UPDATE platform SET
$sql = 'UPDATE platforms SET
name = :name,
jitsi_url = :jitsi_url,
jilo_database = :jilo_database
@ -125,7 +125,7 @@ class Platform {
$this->db->beginTransaction();
// First, get all hosts in this platform
$sql = 'SELECT id FROM host WHERE platform_id = :platform_id';
$sql = 'SELECT id FROM hosts WHERE platform_id = :platform_id';
$query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id);
$query->execute();
@ -133,20 +133,20 @@ class Platform {
// Delete all agents for each host
foreach ($hosts as $host) {
$sql = 'DELETE FROM jilo_agent WHERE host_id = :host_id';
$sql = 'DELETE FROM jilo_agents WHERE host_id = :host_id';
$query = $this->db->prepare($sql);
$query->bindParam(':host_id', $host['id']);
$query->execute();
}
// Delete all hosts in this platform
$sql = 'DELETE FROM host WHERE platform_id = :platform_id';
$sql = 'DELETE FROM hosts WHERE platform_id = :platform_id';
$query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id);
$query->execute();
// Finally, delete the platform
$sql = 'DELETE FROM platform WHERE id = :platform_id';
$sql = 'DELETE FROM platforms WHERE id = :platform_id';
$query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id);
$query->execute();

View File

@ -1,20 +1,16 @@
<?php
use App\Core\NullLogger;
class RateLimiter {
public $db;
private $database;
/** @var mixed NullLogger (or PSR-3 logger) or plugin Log */
private $logger;
private $log;
public $maxAttempts = 5; // Maximum login attempts
public $decayMinutes = 15; // Time window in minutes
public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist
public $autoBlacklistDuration = 24; // Hours to blacklist for
public $authRatelimitTable = 'security_rate_auth'; // For rate limiting username/password attempts
public $pagesRatelimitTable = 'security_rate_page'; // For rate limiting page requests
public $whitelistTable = 'security_ip_whitelist'; // For whitelisting IPs and network ranges
public $blacklistTable = 'security_ip_blacklist'; // For blacklisting IPs and network ranges
public $authRatelimitTable = 'login_attempts'; // For username/password attempts
public $pagesRatelimitTable = 'pages_rate_limits'; // For page requests
public $whitelistTable = 'ip_whitelist';
public $blacklistTable = 'ip_blacklist';
private $pageLimits = [
// Default rate limits per minute
'default' => 60,
@ -26,23 +22,9 @@ class RateLimiter {
'config' => 10
];
/**
* @param mixed $database Database object
* @param mixed $logger Optional NullLogger (or PSR-3 logger) or plugin Log
*/
public function __construct($database, $logger = null) {
$this->database = $database;
public function __construct($database) {
$this->db = $database->getConnection();
// Initialize logger (plugin Log if present or NullLogger otherwise)
if ($logger !== null) {
$this->logger = $logger;
} else {
global $logObject;
$this->logger = isset($logObject) && is_object($logObject) && method_exists($logObject, 'info')
? $logObject
: new NullLogger();
}
// Initialize database tables
$this->log = new Log($database);
$this->createTablesIfNotExist();
}
@ -50,47 +32,42 @@ class RateLimiter {
private function createTablesIfNotExist() {
// Authentication attempts table
$sql = "CREATE TABLE IF NOT EXISTS {$this->authRatelimitTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address VARCHAR(45) NOT NULL,
username VARCHAR(255) NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip_username (ip_address, username)
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
username TEXT NOT NULL,
attempted_at TEXT DEFAULT (DATETIME('now'))
)";
$this->db->exec($sql);
// Pages rate limits table
$sql = "CREATE TABLE IF NOT EXISTS {$this->pagesRatelimitTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address VARCHAR(45) NOT NULL,
endpoint VARCHAR(255) NOT NULL,
request_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip_endpoint (ip_address, endpoint),
INDEX idx_request_time (request_time)
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_time DATETIME DEFAULT CURRENT_TIMESTAMP
)";
$this->db->exec($sql);
// IP whitelist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address VARCHAR(45) NOT NULL,
is_network BOOLEAN DEFAULT FALSE,
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255),
UNIQUE KEY unique_ip (ip_address)
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
description TEXT,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
)";
$this->db->exec($sql);
// IP blacklist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address VARCHAR(45) NOT NULL,
is_network BOOLEAN DEFAULT FALSE,
reason VARCHAR(255),
expiry_time TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255),
UNIQUE KEY unique_ip (ip_address)
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
reason TEXT,
expiry_time TEXT NULL,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
)";
$this->db->exec($sql);
@ -104,7 +81,7 @@ class RateLimiter {
];
// Insert default whitelisted IPs if they don't exist
$stmt = $this->db->prepare("INSERT IGNORE INTO {$this->whitelistTable}
$stmt = $this->db->prepare("INSERT OR IGNORE INTO {$this->whitelistTable}
(ip_address, is_network, description, created_by)
VALUES (?, ?, ?, 'system')");
foreach ($defaultIps as $ip) {
@ -120,7 +97,7 @@ class RateLimiter {
['203.0.113.0/24', true, 'TEST-NET-3 Documentation space - RFC 5737']
];
$stmt = $this->db->prepare("INSERT IGNORE INTO {$this->blacklistTable}
$stmt = $this->db->prepare("INSERT OR IGNORE INTO {$this->blacklistTable}
(ip_address, is_network, reason, created_by)
VALUES (?, ?, ?, 'system')");
@ -135,7 +112,7 @@ class RateLimiter {
*/
public function getRecentAttempts($ip) {
$stmt = $this->db->prepare("SELECT COUNT(*) as attempts FROM {$this->authRatelimitTable}
WHERE ip_address = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL ? MINUTE)");
WHERE ip_address = ? AND attempted_at > datetime('now', '-' || :minutes || ' minutes')");
$stmt->execute([$ip, $this->decayMinutes]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return intval($result['attempts']);
@ -232,19 +209,15 @@ class RateLimiter {
if ($this->isIpBlacklisted($ip)) {
$message = "Cannot whitelist {$ip} - IP is currently blacklisted";
if ($userId) {
$this->logger->log('info', "IP Whitelist: {$message}", ['user_id' => $userId, 'scope' => 'system']);
$this->log->insertLog($userId, "IP Whitelist: {$message}", 'system');
Feedback::flash('ERROR', 'DEFAULT', $message);
}
return false;
}
$stmt = $this->db->prepare("INSERT INTO {$this->whitelistTable}
$stmt = $this->db->prepare("INSERT OR REPLACE INTO {$this->whitelistTable}
(ip_address, is_network, description, created_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
is_network = VALUES(is_network),
description = VALUES(description),
created_by = VALUES(created_by)");
VALUES (?, ?, ?, ?)");
$result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]);
@ -256,14 +229,14 @@ class RateLimiter {
$createdBy,
$description
);
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->logger->log('error', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$this->log->insertLog($userId, "IP Whitelist: Failed to add {$ip}: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage());
}
return false;
@ -291,14 +264,14 @@ class RateLimiter {
$removedBy,
$ipDetails['created_by']
);
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->logger->log('error', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$this->log->insertLog($userId, "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage());
}
return false;
@ -311,7 +284,7 @@ class RateLimiter {
if ($this->isIpWhitelisted($ip)) {
$message = "Cannot blacklist {$ip} - IP is currently whitelisted";
if ($userId) {
$this->logger->log('info', "IP Blacklist: {$message}", ['user_id' => $userId, 'scope' => 'system']);
$this->log->insertLog($userId, "IP Blacklist: {$message}", 'system');
Feedback::flash('ERROR', 'DEFAULT', $message);
}
return false;
@ -319,14 +292,9 @@ class RateLimiter {
$expiryTime = $expiryHours ? date('Y-m-d H:i:s', strtotime("+{$expiryHours} hours")) : null;
$stmt = $this->db->prepare("INSERT INTO {$this->blacklistTable}
$stmt = $this->db->prepare("INSERT OR REPLACE INTO {$this->blacklistTable}
(ip_address, is_network, reason, expiry_time, created_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
is_network = VALUES(is_network),
reason = VALUES(reason),
expiry_time = VALUES(expiry_time),
created_by = VALUES(created_by)");
VALUES (?, ?, ?, ?, ?)");
$result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]);
@ -339,13 +307,13 @@ class RateLimiter {
$reason,
$expiryTime ?? 'never'
);
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->logger->log('error', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$this->log->insertLog($userId, "IP Blacklist: Failed to add {$ip}: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage());
}
return false;
@ -373,13 +341,13 @@ class RateLimiter {
$ipDetails['created_by'],
$ipDetails['reason']
);
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->logger->log('error', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
$this->log->insertLog($userId, "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage());
}
return false;
@ -404,17 +372,17 @@ class RateLimiter {
try {
// Remove expired blacklist entries
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable}
WHERE expiry_time IS NOT NULL AND expiry_time < NOW()");
WHERE expiry_time IS NOT NULL AND expiry_time < datetime('now')");
$stmt->execute();
// Clean old login attempts
$stmt = $this->db->prepare("DELETE FROM {$this->authRatelimitTable}
WHERE attempted_at < DATE_SUB(NOW(), INTERVAL :minutes MINUTE)");
WHERE attempted_at < datetime('now', '-' || :minutes || ' minutes')");
$stmt->execute([':minutes' => $this->decayMinutes]);
return true;
} catch (Exception $e) {
$this->logger->log('error', "Failed to cleanup expired entries: " . $e->getMessage(), ['user_id' => $userId ?? null, 'scope' => 'system']);
$this->log->insertLog(0, "Failed to cleanup expired entries: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', "Failed to cleanup expired entries: " . $e->getMessage());
return false;
}
@ -443,7 +411,7 @@ class RateLimiter {
$sql = "SELECT COUNT(*) as total_attempts
FROM {$this->authRatelimitTable}
WHERE ip_address = :ip
AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
@ -455,12 +423,7 @@ class RateLimiter {
return $result['total_attempts'] < $this->autoBlacklistThreshold;
}
public function attempt($username, $ipAddress, $failed = true) {
// Only record failed attempts
if (!$failed) {
return true;
}
public function attempt($username, $ipAddress) {
// Record this attempt
$sql = "INSERT INTO {$this->authRatelimitTable} (ip_address, username) VALUES (:ip, :username)";
$stmt = $this->db->prepare($sql);
@ -481,7 +444,7 @@ class RateLimiter {
FROM {$this->authRatelimitTable}
WHERE ip_address = :ip
AND username = :username
AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
@ -517,7 +480,7 @@ class RateLimiter {
public function clearOldAttempts() {
$sql = "DELETE FROM {$this->authRatelimitTable}
WHERE attempted_at < DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
WHERE attempted_at < datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
@ -530,7 +493,7 @@ class RateLimiter {
FROM {$this->authRatelimitTable}
WHERE ip_address = :ip
AND username = :username
AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
@ -572,7 +535,7 @@ class RateLimiter {
FROM {$this->pagesRatelimitTable}
WHERE ip_address = :ip
AND endpoint = :endpoint
AND request_time >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
AND request_time >= DATETIME('now', '-1 minute')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
@ -603,7 +566,7 @@ class RateLimiter {
*/
private function cleanOldPageRequests() {
$sql = "DELETE FROM {$this->pagesRatelimitTable}
WHERE request_time < DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
WHERE request_time < DATETIME('now', '-1 minute')";
$stmt = $this->db->prepare($sql);
$stmt->execute();
@ -615,10 +578,8 @@ class RateLimiter {
private function getPageLimitForEndpoint($endpoint, $userId = null) {
// Admin users get higher limits
if ($userId) {
// Check admin rights directly from database
$stmt = $this->db->prepare('SELECT COUNT(*) FROM `user_right` ur JOIN `right` r ON ur.right_id = r.id WHERE ur.user_id = ? AND r.name = ?');
$stmt->execute([$userId, 'superuser']);
if ($stmt->fetchColumn() > 0) {
$userObj = new User($this->db);
if ($userObj->hasRight($userId, 'admin')) {
return $this->pageLimits['admin'];
}
}
@ -654,7 +615,7 @@ class RateLimiter {
FROM {$this->pagesRatelimitTable}
WHERE ip_address = :ip
AND endpoint = :endpoint
AND request_time > DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
AND request_time > DATETIME('now', '-1 minute')";
$stmt = $this->db->prepare($sql);
$stmt->execute([

View File

@ -1,276 +0,0 @@
<?php
/**
* Session Class
*
* Core session management functionality for the application
*/
class Session {
private static $initialized = false;
private static $sessionName = ''; // Will be set from config, if not we'll have a random session name
/**
* Generate a random session name
*/
private static function generateRandomSessionName(): string {
return 'sess_' . bin2hex(random_bytes(8)); // 16-character random string
}
private static $sessionOptions = [
'cookie_httponly' => 1,
'cookie_secure' => 1,
'cookie_samesite' => 'Strict',
'gc_maxlifetime' => 7200 // 2 hours
];
/**
* Initialize session configuration
*/
private static function initialize() {
if (self::$initialized) {
return;
}
global $config;
// Get session name from config or generate a random one
self::$sessionName = $config['session']['name'] ?? self::generateRandomSessionName();
// Set session name before starting the session, only if headers not sent and no active session
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_name(self::$sessionName);
}
// Set session cookie parameters only if headers not sent and no active session
$thisPath = $config['folder'] ?? '/';
$thisDomain = $config['domain'] ?? '';
$isSecure = isset($_SERVER['HTTPS']);
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_set_cookie_params([
'lifetime' => 0, // Session cookie (browser session)
'path' => $thisPath,
'domain' => $thisDomain,
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Strict'
]);
}
// Align session start options dynamically with current transport
self::$sessionOptions['cookie_secure'] = $isSecure ? 1 : 0;
self::$sessionOptions['cookie_samesite'] = 'Strict';
self::$initialized = true;
}
/**
* Get session name from config or generate a random one
*/
private static function getSessionNameFromConfig($config) {
if (isset($config['session']['name']) && !empty($config['session']['name'])) {
return $config['session']['name'];
}
return self::generateRandomSessionName();
}
/**
* Start or resume a session with secure options
*/
public static function startSession() {
self::initialize();
if (session_status() === PHP_SESSION_NONE) {
if (!headers_sent()) {
session_start(self::$sessionOptions);
}
}
}
/**
* Destroy current session and clean up
*/
public static function destroySession() {
if (session_status() === PHP_SESSION_ACTIVE) {
session_unset();
session_destroy();
}
}
/**
* Get current username if set
*/
public static function getUsername() {
return isset($_SESSION['username']) ? htmlspecialchars($_SESSION['username']) : null;
}
/**
* Get current user ID if set
*/
public static function getUserId() {
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
}
/**
* Check if current session is valid
*
* @param bool $strict If true, will return false for new/unauthenticated sessions
* @return bool True if session is valid, false otherwise
*/
public static function isValidSession($strict = true) {
// Ensure a session is started (safe in CLI/tests)
self::startSession();
// If there is no session data at all, it's not valid
if (empty($_SESSION)) {
return false;
}
// In non-strict mode, consider empty session as valid (for login/logout)
if (!$strict && !isset($_SESSION['user_id']) && !isset($_SESSION['username'])) {
return true;
}
// In strict mode, require user_id and username
if ($strict && (!isset($_SESSION['user_id']) || !isset($_SESSION['username']))) {
return false;
}
// Check session timeout
$session_timeout = isset($_SESSION['REMEMBER_ME']) ? (30 * 24 * 60 * 60) : 7200; // 30 days or 2 hours
if (isset($_SESSION['LAST_ACTIVITY']) && (time() - $_SESSION['LAST_ACTIVITY'] > $session_timeout)) {
return false;
}
// Update last activity time
$_SESSION['LAST_ACTIVITY'] = time();
// Regenerate session ID periodically (every 30 minutes)
if (!isset($_SESSION['CREATED'])) {
$_SESSION['CREATED'] = time();
} else if (time() - $_SESSION['CREATED'] > 1800) {
// Regenerate session ID and update creation time
if (!headers_sent() && session_status() === PHP_SESSION_ACTIVE) {
$oldData = $_SESSION;
session_regenerate_id(true);
$_SESSION = $oldData;
$_SESSION['CREATED'] = time();
}
}
return true;
}
/**
* Set remember me option for extended session
*/
public static function setRememberMe($value = true) {
$_SESSION['REMEMBER_ME'] = $value;
}
/**
* Clear session data and cookies
*/
public static function cleanup($config) {
self::destroySession();
// Clear cookies if headers not sent
if (!headers_sent()) {
setcookie('username', '', [
'expires' => time() - 3600,
'path' => $config['folder'],
'domain' => $config['domain'],
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
}
// Start fresh session
self::startSession();
// Reset session timeout flag
unset($_SESSION['session_timeout_shown']);
}
/**
* Create a new authenticated session for a user
*/
public static function createAuthSession($userId, $username, $rememberMe, $config) {
// Ensure session is started
self::startSession();
// Set session variables
$_SESSION['user_id'] = $userId;
$_SESSION['username'] = $username;
$_SESSION['LAST_ACTIVITY'] = time();
$_SESSION['REMEMBER_ME'] = $rememberMe;
// Set cookie lifetime based on remember me
$cookieLifetime = $rememberMe ? time() + (30 * 24 * 60 * 60) : 0;
// Update session cookie with remember me setting
if (!headers_sent()) {
setcookie(
session_name(),
session_id(),
[
'expires' => $cookieLifetime,
'path' => $config['folder'] ?? '/',
'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]
);
// Set username cookie
setcookie('username', $username, [
'expires' => $cookieLifetime,
'path' => $config['folder'] ?? '/',
'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
}
if ($rememberMe) {
self::setRememberMe(true);
}
}
/**
* Store 2FA pending information in session
*/
public static function store2FAPending($userId, $username, $rememberMe = false) {
$_SESSION['2fa_pending_user_id'] = $userId;
$_SESSION['2fa_pending_username'] = $username;
if ($rememberMe) {
$_SESSION['2fa_pending_remember'] = true;
}
}
/**
* Clear 2FA pending information from session
*/
public static function clear2FAPending() {
unset($_SESSION['2fa_pending_user_id']);
unset($_SESSION['2fa_pending_username']);
unset($_SESSION['2fa_pending_remember']);
}
/**
* Get 2FA pending information
*/
public static function get2FAPending() {
if (!isset($_SESSION['2fa_pending_user_id']) || !isset($_SESSION['2fa_pending_username'])) {
return null;
}
return [
'user_id' => $_SESSION['2fa_pending_user_id'],
'username' => $_SESSION['2fa_pending_username'],
'remember_me' => isset($_SESSION['2fa_pending_remember'])
];
}
}

View File

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

View File

@ -3,7 +3,7 @@
/**
* class User
*
* Handles user-related functionalities such as login, rights management, and profile updates.
* Handles user-related functionalities such as registration, login, rights management, and profile updates.
*/
class User {
/**
@ -12,11 +12,6 @@ class User {
private $db;
private $rateLimiter;
private $twoFactorAuth;
/**
* Cache for database schema checks
* @var array<string,bool>
*/
private static $schemaCache = [];
/**
* User constructor.
@ -37,76 +32,60 @@ class User {
$this->twoFactorAuth = new TwoFactorAuthentication($database);
}
/**
* Check if a column exists in a given table. Results are cached per request.
*
* @param string $table
* @param string $column
* @return bool
*/
private function columnExists(string $table, string $column): bool {
$cacheKey = $table . '.' . $column;
if (isset(self::$schemaCache[$cacheKey])) {
return self::$schemaCache[$cacheKey];
}
try {
$stmt = $this->db->prepare("SHOW COLUMNS FROM `$table` LIKE :column");
$stmt->execute([':column' => $column]);
$exists = (bool)$stmt->fetch(PDO::FETCH_ASSOC);
self::$schemaCache[$cacheKey] = $exists;
return $exists;
} catch (Exception $e) {
// On error, assume column doesn't exist to be safe
self::$schemaCache[$cacheKey] = false;
return false;
}
}
/**
* Get the user's preferred theme if stored in DB (user_meta.theme). Returns null if not set.
* Registers a new user.
*
* @param int $userId
* @return string|null
* @param string $username The username of the new user.
* @param string $password The password for the new user.
*
* @return bool|string True if registration is successful, error message otherwise.
*/
public function getUserTheme(int $userId): ?string {
if (!$this->columnExists('user_meta', 'theme')) {
return null;
}
public function register($username, $password) {
try {
$sql = 'SELECT theme FROM user_meta WHERE user_id = :user_id LIMIT 1';
$stmt = $this->db->prepare($sql);
$stmt->execute([':user_id' => $userId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
// we have two inserts, start a transaction
$this->db->beginTransaction();
// hash the password, don't store it plain
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// insert into users table
$sql = 'INSERT
INTO users (username, password)
VALUES (:username, :password)';
$query = $this->db->prepare($sql);
$query->bindValue(':username', $username);
$query->bindValue(':password', $hashedPassword);
// execute the first query
if (!$query->execute()) {
// rollback on error
$this->db->rollBack();
return false;
}
$theme = $row['theme'] ?? null;
return ($theme !== null && $theme !== '') ? $theme : null;
} catch (Exception $e) {
return null;
}
}
/**
* Persist the user's preferred theme in DB (user_meta.theme) when the column exists.
* Silently no-ops if the column is missing.
*
* @param int $userId
* @param string $theme
* @return bool True when stored or safely skipped; false only on explicit DB error.
*/
public function setUserTheme(int $userId, string $theme): bool {
if (!$this->columnExists('user_meta', 'theme')) {
// Column not present; treat as success to avoid breaking UX
// insert the last user id into users_meta table
$sql2 = 'INSERT
INTO users_meta (user_id)
VALUES (:user_id)';
$query2 = $this->db->prepare($sql2);
$query2->bindValue(':user_id', $this->db->lastInsertId());
// execute the second query
if (!$query2->execute()) {
// rollback on error
$this->db->rollBack();
return false;
}
// if all is OK, commit the transaction
$this->db->commit();
return true;
}
try {
$sql = 'UPDATE user_meta SET theme = :theme WHERE user_id = :user_id';
$stmt = $this->db->prepare($sql);
$ok = $stmt->execute([':theme' => $theme, ':user_id' => $userId]);
return (bool)$ok;
} catch (Exception $e) {
return false;
// rollback on any error
$this->db->rollBack();
return $e->getMessage();
}
}
@ -122,8 +101,12 @@ class User {
*/
public function login($username, $password, $twoFactorCode = null) {
// Get user's IP address
require_once __DIR__ . '/../helpers/logs.php';
$ipAddress = getUserIP();
// Record attempt
$this->rateLimiter->attempt($username, $ipAddress);
// Check rate limiting first
if (!$this->rateLimiter->isAllowed($username, $ipAddress)) {
$remainingTime = $this->rateLimiter->getDecayMinutes();
@ -131,7 +114,7 @@ class User {
}
// Then check credentials
$query = $this->db->prepare("SELECT * FROM user WHERE username = :username");
$query = $this->db->prepare("SELECT * FROM users WHERE username = :username");
$query->bindParam(':username', $username);
$query->execute();
@ -170,10 +153,7 @@ class User {
// Get remaining attempts AFTER this failed attempt
$remainingAttempts = $this->rateLimiter->getRemainingAttempts($username, $ipAddress);
return [
'status' => 'failed',
'message' => "Invalid credentials. {$remainingAttempts} attempts remaining."
];
throw new Exception("Invalid credentials. {$remainingAttempts} attempts remaining.");
}
@ -186,7 +166,7 @@ class User {
*/
// FIXME not used now?
public function getUserId($username) {
$sql = 'SELECT id FROM user WHERE username = :username';
$sql = 'SELECT id FROM users WHERE username = :username';
$query = $this->db->prepare($sql);
$query->bindParam(':username', $username);
@ -200,24 +180,24 @@ class User {
/**
* Fetches user details by user ID.
*
* @param int $userId The user ID.
* @param int $user_id The user ID.
*
* @return array|null User details or null if not found.
*/
public function getUserDetails($userId) {
public function getUserDetails($user_id) {
$sql = 'SELECT
um.*,
u.username
FROM
user_meta um
LEFT JOIN user u
users_meta um
LEFT JOIN users u
ON um.user_id = u.id
WHERE
u.id = :user_id';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':user_id' => $user_id,
]);
return $query->fetchAll(PDO::FETCH_ASSOC);
@ -228,19 +208,19 @@ class User {
/**
* Grants a user a specific right.
*
* @param int $userId The user ID.
* @param int $right_id The right ID to grant.
* @param int $user_id The user ID.
* @param int $right_id The right ID to grant.
*
* @return void
*/
public function addUserRight($userId, $right_id) {
$sql = 'INSERT INTO user_right
public function addUserRight($user_id, $right_id) {
$sql = 'INSERT INTO users_rights
(user_id, right_id)
VALUES
(:user_id, :right_id)';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':user_id' => $user_id,
':right_id' => $right_id,
]);
}
@ -249,20 +229,20 @@ class User {
/**
* Revokes a specific right from a user.
*
* @param int $userId The user ID.
* @param int $right_id The right ID to revoke.
* @param int $user_id The user ID.
* @param int $right_id The right ID to revoke.
*
* @return void
*/
public function removeUserRight($userId, $right_id) {
$sql = 'DELETE FROM user_right
public function removeUserRight($user_id, $right_id) {
$sql = 'DELETE FROM users_rights
WHERE
user_id = :user_id
AND
right_id = :right_id';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':user_id' => $user_id,
':right_id' => $right_id,
]);
}
@ -277,7 +257,7 @@ class User {
$sql = 'SELECT
id AS right_id,
name AS right_name
FROM `right`
FROM rights
ORDER BY id ASC';
$query = $this->db->prepare($sql);
$query->execute();
@ -290,27 +270,27 @@ class User {
/**
* Retrieves the rights assigned to a specific user.
*
* @param int $userId The user ID.
* @param int $user_id The user ID.
*
* @return array List of user rights.
*/
public function getUserRights($userId) {
public function getUserRights($user_id) {
$sql = 'SELECT
u.id AS user_id,
r.id AS right_id,
r.name AS right_name
FROM
`user` u
LEFT JOIN `user_right` ur
users u
LEFT JOIN users_rights ur
ON u.id = ur.user_id
LEFT JOIN `right` r
LEFT JOIN rights r
ON ur.right_id = r.id
WHERE
u.id = :user_id';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':user_id' => $user_id,
]);
$result = $query->fetchAll(PDO::FETCH_ASSOC);
@ -319,7 +299,7 @@ class User {
$specialEntries = [];
// user 1 is always superuser
if ($userId == 1) {
if ($user_id == 1) {
$specialEntries = [
[
'user_id' => 1,
@ -329,7 +309,7 @@ class User {
];
// user 2 is always demo
} elseif ($userId == 2) {
} elseif ($user_id == 2) {
$specialEntries = [
[
'user_id' => 2,
@ -353,17 +333,17 @@ class User {
/**
* Check if the user has a specific right.
*
* @param int $userId The user ID.
* @param string $right_name The human-readable name of the user right.
* @param int $user_id The user ID.
* @param string $right_name The human-readable name of the user right.
*
* @return bool True if the user has the right, false otherwise.
*/
function hasRight($userId, $right_name) {
$userRights = $this->getUserRights($userId);
function hasRight($user_id, $right_name) {
$userRights = $this->getUserRights($user_id);
$userHasRight = false;
// superuser always has all the rights
if ($userId === 1) {
if ($user_id === 1) {
$userHasRight = true;
}
@ -382,8 +362,8 @@ class User {
/**
* Updates a user's metadata in the database.
*
* @param int $userId The ID of the user to update.
* @param array $updatedUser An associative array containing updated user data:
* @param int $user_id The ID of the user to update.
* @param array $updatedUser An associative array containing updated user data:
* - 'name' (string): The updated name of the user.
* - 'email' (string): The updated email of the user.
* - 'timezone' (string): The updated timezone of the user.
@ -391,9 +371,9 @@ class User {
*
* @return bool|string Returns true if the update is successful, or an error message if an exception occurs.
*/
public function editUser($userId, $updatedUser) {
public function editUser($user_id, $updatedUser) {
try {
$sql = 'UPDATE user_meta SET
$sql = 'UPDATE users_meta SET
name = :name,
email = :email,
timezone = :timezone,
@ -401,7 +381,7 @@ class User {
WHERE user_id = :user_id';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':user_id' => $user_id,
':name' => $updatedUser['name'],
':email' => $updatedUser['email'],
':timezone' => $updatedUser['timezone'],
@ -420,20 +400,20 @@ class User {
/**
* Removes a user's avatar from the database and deletes the associated file.
*
* @param int $userId The ID of the user whose avatar is being removed.
* @param string $old_avatar Optional. The file path of the current avatar to delete. Default is an empty string.
* @param int $user_id The ID of the user whose avatar is being removed.
* @param string $old_avatar Optional. The file path of the current avatar to delete. Default is an empty string.
*
* @return bool|string Returns true if the avatar is successfully removed, or an error message if an exception occurs.
*/
public function removeAvatar($userId, $old_avatar = '') {
public function removeAvatar($user_id, $old_avatar = '') {
try {
// remove from database
$sql = 'UPDATE user_meta SET
$sql = 'UPDATE users_meta SET
avatar = NULL
WHERE user_id = :user_id';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':user_id' => $user_id,
]);
// delete the old avatar file
@ -453,14 +433,14 @@ class User {
/**
* Updates a user's avatar by uploading a new file and saving its path in the database.
*
* @param int $userId The ID of the user whose avatar is being updated.
* @param array $avatar_file The uploaded avatar file from the $_FILES array.
* Should include 'tmp_name', 'name', 'error', etc.
* @param string $avatars_path The directory path where avatar files should be saved.
* @param int $user_id The ID of the user whose avatar is being updated.
* @param array $avatar_file The uploaded avatar file from the $_FILES array.
* Should include 'tmp_name', 'name', 'error', etc.
* @param string $avatars_path The directory path where avatar files should be saved.
*
* @return bool|string Returns true if the avatar is successfully updated, or an error message if an exception occurs.
*/
public function changeAvatar($userId, $avatar_file, $avatars_path) {
public function changeAvatar($user_id, $avatar_file, $avatars_path) {
try {
// check if the file was uploaded
if (isset($avatar_file) && $avatar_file['error'] === UPLOAD_ERR_OK) {
@ -473,77 +453,37 @@ class User {
$newFileName = md5(time() . $fileName) . '.' . $fileExtension;
$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
if (move_uploaded_file($fileTmpPath, $dest_path)) {
try {
// update user's avatar path in DB
$sql = 'UPDATE user_meta SET
$sql = 'UPDATE users_meta SET
avatar = :avatar
WHERE user_id = :user_id';
$query = $this->db->prepare($sql);
$query->execute([
':avatar' => $newFileName,
':user_id' => $userId
':user_id' => $user_id
]);
// all went OK
$_SESSION['notice'] = 'Avatar updated successfully. ';
$_SESSION['notice'] .= 'Avatar updated successfully. ';
return true;
} catch (Exception $e) {
$_SESSION['error'] .= 'Database error updating avatar. ';
return $e->getMessage();
}
} else {
$_SESSION['error'] = 'Error moving the uploaded file. Please check directory permissions. ';
$_SESSION['error'] .= 'Error moving the uploaded file. ';
}
} else {
$_SESSION['error'] = 'Invalid avatar file type. Only JPG, PNG, and JPEG are allowed. ';
$_SESSION['error'] .= 'Invalid avatar file type. ';
}
} else {
// 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;
}
$_SESSION['error'] .= 'Error uploading the avatar file. ';
}
} catch (Exception $e) {
$_SESSION['error'] = 'An error occurred while processing the avatar: ' . $e->getMessage();
return $e->getMessage();
}
return false;
}
/**
@ -553,7 +493,7 @@ class User {
*/
public function getUsers() {
$sql = "SELECT id, username
FROM `user`
FROM users
ORDER BY username ASC";
$stmt = $this->db->prepare($sql);
@ -565,9 +505,9 @@ class User {
/**
* Enable two-factor authentication for a user
*
* @param int $userId User ID
* @param string $secret Secret key to use
* @param string $code Verification code to validate
* @param int $userId User ID
* @param string $secret Secret key to use
* @param string $code Verification code to validate
* @return bool True if enabled successfully
*/
public function enableTwoFactor($userId, $secret = null, $code = null) {
@ -587,8 +527,8 @@ class User {
/**
* Verify a two-factor authentication code
*
* @param int $userId User ID
* @param string $code The verification code
* @param int $userId User ID
* @param string $code The verification code
* @return bool True if verified
*/
public function verifyTwoFactor($userId, $code) {
@ -608,15 +548,15 @@ class User {
/**
* Change a user's password
*
* @param int $userId User ID
* @param string $currentPassword Current password for verification
* @param string $newPassword New password to set
* @param int $userId User ID
* @param string $currentPassword Current password for verification
* @param string $newPassword New password to set
* @return bool True if password was changed successfully
*/
public function changePassword($userId, $currentPassword, $newPassword) {
try {
// First verify the current password
$sql = "SELECT password FROM user WHERE id = :user_id";
$sql = "SELECT password FROM users WHERE id = :user_id";
$query = $this->db->prepare($sql);
$query->execute([':user_id' => $userId]);
$user = $query->fetch(PDO::FETCH_ASSOC);
@ -629,7 +569,7 @@ class User {
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
// Update the password
$sql = "UPDATE user SET password = :password WHERE id = :user_id";
$sql = "UPDATE users SET password = :password WHERE id = :user_id";
$query = $this->db->prepare($sql);
return $query->execute([
':password' => $hashedPassword,

View File

@ -21,25 +21,9 @@ class Validator {
$value = $this->data[$field] ?? null;
switch ($rule) {
// case for required fields that can be empty strings
case 'required':
if ($parameter && empty($value)) {
$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");
}
}
$this->addError($field, "Field is required");
}
break;
case 'email':
@ -108,10 +92,6 @@ class Validator {
$this->errors[$field][] = $message;
}
private function formatFieldLabel($field) {
return ucfirst(str_replace('_', ' ', $field));
}
public function getErrors() {
return $this->errors;
}

View File

@ -10,18 +10,8 @@ return [
'domain' => 'localhost',
// subfolder for the web app, if any
'folder' => '/jilo-web/',
// site name used in emails and in the interface
// site name used in emails and in the inteerface
'site_name' => 'Jilo Web',
// session configuration
'session' => [
// session name, if empty a random one will be generated
'name' => 'jilo',
// 2 hours (7200) default, when "remember me" is not checked
'lifetime' => '7200',
// 30 days (2592000) default, when "remember me" is checked
'remember_me_lifetime' => '2592000',
],
// set to false to disable new registrations
'registration_enabled' => true,
// will be displayed on login screen
@ -32,26 +22,18 @@ return [
//*******************************************
// database
'db_type' => 'mariadb',
'sqlite' => [
'db' => [
// DB type for the web app, currently only "sqlite" is used
'db_type' => 'sqlite',
// default is ../app/jilo-web.db
'sqlite_file' => '../app/jilo-web.db',
],
'sql' => [
'sql_host' => 'localhost',
'sql_port' => '3306',
'sql_database' => 'jilo',
'sql_username' => 'jilouser',
'sql_password' => 'jilopass',
],
// avatars path
'avatars_path' => 'uploads/avatars/',
// default avatar
'default_avatar' => 'static/default_avatar.png',
// system info
'version' => '0.4.1',
'version' => '0.4',
// development has verbose error messages, production has not
'environment' => 'development',

View File

@ -1,42 +0,0 @@
<?php
/**
* Theme Configuration
*
* This file is auto-generated. Do not edit it manually.
* Use the theme management interface to modify theme settings.
*/
return [
// Active theme (can be overridden by user preference)
'active_theme' => 'default',
// Available themes with their display names
'available_themes' => [
'default' => 'Default built-in theme',
'modern' => 'Modern theme',
'retro' => 'Alternative retro theme'
],
// Path configurations
'paths' => [
// Base directory for all external themes
'themes' => __DIR__ . '/../../themes',
// Default templates location (built-in fallback)
'templates' => __DIR__ . '/../templates',
// Public assets directory (built-in fallback)
'public' => __DIR__ . '/../../public_html'
],
// Theme configuration defaults
'default_config' => [
'name' => 'Unnamed Theme',
'description' => 'A Jilo Web theme',
'version' => '1.0.0',
'author' => 'Lindeas Inc.',
'screenshot' => 'screenshot.png',
'options' => []
]
];

View File

@ -1,41 +0,0 @@
<?php
namespace App\Core;
class ConfigLoader
{
/**
* @var string|null
*/
private static $configPath = null;
/**
* Load configuration array from a set of possible file locations.
*
* @param string[] $locations
* @return array
*/
public static function loadConfig(array $locations): array
{
$configFile = null;
foreach ($locations as $location) {
if (file_exists($location)) {
$configFile = $location;
break;
}
}
if (!$configFile) {
die('Config file not found');
}
self::$configPath = $configFile;
return require $configFile;
}
/**
* @return string|null
*/
public static function getConfigPath(): ?string
{
return self::$configPath;
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Core;
use Exception;
use Feedback;
class DatabaseConnector
{
/**
* Connect to the database using given configuration and handle errors.
*
* @param array $config
* @return mixed Database connection
*/
public static function connect(array $config)
{
// Load DB classes
require_once __DIR__ . '/../classes/database.php';
require_once __DIR__ . '/../includes/database.php';
try {
$db = connectDB($config);
if (!$db) {
throw new Exception('Could not connect to database');
}
return $db;
} catch (Exception $e) {
// Show error and exit
Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to the database.', $e->getMessage()));
include __DIR__ . '/../templates/page-header.php';
include_once __DIR__ . '/../helpers/feedback.php';
include __DIR__ . '/../templates/page-footer.php';
exit();
}
}
}

View File

@ -1,53 +0,0 @@
<?php
namespace App\Core;
class HookDispatcher
{
/**
* Stores all registered hooks and their callbacks.
* @var array<string, array<callable>>
*/
private static array $hooks = [];
/**
* Register a callback for a given hook.
*/
public static function register(string $hook, callable $callback): void
{
if (!isset(self::$hooks[$hook])) {
self::$hooks[$hook] = [];
}
self::$hooks[$hook][] = $callback;
}
/**
* Dispatch all callbacks for the specified hook.
*/
public static function dispatch(string $hook, array $context = []): void
{
if (!empty(self::$hooks[$hook])) {
foreach (self::$hooks[$hook] as $callback) {
call_user_func($callback, $context);
}
}
}
/**
* Apply filters for a hook key, passing a value through all callbacks.
* Each callback should accept the value and return a modified value.
*
* @param string $hook
* @param mixed $value
* @return mixed
*/
public static function applyFilters(string $hook, $value)
{
if (!empty(self::$hooks[$hook])) {
foreach (self::$hooks[$hook] as $callback) {
$value = call_user_func($callback, $value);
}
}
return $value;
}
}

View File

@ -1,50 +0,0 @@
<?php
namespace App\Core;
require_once __DIR__ . '/Settings.php';
class LogThrottler
{
/**
* Log a message no more than once per interval.
*
* @param object $logger Logger implementing log($level, $message, array $context)
* @param mixed $db PDO or DatabaseConnector for Settings
* @param string $key Unique key for throttling (e.g. migrations_pending)
* @param int $intervalSeconds Minimum seconds between logs
* @param string $level Log level
* @param string $message Log message
* @param array $context Log context
*/
public static function logThrottled($logger, $db, string $key, int $intervalSeconds, string $level, string $message, array $context = []): void
{
if (!is_object($logger) || !method_exists($logger, 'log')) {
return;
}
$settings = null;
$shouldLog = true;
$settingsKey = 'log_throttle_' . $key;
try {
$settings = new Settings($db);
$lastLogged = $settings->get($settingsKey);
if ($lastLogged) {
$lastTimestamp = strtotime($lastLogged);
if ($lastTimestamp !== false && (time() - $lastTimestamp) < $intervalSeconds) {
$shouldLog = false;
}
}
} catch (\Throwable $e) {
$settings = null;
}
if ($shouldLog) {
$logger->log($level, $message, $context);
if ($settings) {
$settings->set($settingsKey, date('Y-m-d H:i:s'));
}
}
}
}

View File

@ -1,89 +0,0 @@
<?php
namespace App\Core;
class Maintenance
{
// Keep it simple: store the flag within the app directory
public const FLAG_PATH = __DIR__ . '/../../app/.maintenance.flag';
public static function isEnabled(): bool
{
if (getenv('JILO_MAINTENANCE') === '1') {
return true;
}
// Prefer DB settings if available in the current request
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
return $settings->get('maintenance_enabled', '0') === '1';
} catch (\Throwable $e) {
// fall back to file flag
}
}
return file_exists(self::FLAG_PATH);
}
public static function enable(string $message = ''): bool
{
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
$ok1 = $settings->set('maintenance_enabled', '1');
$ok2 = $settings->set('maintenance_message', $message);
return $ok1 && $ok2;
} catch (\Throwable $e) {
// fall back to file flag
}
}
$dir = dirname(self::FLAG_PATH);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$content = $message !== '' ? $message : 'Site is under maintenance';
return file_put_contents(self::FLAG_PATH, $content) !== false;
}
public static function disable(): bool
{
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
$ok1 = $settings->set('maintenance_enabled', '0');
// keep last message for reference, optional to clear
return $ok1;
} catch (\Throwable $e) {
// fall back to file flag
}
}
if (file_exists(self::FLAG_PATH)) {
return unlink(self::FLAG_PATH);
}
return true;
}
public static function getMessage(): string
{
if (!self::isEnabled()) {
return '';
}
$envMsg = getenv('JILO_MAINTENANCE_MESSAGE');
if ($envMsg) {
return trim($envMsg);
}
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
return (string)$settings->get('maintenance_message', '');
} catch (\Throwable $e) {
// ignore and fall back to file flag
}
}
$msg = @file_get_contents(self::FLAG_PATH);
return is_string($msg) ? trim($msg) : '';
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Core;
class MiddlewarePipeline {
/** @var callable[] */
private $middlewares = [];
/**
* Add a middleware to the pipeline.
* @param callable $middleware Should return false to halt execution.
*/
public function add(callable $middleware): void {
$this->middlewares[] = $middleware;
}
/**
* Execute all middlewares in sequence.
* @return bool False if any middleware returns false, true otherwise.
*/
public function run(): bool {
foreach ($this->middlewares as $middleware) {
$result = call_user_func($middleware);
if ($result === false) {
return false;
}
}
return true;
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Core;
use Exception;
class MigrationException extends Exception
{
private string $migration;
public function __construct(string $migration, string $message, ?Exception $previous = null)
{
$this->migration = $migration;
parent::__construct($message, 0, $previous);
}
public function getMigration(): string
{
return $this->migration;
}
}

View File

@ -1,356 +0,0 @@
<?php
namespace App\Core;
require_once __DIR__ . '/NullLogger.php';
require_once __DIR__ . '/MigrationException.php';
use PDO;
use Exception;
class MigrationRunner
{
private PDO $pdo;
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 string $migrationsDir Directory containing .sql migrations
*/
public function __construct($db, string $migrationsDir)
{
// Normalize to PDO
if ($db instanceof PDO) {
$this->pdo = $db;
} elseif (is_object($db) && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
if (!$pdo instanceof PDO) {
throw new Exception('Database wrapper did not return a PDO instance');
}
$this->pdo = $pdo;
} else {
$type = is_object($db) ? get_class($db) : gettype($db);
throw new Exception("Unsupported database type: {$type}");
}
$this->migrationsDir = rtrim($migrationsDir, '/');
if (!is_dir($this->migrationsDir)) {
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
}
$this->driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$this->isSqlite = ($this->driver === 'sqlite');
$this->ensureMigrationsTable();
$this->ensureMigrationColumns();
$this->initializeLogger();
}
private function ensureMigrationsTable(): void
{
if ($this->isSqlite) {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL,
content_hash TEXT NULL,
content TEXT NULL
)";
} else {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL,
content_hash CHAR(64) NULL,
content LONGTEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$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
{
$files = glob($this->migrationsDir . '/*.sql');
sort($files, SORT_NATURAL);
return array_map('basename', $files);
}
public function listAppliedMigrations(): array
{
$stmt = $this->pdo->query('SELECT migration FROM migrations ORDER BY migration ASC');
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
public function listPendingMigrations(): array
{
$all = $this->listAllMigrations();
$applied = $this->listAppliedMigrations();
$pending = array_values(array_diff($all, $applied));
return $this->sortMigrations($pending);
}
public function hasPendingMigrations(): bool
{
return count($this->listPendingMigrations()) > 0;
}
public function applyPendingMigrations(): array
{
return $this->runMigrations($this->listPendingMigrations());
}
public function applyNextMigration(): array
{
$pending = $this->listPendingMigrations();
if (empty($pending)) {
return [];
}
return $this->runMigrations([reset($pending)]);
}
public function applyMigrationByName(string $migration): array
{
$pending = $this->listPendingMigrations();
if (!in_array($migration, $pending, true)) {
return [];
}
return $this->runMigrations([$migration]);
}
private function runMigrations(array $migrations): array
{
$appliedNow = [];
if (empty($migrations)) {
return $appliedNow;
}
$this->lastResults = [];
try {
$this->pdo->beginTransaction();
foreach ($migrations as $migration) {
try {
$path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path);
if ($sql === false) {
throw new Exception("Unable to read migration file: {$migration}");
}
$trimmedSql = trim($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) {
if ($stmtSql === '') {
continue;
}
$this->pdo->exec($stmtSql);
}
$statementCount = count($statements);
$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;
$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();
}
} 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) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->logger->log('error', 'Migration run failed: ' . $e->getMessage(), ['scope' => 'system']);
throw $e;
}
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

@ -1,17 +0,0 @@
<?php
namespace App\Core;
/**
* NullLogger is a fallback for disabling logging when there is no logging plugin enabled.
*/
class NullLogger
{
/**
* PSR-3 compatible log stub.
* @param string $level
* @param string $message
* @param array $context
*/
public function log(string $level, string $message, array $context = []): void {}
}

View File

@ -1,192 +0,0 @@
<?php
namespace App\Core;
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.
* Enforces declared dependencies before bootstrapping each plugin.
*
* @param string $pluginsDir
* @return array<string, array{path: string, meta: array}>
*/
public static function load(string $pluginsDir): array
{
self::$catalog = self::scanCatalog($pluginsDir);
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';
if (!file_exists($manifest)) {
continue;
}
$meta = json_decode(file_get_contents($manifest), true);
if (!is_array($meta)) {
$meta = [];
}
$name = basename($pluginPath);
$catalog[$name] = [
'path' => $pluginPath,
'meta' => $meta,
];
}
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)) {
include_once $bootstrap;
}
self::$loaded[$plugin] = self::$catalog[$plugin];
return true;
}
/**
* 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

@ -1,57 +0,0 @@
<?php
namespace App\Core;
use Session;
use Feedback;
class Router {
/**
* Check session validity and handle redirection for protected pages.
* Returns current username if session is valid, null otherwise.
*/
public static function checkAuth(array $config, string $app_root, array $public_pages, string $page): ?string {
// Always allow login page to be accessed
if ($page === 'login') {
return null;
}
// Check if this is a public page
$isPublicPage = in_array($page, $public_pages, true);
// For public pages, don't validate session
if ($isPublicPage) {
return null;
}
// For protected pages, check if we have a valid session
$validSession = Session::isValidSession(true);
// If session is valid, return the username
if ($validSession) {
return Session::getUsername();
}
// If we get here, we need to redirect to login
// Only show timeout message if we had an active session before
if (isset($_SESSION['LAST_ACTIVITY']) && !isset($_SESSION['session_timeout_shown'])) {
Feedback::flash('LOGIN', 'SESSION_TIMEOUT');
$_SESSION['session_timeout_shown'] = true;
}
// Preserve flash messages
$flash_messages = $_SESSION['flash_messages'] ?? [];
Session::cleanup($config);
$_SESSION['flash_messages'] = $flash_messages;
// Build login URL with redirect if appropriate
$loginUrl = $app_root . '?page=login';
$trimmed = trim($page, '/?');
if (!empty($trimmed) && !in_array($trimmed, INVALID_REDIRECT_PAGES, true)) {
$loginUrl .= '&redirect=' . urlencode($_SERVER['REQUEST_URI']);
}
header('Location: ' . $loginUrl);
exit();
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Core;
use PDO;
use Exception;
class Settings
{
private PDO $pdo;
public function __construct($db)
{
if ($db instanceof PDO) {
$this->pdo = $db;
} elseif (is_object($db) && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
if (!$pdo instanceof PDO) {
throw new Exception('Settings: database wrapper did not return PDO');
}
$this->pdo = $pdo;
} else {
$type = is_object($db) ? get_class($db) : gettype($db);
throw new Exception("Settings: unsupported database type: {$type}");
}
$this->ensureTable();
}
private function ensureTable(): void
{
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$sql = "CREATE TABLE IF NOT EXISTS settings (\n `key` TEXT PRIMARY KEY,\n `value` TEXT,\n `updated_at` TEXT NOT NULL\n )";
} else {
$sql = "CREATE TABLE IF NOT EXISTS settings (\n `key` VARCHAR(191) NOT NULL PRIMARY KEY,\n `value` TEXT NULL,\n `updated_at` DATETIME NOT NULL\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$this->pdo->exec($sql);
}
public function get(string $key, $default = null)
{
$stmt = $this->pdo->prepare('SELECT `value` FROM settings WHERE `key` = :k');
$stmt->execute([':k' => $key]);
$val = $stmt->fetchColumn();
if ($val === false) return $default;
return $val;
}
public function set(string $key, $value): bool
{
$stmt = $this->pdo->prepare('REPLACE INTO settings (`key`, `value`, `updated_at`) VALUES (:k, :v, NOW())');
return (bool)$stmt->execute([':k' => $key, ':v' => $value]);
}
}

View File

@ -1,93 +0,0 @@
<?php
/**
* Email Template Helper
*
* Provides functions to render email templates with variable substitution
*/
/**
* Render email template with variables
*
* @param string $templateName Template filename (without extension)
* @param array $variables Variables to substitute
* @param array $options Additional options
* @return string Rendered template content
*/
function renderEmailTemplate($templateName, $variables = [], array $options = []) {
$searchPaths = [];
// Explicit plugin template path takes priority
if (!empty($options['plugin_template'])) {
$searchPaths[] = rtrim((string)$options['plugin_template'], DIRECTORY_SEPARATOR);
}
// Plugin name maps to its templates directory (if registered)
if (!empty($options['plugin'])) {
$pluginKey = (string)$options['plugin'];
$pluginInfo = $GLOBALS['enabled_plugins'][$pluginKey] ?? null;
if (!empty($pluginInfo['path'])) {
$pluginBase = rtrim($pluginInfo['path'], DIRECTORY_SEPARATOR);
// We search for email templates in the following locations:
// we can add more locations if needed, but "views/emails" is the standard location
$searchPaths[] = $pluginBase . '/views/emails';
$searchPaths[] = $pluginBase . '/views/email';
}
}
// Fallback to core app templates
$searchPaths[] = __DIR__ . '/../templates/emails';
$templateFile = null;
foreach ($searchPaths as $basePath) {
$candidate = rtrim($basePath, DIRECTORY_SEPARATOR) . '/' . $templateName . '.txt';
if (is_file($candidate)) {
$templateFile = $candidate;
break;
}
}
if ($templateFile === null) {
throw new RuntimeException("Email template '$templateName' not found in any configured template paths");
}
$content = file_get_contents($templateFile);
// Replace {{variable}} placeholders
foreach ($variables as $key => $value) {
$content = str_replace('{{' . $key . '}}', $value, $content);
}
return $content;
}
/**
* Send email using template
*
* @param string $to Recipient email
* @param string $subject Email subject
* @param string $templateName Template name
* @param array $variables Template variables
* @param array $config Application config
* @param array $additionalHeaders Additional email headers
* @param array $options Additional options
* @return bool Success status
*/
function sendTemplateEmail($to, $subject, $templateName, $variables, $config, $additionalHeaders = [], array $options = []) {
try {
$message = renderEmailTemplate($templateName, $variables, $options);
$fromDomain = $config['domain'] ?? ($_SERVER['HTTP_HOST'] ?? 'totalmeet.local');
$headers = array_merge([
'From: noreply@' . $fromDomain,
'X-Mailer: PHP/' . phpversion(),
'Content-Type: text/plain; charset=UTF-8'
], $additionalHeaders);
return mail($to, $subject, $message, implode("\r\n", $headers));
} catch (Exception $e) {
error_log("Failed to send template email: " . $e->getMessage());
return false;
}
}

View File

@ -8,7 +8,6 @@
// Get any flash messages from previous request
$flash_messages = Feedback::getFlash();
if (!empty($flash_messages)) {
$system_messages = array_merge($system_messages ?? [], array_map(function($flash) {
return [
@ -16,8 +15,7 @@ if (!empty($flash_messages)) {
'key' => $flash['key'],
'custom_message' => $flash['custom_message'] ?? null,
'dismissible' => $flash['dismissible'] ?? false,
'small' => $flash['small'] ?? false,
'sanitize' => $flash['sanitize'] ?? true
'small' => $flash['small'] ?? false
];
}, $flash_messages));
}
@ -30,8 +28,7 @@ if (isset($system_messages) && is_array($system_messages)) {
$msg['key'],
$msg['custom_message'] ?? null,
$msg['dismissible'] ?? false,
$msg['small'] ?? false,
$msg['sanitize'] ?? true
$msg['small'] ?? false
);
}
}

View File

@ -1,24 +0,0 @@
<?php
/**
* Returns the user's IP address.
* Uses global $user_IP set by Logger plugin if available, else falls back to server variables.
*
* @return string
*/
function getUserIP() {
global $user_IP;
if (!empty($user_IP)) {
return $user_IP;
}
// Fallback to HTTP headers or REMOTE_ADDR
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// May contain multiple IPs
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? '';
}

View File

@ -1,37 +0,0 @@
<?php
/**
* Returns a logger instance: plugin Log if available, otherwise NullLogger.
*
* @param mixed $database Database or DatabaseConnector instance.
* @return mixed Logger instance with PSR-3 log() compatible method.
*/
function getLoggerInstance($database) {
if (class_exists('Log')) {
return new Log($database);
}
require_once __DIR__ . '/../core/NullLogger.php';
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

@ -0,0 +1,20 @@
<?php
function getUserIP() {
// get directly the user IP
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
// if user is behind some proxy
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
// get only the first IP if there are more
if (strpos($ip, ',') !== false) {
$ip = explode(',', $ip)[0];
}
return trim($ip);
}

View File

@ -1,96 +1,81 @@
<div class="text-center">
<div class="pagination">
<?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 = '';
// calls
if (isset($_REQUEST['id'])) {
$param .= '&id=' . htmlspecialchars($_REQUEST['id']);
}
if (isset($_REQUEST['name'])) {
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
}
if (isset($_REQUEST['invitees'])) {
$param .= '&invitees=' . htmlspecialchars($_REQUEST['invitees']);
if (isset($_REQUEST['ip'])) {
$param .= '&ip=' . htmlspecialchars($_REQUEST['ip']);
}
if (isset($_REQUEST['description'])) {
$param .= '&description=' . htmlspecialchars($_REQUEST['description']);
if (isset($_REQUEST['event'])) {
$param .= '&event=' . htmlspecialchars($_REQUEST['event']);
}
if (isset($_REQUEST['filter'])) {
$param .= '&filter=' . htmlspecialchars($_REQUEST['filter']);
}
// contacts
if (isset($_REQUEST['name'])) {
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
}
if (isset($_REQUEST['phone'])) {
$param .= '&phone=' . htmlspecialchars($_REQUEST['phone']);
}
if (isset($_REQUEST['email'])) {
$param .= '&email=' . htmlspecialchars($_REQUEST['email']);
}
// messages
if (isset($_REQUEST['from'])) {
$param .= '&from=' . htmlspecialchars($_REQUEST['from']);
}
if (isset($_REQUEST['to'])) {
$param .= '&to=' . htmlspecialchars($_REQUEST['to']);
}
if (isset($_REQUEST['subject'])) {
$param .= '&subject=' . htmlspecialchars($_REQUEST['subject']);
}
// notifications
if (isset($_REQUEST['message'])) {
$param .= '&message=' . htmlspecialchars($_REQUEST['message']);
}
// time period
if (isset($_REQUEST['from_time'])) {
$param .= '&from_time=' . htmlspecialchars($_REQUEST['from_time']);
$param .= '&from_time=' . htmlspecialchars($from_time);
}
if (isset($_REQUEST['until_time'])) {
$param .= '&until_time=' . htmlspecialchars($_REQUEST['until_time']);
$param .= '&until_time=' . htmlspecialchars($until_time);
}
$max_visible_pages = 10;
$step_pages = 10;
echo '<div class="tm-pagination text-center"><div class="pagination">';
if ($browse_page > 1) {
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>';
echo '<span><a href="' . htmlspecialchars($url) . '&p=1">first</a></span>';
} else {
echo '<span class="pagination-link disabled">first</span>';
echo '<span class="pagination-link disabled">&laquo;</span>';
echo '<span>first</span>';
}
for ($i = 1; $i <= $page_count; $i++) {
// always show the first, last, step pages (10, 20, 30, etc.),
// and pages around current page
if ($i == 1 || $i == $page_count ||
$i % $step_pages == 0 ||
abs($i - $browse_page) < $max_visible_pages / 2) {
// and the pages close to the current one
if (
$i === 1 || // first page
$i === $page_count || // last page
$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 ($i == $browse_page) {
echo '<span class="pagination-link active">' . $i . '</span>';
if ($browse_page < $page_count) {
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) +1) . '">>></a></span>';
} else {
echo '<span>>></span>';
}
} else {
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $i . $param . '">' . $i . '</a>';
// other pages
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . htmlspecialchars($i) . '">[' . htmlspecialchars($i) . ']</a></span>';
}
} elseif ($i == 2 || $i == $page_count - 1 ||
($i > $browse_page + $max_visible_pages / 2 && $i % $step_pages == 1) ||
($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) {
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>';
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($page_count)) . '">last</a></span>';
} else {
echo '<span class="pagination-link disabled">&raquo;</span>';
echo '<span class="pagination-link disabled">last</span>';
echo '<span>last</span>';
}
echo '</div></div>';
}
?>
</div>
</div>

View File

@ -1,109 +0,0 @@
<?php
/**
* Theme Asset handler
*
* Serves theme assets (images, CSS, JS, etc.) securely by checking if the requested
* theme and asset path are valid and accessible.
*
* This is a standalone handler that doesn't require the full application initialization.
*/
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', '1');
// Define base path if not defined
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
// Basic security checks
if (!isset($_GET['theme']) || !preg_match('/^[a-zA-Z0-9_-]+$/', $_GET['theme'])) {
http_response_code(400);
exit('Invalid theme specified');
}
if (!isset($_GET['path']) || empty($_GET['path'])) {
http_response_code(400);
exit('No asset path specified');
}
$themeId = $_GET['theme'];
$assetPath = $_GET['path'];
// Validate asset path (only alphanumeric, hyphen, underscore, dot, and forward slash)
if (!preg_match('/^[a-zA-Z0-9_\-\.\/]+$/', $assetPath)) {
http_response_code(400);
exit('Invalid asset path');
}
// Prevent directory traversal
if (strpos($assetPath, '..') !== false) {
http_response_code(400);
exit('Invalid asset path');
}
// Build full path to the asset
$themesDir = dirname(dirname(__DIR__)) . '/themes';
$fullPath = realpath("$themesDir/$themeId/$assetPath");
// Additional security check to ensure the path is within the themes directory
if ($fullPath === false) {
http_response_code(404);
header('Content-Type: text/plain');
error_log("Asset not found: $themesDir/$themeId/$assetPath");
exit("Asset not found: $themesDir/$themeId/$assetPath");
}
if (strpos($fullPath, realpath($themesDir)) !== 0) {
http_response_code(400);
header('Content-Type: text/plain');
error_log("Security violation: Attempted to access path outside themes directory: $fullPath");
exit('Invalid asset path');
}
// Check if the file exists and is readable
if (!file_exists($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
header('Content-Type: text/plain');
error_log("File not found or not readable: $fullPath");
exit("File not found or not readable: " . basename($fullPath));
}
// Clear any previous output
if (ob_get_level()) {
ob_clean();
}
// Determine content type based on file extension
$extension = strtolower(pathinfo($assetPath, 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',
];
$contentType = $contentTypes[$extension] ?? 'application/octet-stream';
// Set proper headers
header('Content-Type: ' . $contentType);
header('Content-Length: ' . filesize($fullPath));
// Cache for 24 hours (86400 seconds)
$expires = 86400;
header('Cache-Control: public, max-age=' . $expires);
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT');
header('Pragma: cache');
// Output the file
readfile($fullPath);

View File

@ -1,498 +0,0 @@
<?php
/**
* Theme Helper
*
* Handles theme management and template/asset loading for the application.
* Supports multiple themes with fallback to default theme when needed.
* The default theme uses app/templates and public_html/static as fallbacks/
*/
namespace App\Helpers;
use Exception;
// Include Session class
require_once __DIR__ . '/../classes/session.php';
use Session;
class Theme
{
/**
* @var array Theme configuration
*/
private static $config;
/**
* Get the theme configuration
*
* @return array
*/
public static function getConfig()
{
$configFile = __DIR__ . '/../config/theme.php';
// Create default config if it doesn't exist
if (!file_exists($configFile)) {
$configDir = dirname($configFile);
if (!is_dir($configDir)) {
mkdir($configDir, 0755, true);
}
// Generate the config file with proper formatting
$configContent = <<<'EOT'
<?php
/**
* Theme Configuration
*
* This file is auto-generated. Do not edit it manually.
* Use the theme management interface to modify theme settings.
*/
return [
// Active theme (can be overridden by user preference)
'active_theme' => 'modern',
// Available themes with their display names
'available_themes' => [
'default' => 'Default built-in theme',
'modern' => 'Modern theme',
'retro' => 'Alternative retro theme'
],
// Path configurations
'paths' => [
// Base directory for all external themes
'themes' => __DIR__ . '/../../themes',
// Default templates location (built-in fallback)
'templates' => __DIR__ . '/../templates',
// Public assets directory (built-in fallback)
'public' => __DIR__ . '/../../public_html'
],
// Theme configuration defaults
'default_config' => [
'name' => 'Unnamed Theme',
'description' => 'A Jilo Web theme',
'version' => '1.0.0',
'author' => 'Lindeas Inc.',
'screenshot' => 'screenshot.png',
'options' => []
]
];
EOT;
file_put_contents($configFile, $configContent);
}
// Load the configuration
self::$config = require $configFile;
return self::$config;
}
/**
* @var string Current theme name
*/
private static $currentTheme;
/**
* Initialize the theme system
*/
public static function init()
{
// Only load config if not already loaded
if (self::$config === null) {
try {
self::getConfig(); // This will create default config if needed
} catch (Exception $e) {
error_log('Failed to load theme configuration: ' . $e->getMessage());
// Fallback to default configuration
self::$config = [
'active_theme' => 'modern',
'available_themes' => [
'modern' => ['name' => 'Modern'],
'retro' => ['name' => 'Retro']
]
];
}
}
self::$currentTheme = self::getCurrentThemeName();
}
/**
* Get the current theme name
*
* @return string
*/
public static function getCurrentThemeName()
{
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
Session::startSession();
}
// Check if already determined
if (self::$currentTheme !== null) {
return self::$currentTheme;
}
// Try to get from session first
$sessionTheme = isset($_SESSION['theme']) ? $_SESSION['theme'] : null;
if ($sessionTheme && isset(self::$config['available_themes'][$sessionTheme])) {
self::$currentTheme = $sessionTheme;
} else {
// Attempt to load per-user theme from DB if user is logged in and userObject is available
if (Session::isValidSession() && isset($_SESSION['user_id']) && isset($GLOBALS['userObject']) && is_object($GLOBALS['userObject']) && method_exists($GLOBALS['userObject'], 'getUserTheme')) {
try {
$dbTheme = $GLOBALS['userObject']->getUserTheme((int)$_SESSION['user_id']);
if ($dbTheme && isset(self::$config['available_themes'][$dbTheme]) && self::themeExists($dbTheme)) {
// Set session and current theme to the user's stored preference
$_SESSION['theme'] = $dbTheme;
self::$currentTheme = $dbTheme;
}
} catch (\Throwable $e) {
// Ignore and continue to default fallback
}
}
// Fall back to default theme if still not determined
if (self::$currentTheme === null) {
self::$currentTheme = self::$config['active_theme'];
}
}
return self::$currentTheme;
}
/**
* Get the URL for a theme asset
*
* @param string $themeId Theme ID
* @param string $assetPath Path to the asset relative to theme directory (e.g., 'css/style.css')
* @return string|null URL to the asset or null if not found
*/
public static function getAssetUrl($themeId, $assetPath = '')
{
// Clean and validate the asset path
$assetPath = ltrim($assetPath, '/');
if (empty($assetPath)) {
return null;
}
// Only allow alphanumeric, hyphen, underscore, dot, and forward slash
if (!preg_match('/^[a-zA-Z0-9_\-\.\/]+$/', $assetPath)) {
return null;
}
// Prevent directory traversal
if (strpos($assetPath, '..') !== false) {
return null;
}
$fullPath = __DIR__ . "/../../themes/$themeId/$assetPath";
if (!file_exists($fullPath) || !is_readable($fullPath)) {
return null;
}
// Generate URL that goes through index.php
global $app_root;
// Remove any trailing slash from app_root to avoid double slashes
$baseUrl = rtrim($app_root, '/');
return "$baseUrl/?page=theme-asset&theme=" . urlencode($themeId) . "&path=" . urlencode($assetPath);
}
/**
* Set the current theme for the session
*
* @param string $themeName
* @return bool
*/
public static function setCurrentTheme(string $themeName, bool $persist = true): bool
{
if (!self::themeExists($themeName)) {
return false;
}
// Update session
if (Session::isValidSession()) {
$_SESSION['theme'] = $themeName;
} else {
return false;
}
// Clear the current theme cache
self::$currentTheme = null;
// Persist per-user preference in DB when available and requested
if ($persist && Session::isValidSession() && isset($_SESSION['user_id'])) {
// Try to use existing user object if available
if (isset($GLOBALS['userObject']) && is_object($GLOBALS['userObject']) && method_exists($GLOBALS['userObject'], 'setUserTheme')) {
try {
$GLOBALS['userObject']->setUserTheme((int)$_SESSION['user_id'], $themeName);
} catch (\Throwable $e) {
// Non-fatal: keep session theme even if DB save fails
error_log('Failed to persist user theme: ' . $e->getMessage());
}
}
}
self::$currentTheme = $themeName;
return true;
}
/**
* Check if a theme exists
*
* @param string $themeName
* @return bool
*/
public static function themeExists(string $themeName): bool
{
// Default theme always exists as it uses core templates
if ($themeName === 'default') {
return true;
}
$themePath = self::getThemePath($themeName);
return is_dir($themePath) && file_exists("$themePath/config.php");
}
/**
* Get the path to a theme
*
* @param string|null $themeName
* @return string
*/
public static function getThemePath(?string $themeName = null): string
{
$themeName = $themeName ?? self::getCurrentThemeName();
$config = self::getConfig();
return rtrim($config['paths']['themes'], '/') . "/$themeName";
}
/**
* 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
*
* @param string $path
* @param bool $includeVersion
* @return string
*/
public static function asset($path, $includeVersion = false)
{
$themeName = self::getCurrentThemeName();
$config = self::getConfig();
$baseUrl = rtrim($GLOBALS['app_root'] ?? '', '/');
// For non-default themes, use theme assets
if ($themeName !== 'default') {
$assetPath = "/themes/{$themeName}/assets/" . ltrim($path, '/');
// Add version query string for cache busting
if ($includeVersion) {
$version = self::getThemeVersion($themeName);
$assetPath .= (strpos($assetPath, '?') !== false ? '&' : '?') . 'v=' . $version;
}
} else {
// For default theme, use public_html directly
$assetPath = '/' . ltrim($path, '/');
}
return $baseUrl . $assetPath;
}
/**
* Include a theme template file
*
* @param string $template Template name without .php extension
* @return void
*/
public static function include($template)
{
global $config;
$config = $config ?? [];
$themeConfig = self::getConfig();
$themeName = self::getCurrentThemeName();
// We need this here, otherwise because this helper
// between index and the views breaks the session vars
extract($GLOBALS, EXTR_SKIP | EXTR_REFS);
// Ensure config is always available in templates
$config = array_merge($config, $themeConfig);
// For non-default themes, look in the theme directory first
if ($themeName !== 'default') {
$themePath = $config['paths']['themes'] . '/' . $themeName . '/views/' . $template . '.php';
if (file_exists($themePath)) {
include $themePath;
return;
}
}
// Fallback to default template location
$defaultPath = $config['paths']['templates'] . '/' . $template . '.php';
if (file_exists($defaultPath)) {
include $defaultPath;
return;
}
// Log error if template not found
error_log("Template not found: {$template} in theme: {$themeName}");
}
/**
* Get all available themes
*
* @return array
*/
public static function getAvailableThemes(): array
{
$config = self::getConfig();
$availableThemes = $config['available_themes'] ?? [];
$themes = [];
// Add default theme if not already present
if (!isset($availableThemes['default'])) {
$availableThemes['default'] = 'Default built-in theme';
}
// Verify each theme exists and has a config file
$themesDir = $config['paths']['themes'] ?? (__DIR__ . '/../../themes');
foreach ($availableThemes as $id => $name) {
if ($id === 'default' || (is_dir("$themesDir/$id") && file_exists("$themesDir/$id/config.php"))) {
$themes[$id] = $name;
}
}
return $themes;
}
}
// Initialize the theme system
Theme::init();

View File

@ -1,5 +0,0 @@
<?php
// Pages that should not be used as redirect targets
const INVALID_REDIRECT_PAGES = [
'', 'login', 'logout', 'register', 'dashboard', '/'
];

View File

@ -1,9 +1,10 @@
<?php
require_once __DIR__ . '/../helpers/security.php';
require_once __DIR__ . '/../helpers/logs.php';
function applyCsrfMiddleware() {
global $logObject, $user_IP;
global $logObject;
$security = SecurityHelper::getInstance();
// Skip CSRF check for GET requests
@ -33,14 +34,14 @@ function applyCsrfMiddleware() {
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!$security->verifyCsrfToken($token)) {
// Log CSRF attempt
$ipAddress = $user_IP;
$ipAddress = getUserIP();
$logMessage = sprintf(
"CSRF attempt detected - IP: %s, Page: %s, User: %s",
$ipAddress,
$_GET['page'] ?? 'unknown',
$_SESSION['username'] ?? 'anonymous'
);
$logObject->log('error', $logMessage, ['user_id' => null, 'scope' => 'system']);
$logObject->insertLog(null, $logMessage, 'system');
// Return error message
http_response_code(403);

View File

@ -1,63 +1,61 @@
<?php
// connect to database
function connectDB($config) {
// sqlite database file
if ($config['db_type'] === 'sqlite') {
function connectDB($config, $database = '', $dbFile = '', $platformId = '') {
// connecting ti a jilo sqlite database
if ($database === 'jilo') {
try {
$dbFile = $config['sqlite']['sqlite_file'] ?? null;
if (!$dbFile || !file_exists($dbFile)) {
throw new Exception(getError("Database file \"{$dbFile}\"not found."));
throw new Exception(getError("Invalid platform ID \"{$platformId}\", database file \"{$dbFile}\" not found."));
}
$db = new Database([
'type' => $config['db_type'],
'type' => 'sqlite',
'dbFile' => $dbFile,
]);
$pdo = $db->getConnection();
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to DB.', $e->getMessage()));
return false;
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
return $db;
// mysql/mariadb database
} elseif ($config['db_type'] === 'mysql' || $config['db_type'] === 'mariadb') {
$db = new Database([
'type' => $config['db_type'],
'host' => $config['sql']['sql_host'] ?? 'localhost',
'port' => $config['sql']['sql_port'] ?? '3306',
'dbname' => $config['sql']['sql_database'],
'user' => $config['sql']['sql_username'],
'password' => $config['sql']['sql_password'],
]);
try {
$pdo = $db->getConnection();
} catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to DB.', $e->getMessage()));
return false;
}
return $db;
// unknown database
// connecting to a jilo-web database of the web app
} else {
Feedback::flash('ERROR', 'DEFAULT', getError("Error: unknown database type \"{$config['db_type']}\""));
return false;
}
}
// connect to Jilo database
function connectJiloDB($config, $dbFile = '', $platformId = '') {
try {
if (!$dbFile || !file_exists($dbFile)) {
throw new Exception(getError("Invalid platform ID \"{$platformId}\", database file \"{$dbFile}\" not found."));
// sqlite database file
if ($config['db']['db_type'] === 'sqlite') {
try {
$db = new Database([
'type' => $config['db']['db_type'],
'dbFile' => $config['db']['sqlite_file'],
]);
$pdo = $db->getConnection();
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
// mysql/mariadb database
} elseif ($config['db']['db_type'] === 'mysql' || $config['db']['db_type'] === 'mariadb') {
try {
$db = new Database([
'type' => $config['db']['db_type'],
'host' => $config['db']['sql_host'] ?? 'localhost',
'port' => $config['db']['sql_port'] ?? '3306',
'dbname' => $config['db']['sql_database'],
'user' => $config['db']['sql_username'],
'password' => $config['db']['sql_password'],
]);
$pdo = $db->getConnection();
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
// unknown database
} else {
$error = "Error: unknow database type \"{$config['db']['db_type']}\"";
Feedback::flash('ERROR', 'DEFAULT', $error);
exit();
}
$db = new Database([
'type' => 'sqlite',
'dbFile' => $dbFile,
]);
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
}

View File

@ -1,6 +1,7 @@
<?php
require_once __DIR__ . '/../classes/ratelimiter.php';
require_once __DIR__ . '/../helpers/logs.php';
/**
* Rate limit middleware for page requests
@ -12,10 +13,10 @@ require_once __DIR__ . '/../classes/ratelimiter.php';
* @return bool True if request is allowed, false if rate limited
*/
function checkRateLimit($database, $endpoint, $userId = null, $existingRateLimiter = null) {
global $app_root, $user_IP;
global $app_root;
$isTest = defined('PHPUNIT_RUNNING');
$rateLimiter = $existingRateLimiter ?? new RateLimiter($database);
$ipAddress = $user_IP;
$ipAddress = getUserIP();
// Check if request is allowed
if (!$rateLimiter->isPageRequestAllowed($ipAddress, $endpoint, $userId)) {

View File

@ -0,0 +1,97 @@
<?php
/**
* Session Middleware
*
* Validates session status and handles session timeout.
* This middleware should be included in all protected pages.
*/
function applySessionMiddleware($config, $app_root) {
$isTest = defined('PHPUNIT_RUNNING');
// Access $_SESSION directly in test mode
if (!$isTest) {
// Start session if not already started
if (session_status() !== PHP_SESSION_ACTIVE && !headers_sent()) {
session_start([
'cookie_httponly' => 1,
'cookie_secure' => 1,
'cookie_samesite' => 'Strict',
'gc_maxlifetime' => 7200 // 2 hours
]);
}
}
// Check if user is logged in with all required session variables
if (!isset($_SESSION['user_id']) || !isset($_SESSION['username'])) {
cleanupSession($config, $app_root, $isTest);
return false;
}
// Check session timeout
$session_timeout = isset($_SESSION['REMEMBER_ME']) ? (30 * 24 * 60 * 60) : 7200; // 30 days or 2 hours
if (isset($_SESSION['LAST_ACTIVITY']) && (time() - $_SESSION['LAST_ACTIVITY'] > $session_timeout)) {
// Session has expired
cleanupSession($config, $app_root, $isTest);
return false;
}
// Update last activity time
$_SESSION['LAST_ACTIVITY'] = time();
// Regenerate session ID periodically (every 30 minutes)
if (!isset($_SESSION['CREATED'])) {
$_SESSION['CREATED'] = time();
} else if (time() - $_SESSION['CREATED'] > 1800) {
// Regenerate session ID and update creation time
if (!$isTest && !headers_sent() && session_status() === PHP_SESSION_ACTIVE) {
$oldData = $_SESSION;
session_regenerate_id(true);
$_SESSION = $oldData;
$_SESSION['CREATED'] = time();
}
}
return true;
}
/**
* Helper function to clean up session data and redirect
*/
function cleanupSession($config, $app_root, $isTest) {
// Always clear session data
$_SESSION = array();
if (!$isTest) {
if (session_status() === PHP_SESSION_ACTIVE) {
session_unset();
session_destroy();
// Start a new session to prevent errors
if (!headers_sent()) {
session_start([
'cookie_httponly' => 1,
'cookie_secure' => 1,
'cookie_samesite' => 'Strict',
'gc_maxlifetime' => 7200
]);
}
}
// Clear cookies
if (!headers_sent()) {
setcookie('username', '', [
'expires' => time() - 3600,
'path' => $config['folder'],
'domain' => $config['domain'],
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
}
header('Location: ' . $app_root . '?page=login&timeout=1');
exit();
}
}

View File

@ -11,16 +11,10 @@ return [
'LOGIN_SUCCESS' => 'Login successful.',
'LOGIN_FAILED' => 'Login failed. Please check your credentials.',
'LOGOUT_SUCCESS' => 'Logout successful. You can log in again.',
'SESSION_TIMEOUT' => 'Your session has expired. Please log in again.',
'IP_BLACKLISTED' => 'Access denied. Your IP address is blacklisted.',
'IP_NOT_WHITELISTED' => 'Access denied. Your IP address is not whitelisted.',
'TOO_MANY_ATTEMPTS' => 'Too many login attempts. Please try again later.',
],
'REGISTER' => [
'SUCCESS' => 'Registration successful. You can log in now.',
'FAILED' => 'Registration failed: %s',
'DISABLED' => 'Registration is disabled.',
],
'SECURITY' => [
'WHITELIST_ADD_SUCCESS' => 'IP address successfully added to whitelist.',
'WHITELIST_ADD_FAILED' => 'Failed to add IP to whitelist.',
@ -36,15 +30,14 @@ return [
'PERMISSION_DENIED' => 'Permission denied. You do not have the required rights.',
'IP_REQUIRED' => 'IP address is required.',
],
'THEME' => [
'THEME_CHANGE_SUCCESS' => 'Theme has been changed successfully.',
'THEME_CHANGE_FAILED' => 'Failed to change theme. The selected theme may not be available.',
'REGISTER' => [
'SUCCESS' => 'Registration successful. You can log in now.',
'FAILED' => 'Registration failed: %s',
'DISABLED' => 'Registration is disabled.',
],
'SYSTEM' => [
'DB_ERROR' => 'Error connecting to the database: %s',
'DB_CONNECT_ERROR' => 'Error connecting to DB: %s',
'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"',
'MIGRATIONS_PENDING' => '%s',
'MAINTENANCE_ON' => 'Maintenance mode is enabled. Regular users see a maintenance page.',
],
];

View File

@ -1,270 +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 === 'migrate_apply_one') {
require_once __DIR__ . '/../core/MigrationRunner.php';
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
$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];
}
} 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 = [];
$next_pending = null;
$migration_contents = [];
$test_migrations_exist = false;
$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']);
}
$migration_records = [];
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;
// 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);
$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) {
// show error in the page
$migration_error = $e->getMessage();
}
// CSRF token
$csrf_token = $security->generateCsrfToken();
// Load the template
include __DIR__ . '/../templates/admin-tools.php';

View File

@ -1,461 +0,0 @@
<?php
/*
* Admin control center
*
* Provides maintenance/migration tooling and exposes hook placeholders
* so plugins can contribute additional sections, actions, and metrics.
*/
require_once __DIR__ . '/../core/Maintenance.php';
require_once __DIR__ . '/../core/MigrationRunner.php';
require_once __DIR__ . '/../core/PluginManager.php';
require_once '../app/helpers/security.php';
include_once '../app/helpers/feedback.php';
$security = SecurityHelper::getInstance();
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Check if the user has admin permissions
$canAdmin = false;
if (isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
$canAdmin = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser');
}
if (!$canAdmin) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
header('Location: ' . $app_root);
exit;
}
$postAction = $_POST['action'] ?? '';
$queryAction = $_GET['action'] ?? '';
$action = $postAction ?: $queryAction;
$targetId = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : null;
$sectionRegistry = [
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
'maintenance' => ['label' => 'Maintenance', 'position' => 200, 'hook' => null, 'type' => 'core'],
'migrations' => ['label' => 'Migrations', 'position' => 300, 'hook' => null, 'type' => 'core'],
'plugins' => ['label' => 'Plugins', 'position' => 400, 'hook' => null, 'type' => 'core'],
];
// Register sections for plugins
$registerSection = static function(array $section) use (&$sectionRegistry): void {
$key = strtolower(trim($section['key'] ?? ''));
$label = trim((string)($section['label'] ?? ''));
if ($key === '' || $label === '') {
return;
}
$position = (int)($section['position'] ?? 900);
$sectionRegistry[$key] = [
'label' => $label,
'position' => $position,
'hook' => $section['hook'] ?? ('admin.' . $key . '.render'),
'type' => $section['type'] ?? 'plugin',
];
};
// Hooks sections for plugins
do_hook('admin.sections.register', [
'register' => $registerSection,
'app_root' => $app_root,
'sections' => &$sectionRegistry,
]);
uasort($sectionRegistry, static function(array $a, array $b): int {
if ($a['position'] === $b['position']) {
return strcmp($a['label'], $b['label']);
}
return $a['position'] <=> $b['position'];
});
if (empty($sectionRegistry)) {
$sectionRegistry = [
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
];
}
$validSections = array_keys($sectionRegistry);
$buildAdminUrl = static function(string $section = 'overview') use ($app_root, &$sectionRegistry): string {
if (!isset($sectionRegistry[$section])) {
$section = array_key_first($sectionRegistry) ?? 'overview';
}
$suffix = $section !== 'overview' ? ('&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

@ -19,8 +19,8 @@ $agentId = filter_input(INPUT_GET, 'agent', FILTER_VALIDATE_INT);
require '../app/classes/agent.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
$agentObject = new Agent($dbWeb);
$hostObject = new Host($dbWeb);
/**
* Get the cache key for an agent
@ -49,8 +49,8 @@ function isCacheExpired($agentId) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Apply rate limiting for adding new contacts
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'contact', $userId);
require '../app/includes/rate_limit_middleware.php';
checkRateLimit($dbWeb, 'contact', $user_id);
// Validate agent ID for POST operations
if ($agentId === false || $agentId === null) {
@ -167,7 +167,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/agents.php';

View File

@ -9,7 +9,7 @@
*/
// connect to database
$response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here
if ($response['db'] === null) {
@ -101,7 +101,7 @@ if ($response['db'] === null) {
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// display the widget
include '../app/templates/components.php';

View File

@ -9,7 +9,7 @@
*/
// connect to database
$response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here
if ($response['db'] === null) {
@ -160,7 +160,7 @@ if ($response['db'] === null) {
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// display the widget
include '../app/templates/conferences.php';

View File

@ -7,13 +7,14 @@
*/
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
require '../app/classes/config.php';
require '../app/classes/api_response.php';
// Initialize required objects
$userObject = new User($db);
$userObject = new User($dbWeb);
$logObject = new Log($dbWeb);
$configObject = new Config();
// For AJAX requests
@ -50,8 +51,8 @@ if (!$isWritable) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check if user has permission to edit config
if (!$userObject->hasRight($userId, 'edit config file')) {
$logObject->log('error', "Unauthorized: User \"$currentUser\" tried to edit config file. IP: $user_IP", ['user_id' => $userId, 'scope' => 'system']);
if (!$userObject->hasRight($user_id, 'edit config file')) {
$logObject->insertLog($user_id, "Unauthorized: User \"$currentUser\" tried to edit config file. IP: $user_IP", 'system');
if ($isAjax) {
ApiResponse::error('Forbidden: You do not have permission to edit the config file', null, 403);
exit;
@ -62,8 +63,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
// Apply rate limiting
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'config', $userId);
require '../app/includes/rate_limit_middleware.php';
checkRateLimit($dbWeb, 'config', $user_id);
// Ensure no output before this point
ob_clean();
@ -73,7 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Get raw input
$jsonData = file_get_contents('php://input');
if ($jsonData === false) {
$logObject->log('error', "Failed to read request data for config update", ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog($user_id, "Failed to read request data for config update", 'system');
ApiResponse::error('Failed to read request data');
exit;
}
@ -114,11 +115,10 @@ if (!$isAjax) {
* Handles GET requests to display templates.
*/
if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file')) {
if ($userObject->hasRight($user_id, 'view config file')) {
include '../app/templates/config.php';
} else {
$logObject->log('error', "Unauthorized: User \"$currentUser\" tried to access \"config\" page. IP: $user_IP", ['user_id' => $userId, 'scope' => 'system']);
$logObject->insertLog($user_id, "Unauthorized: User \"$currentUser\" tried to access \"config\" page. IP: $user_IP", 'system');
include '../app/templates/error-unauthorized.php';
}
}

View File

@ -14,20 +14,24 @@
* - `password`: Change password
*/
// Initialize user object
$userObject = new User($db);
// Check if user is logged in
if (!isset($_SESSION['user_id'])) {
header("Location: $app_root?page=login");
exit();
}
$user_id = $_SESSION['user_id'];
// Initialize user object
$userObject = new User($dbWeb);
// Get action and item from request
$action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? '';
// if a form is submitted
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Ensure security helper is available
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Validate CSRF token
$security->verifyCsrfToken($_POST['csrf_token'] ?? '');
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.');
header("Location: $app_root?page=credentials");
@ -36,7 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Apply rate limiting
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'credentials', $userId);
checkRateLimit($dbWeb, 'credentials', $user_id);
switch ($item) {
case '2fa':
@ -46,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$code = $_POST['code'] ?? '';
$secret = $_POST['secret'] ?? '';
if ($userObject->enableTwoFactor($userId, $secret, $code)) {
if ($userObject->enableTwoFactor($user_id, $secret, $code)) {
Feedback::flash('NOTICE', 'DEFAULT', 'Two-factor authentication has been enabled successfully.');
header("Location: $app_root?page=credentials");
exit();
@ -63,7 +67,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
case 'verify':
// This is a user-initiated verification
$code = $_POST['code'] ?? '';
if ($userObject->verifyTwoFactor($userId, $code)) {
if ($userObject->verifyTwoFactor($user_id, $code)) {
$_SESSION['2fa_verified'] = true;
header("Location: $app_root?page=dashboard");
exit();
@ -75,7 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
break;
case 'disable':
if ($userObject->disableTwoFactor($userId)) {
if ($userObject->disableTwoFactor($user_id)) {
Feedback::flash('NOTICE', 'DEFAULT', 'Two-factor authentication has been disabled.');
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable two-factor authentication.');
@ -92,7 +96,8 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$validator = new Validator($_POST);
$rules = [
'current_password' => [
'required' => true
'required' => true,
'min' => 8
],
'new_password' => [
'required' => true,
@ -110,7 +115,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
exit();
}
if ($userObject->changePassword($userId, $_POST['current_password'], $_POST['new_password'])) {
if ($userObject->changePassword($user_id, $_POST['current_password'], $_POST['new_password'])) {
Feedback::flash('NOTICE', 'DEFAULT', 'Password has been changed successfully.');
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to change password. Please verify your current password.');
@ -131,12 +136,12 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$security->generateCsrfToken();
// Get 2FA status for the template
$has2fa = $userObject->isTwoFactorEnabled($userId);
$has2fa = $userObject->isTwoFactorEnabled($user_id);
switch ($action) {
case 'setup':
if (!$has2fa) {
$result = $userObject->enableTwoFactor($userId);
$result = $userObject->enableTwoFactor($user_id);
if ($result['success']) {
$setupData = $result['data'];
} else {
@ -146,7 +151,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
}
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the 2FA setup template
include '../app/templates/credentials-2fa-setup.php';
@ -154,7 +159,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
case 'verify':
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php';
@ -162,7 +167,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
default:
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the combined management template
include '../app/templates/credentials-manage.php';

View File

@ -10,13 +10,13 @@
*/
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
require '../app/classes/conference.php';
require '../app/classes/participant.php';
// connect to database
$response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here
if ($response['db'] === null) {
@ -92,7 +92,7 @@ if ($response['db'] === null) {
// display the widget
include '../app/templates/dashboard-monthly.php';
include '../app/templates/widget-monthly.php';
/**
@ -154,7 +154,7 @@ if ($response['db'] === null) {
$widget['pagination'] = false;
// display the widget
include '../app/templates/dashboard-conferences.php';
include '../app/templates/widget.php';
/**
@ -224,6 +224,6 @@ if ($response['db'] === null) {
}
// display the widget
include '../app/templates/dashboard-conferences.php';
include '../app/templates/widget.php';
}

View File

@ -7,11 +7,11 @@ require '../app/classes/agent.php';
require '../app/classes/conference.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
$agentObject = new Agent($dbWeb);
$hostObject = new Host($dbWeb);
// Connect to Jilo database for log data
$response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
if ($response['db'] === null) {
Feedback::flash('ERROR', 'DEFAULT', $response['error']);
} else {
@ -85,7 +85,7 @@ $widget['name'] = 'Graphs';
$widget['title'] = 'Jitsi graphs';
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/graphs.php';

View File

@ -1,6 +1,6 @@
<?php
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
include '../app/templates/help.php';

View File

@ -3,8 +3,8 @@
require '../app/classes/agent.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
$agentObject = new Agent($dbWeb);
$hostObject = new Host($dbWeb);
// Define metrics to display
$metrics = [
@ -100,7 +100,7 @@ foreach ($hosts as $host) {
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/latest.php';

View File

@ -10,7 +10,7 @@ $settingsObject = new Settings();
$livejsData = $settingsObject->getPlatformJsFile($platformDetails[0]['jitsi_url'], $item, $raw);
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/livejs.php';

View File

@ -19,13 +19,13 @@ unset($error);
try {
// connect to database
$db = connectDB($config);
$db = connectDB($config)['db'];
// Initialize RateLimiter
require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($db);
// Get user IP
require_once '../app/helpers/ip_helper.php';
$user_IP = getUserIP();
$action = $_REQUEST['action'] ?? '';
@ -33,23 +33,21 @@ try {
if ($action === 'verify' && isset($_SESSION['2fa_pending_user_id'])) {
// Handle 2FA verification
$code = $_POST['code'] ?? '';
$pending2FA = Session::get2FAPending();
if (!$pending2FA) {
header('Location: ' . htmlspecialchars($app_root) . '?page=login');
exit();
}
$userId = $_SESSION['2fa_pending_user_id'];
$username = $_SESSION['2fa_pending_username'];
$rememberMe = isset($_SESSION['2fa_pending_remember']);
require_once '../app/classes/twoFactorAuth.php';
$twoFactorAuth = new TwoFactorAuthentication($db);
if ($twoFactorAuth->verify($pending2FA['user_id'], $code)) {
if ($twoFactorAuth->verify($userId, $code)) {
// Complete login
handleSuccessfulLogin($pending2FA['user_id'], $pending2FA['username'],
$pending2FA['remember_me'], $config, $app_root, $logObject, $user_IP);
handleSuccessfulLogin($userId, $username, $rememberMe, $config, $logObject, $user_IP);
// Clean up 2FA session data
Session::clear2FAPending();
unset($_SESSION['2fa_pending_user_id']);
unset($_SESSION['2fa_pending_username']);
unset($_SESSION['2fa_pending_remember']);
exit();
}
@ -60,10 +58,7 @@ try {
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Make userId available to template
$userId = $pending2FA['user_id'];
include '../app/helpers/feedback.php';
// Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php';
@ -97,7 +92,7 @@ try {
// Process reset request
require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db, $config);
$resetHandler = new PasswordReset($db);
$result = $resetHandler->requestReset($email);
// Always show same message whether email exists or not for security
@ -115,7 +110,7 @@ try {
$security->generateCsrfToken();
// Load the forgot password form
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
include '../app/templates/form-password-forgot.php';
exit();
@ -123,7 +118,7 @@ try {
// Handle password reset
try {
require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db, $config);
$resetHandler = new PasswordReset($db);
$token = $_GET['token'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@ -175,7 +170,7 @@ try {
}
// Show reset password form
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
include '../app/templates/form-password-reset.php';
exit();
@ -201,7 +196,8 @@ try {
],
'password' => [
'type' => 'string',
'required' => true
'required' => true,
'min' => 5
]
];
@ -224,6 +220,9 @@ try {
if ($rateLimiter->tooManyAttempts($username, $user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
}
// Record this attempt
$rateLimiter->attempt($username, $user_IP);
}
// Attempt login
@ -233,8 +232,11 @@ try {
switch ($loginResult['status']) {
case 'requires_2fa':
// Store pending 2FA info
Session::store2FAPending($loginResult['user_id'], $loginResult['username'],
isset($formData['remember_me']));
$_SESSION['2fa_pending_user_id'] = $loginResult['user_id'];
$_SESSION['2fa_pending_username'] = $loginResult['username'];
if (isset($formData['remember_me'])) {
$_SESSION['2fa_pending_remember'] = true;
}
// Redirect to 2FA verification
header('Location: ?page=login&action=verify');
@ -243,7 +245,7 @@ try {
case 'success':
// Complete login
handleSuccessfulLogin($loginResult['user_id'], $loginResult['username'],
isset($formData['remember_me']), $config, $app_root, $logObject, $user_IP);
isset($formData['remember_me']), $config, $logObject, $user_IP);
exit();
default:
@ -256,9 +258,8 @@ try {
// Log the failed attempt
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
if (isset($username)) {
$userId = $userObject->getUserId($username)[0]['id'] ?? 0;
$logObject->log('error', "Login: Failed login attempt for user \"$username\". IP: $user_IP. Reason: {$e->getMessage()}", ['user_id' => $userId, 'scope' => 'user']);
$rateLimiter->attempt($username, $user_IP);
$user_id = $userObject->getUserId($username)[0]['id'] ?? 0;
$logObject->insertLog($user_id, "Login: Failed login attempt for user \"$username\". IP: $user_IP. Reason: {$e->getMessage()}", 'user');
}
}
}
@ -268,11 +269,11 @@ try {
// Show configured login message if any
if (!empty($config['login_message'])) {
echo Feedback::render('NOTICE', 'DEFAULT', $config['login_message'], false, false, false);
echo Feedback::render('NOTICE', 'DEFAULT', $config['login_message'], false);
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/form-login.php';
@ -280,26 +281,42 @@ include '../app/templates/form-login.php';
/**
* Handle successful login by setting up session and cookies
*/
function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $app_root, $logObject, $userIP) {
// Create authenticated session
Session::createAuthSession($userId, $username, $rememberMe, $config);
function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $logObject, $userIP) {
if ($rememberMe) {
// 30*24*60*60 = 30 days
$cookie_lifetime = 30 * 24 * 60 * 60;
$setcookie_lifetime = time() + 30 * 24 * 60 * 60;
} else {
// 0 - session end on browser close
$cookie_lifetime = 0;
$setcookie_lifetime = 0;
}
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
// set session lifetime and cookies
setcookie('username', $username, [
'expires' => $setcookie_lifetime,
'path' => $config['folder'],
'domain' => $config['domain'],
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
// Set session variables
$_SESSION['user_id'] = $userId;
$_SESSION['USERNAME'] = $username;
$_SESSION['LAST_ACTIVITY'] = time();
if ($rememberMe) {
$_SESSION['REMEMBER_ME'] = true;
}
// Log successful login
$logObject->log('info', "Login: User \"$username\" logged in. IP: $userIP", ['user_id' => $userId, 'scope' => 'user']);
$logObject->insertLog($userId, "Login: User \"$username\" logged in. IP: $userIP", 'user');
// Set success message
// Set success message and redirect
Feedback::flash('LOGIN', 'LOGIN_SUCCESS');
// After successful login, redirect to original page if provided in URL param or POST
$redirect = $app_root;
$candidate = $_POST['redirect'] ?? $_GET['redirect'] ?? '';
$trimmed = trim($candidate, '/?');
if (
(strpos($candidate, '/') === 0 || strpos($candidate, '?') === 0)
&& !in_array($trimmed, INVALID_REDIRECT_PAGES, true)
) {
$redirect = $candidate;
}
header('Location: ' . $redirect);
exit();
header('Location: ' . htmlspecialchars($app_root));
}

View File

@ -8,17 +8,12 @@
* It supports pagination and filtering.
*/
// Define plugin base path if not already defined
if (!defined('PLUGIN_LOGS_PATH')) {
define('PLUGIN_LOGS_PATH', dirname(__FILE__, 2) . '/');
}
require_once PLUGIN_LOGS_PATH . 'models/Log.php';
require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php';
require_once dirname(__FILE__, 4) . '/app/classes/user.php';
// Get any new feedback messages
include '../app/helpers/feedback.php';
// Check for rights; user or system
$has_system_access = ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view app logs'));
$has_system_access = ($userObject->hasRight($user_id, 'superuser') ||
$userObject->hasRight($user_id, 'view app logs'));
// Get current page for pagination
$currentPage = $_REQUEST['page_num'] ?? 1;
@ -74,8 +69,8 @@ if (isset($_REQUEST['tab'])) {
}
// prepare the result
$search = $logObject->readLog($userId, $scope, $offset, $items_per_page, $filters);
$search_all = $logObject->readLog($userId, $scope, 0, 0, $filters);
$search = $logObject->readLog($user_id, $scope, $offset, $items_per_page, $filters);
$search_all = $logObject->readLog($user_id, $scope, 0, 0, $filters);
if (!empty($search)) {
// we get total items and number of pages
@ -91,7 +86,6 @@ if (!empty($search)) {
$log_record = array(
// assign title to the field in the array record
'time' => $item['time'],
'log level' => $item['level'],
'log message' => $item['message']
);
} else {
@ -100,7 +94,6 @@ if (!empty($search)) {
'userID' => $item['user_id'],
'username' => $item['username'],
'time' => $item['time'],
'log level' => $item['level'],
'log message' => $item['message']
);
}
@ -110,13 +103,7 @@ if (!empty($search)) {
}
}
$username = $userObject->getUserDetails($userId)[0]['username'];
$username = $userObject->getUserDetails($user_id)[0]['username'];
// Get any new feedback messages
include_once dirname(__FILE__, 4) . '/app/helpers/feedback.php';
// Load plugin helpers
include PLUGIN_LOGS_PATH . 'helpers/logs_view_helper.php';
// Display messages list
include PLUGIN_LOGS_PATH . 'views/logs.php';
// Load the template
include '../app/templates/logs.php';

View File

@ -9,7 +9,7 @@
*/
// connect to database
$response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here
if ($response['db'] === null) {
@ -170,7 +170,7 @@ if ($response['db'] === null) {
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// display the widget
include '../app/templates/participants.php';

View File

@ -1,74 +0,0 @@
<?php
/**
* Plugin Asset handler
*
* Serves plugin assets (CSS, JS, images, fonts, etc.) securely by validating the
* requested plugin name, asset path and enabled status. This way each plugin can
* keep its assets in its local folders and only load them when needed.
*/
require_once __DIR__ . '/../classes/session.php';
$pluginName = $_GET['plugin'] ?? '';
$assetPath = $_GET['path'] ?? '';
$sendError = static function(int $code, string $message): void {
http_response_code($code);
header('Content-Type: text/plain');
exit($message);
};
if ($pluginName === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $pluginName)) {
$sendError(400, 'Invalid plugin specified');
}
if ($assetPath === '') {
$sendError(400, 'No asset path specified');
}
if (!preg_match('/^[a-zA-Z0-9_\-.\/]+$/', $assetPath) || strpos($assetPath, '..') !== false) {
$sendError(400, 'Invalid asset path');
}
if (!isset($GLOBALS['enabled_plugins'][$pluginName])) {
$sendError(404, 'Plugin not enabled');
}
$pluginsRoot = realpath(dirname(__DIR__, 2) . '/plugins');
if ($pluginsRoot === false) {
$sendError(500, 'Plugins directory missing');
}
$pluginBase = realpath($pluginsRoot . '/' . $pluginName);
if ($pluginBase === false || strpos($pluginBase, $pluginsRoot) !== 0) {
$sendError(404, 'Plugin not found');
}
$fullPath = realpath($pluginBase . '/' . ltrim($assetPath, '/'));
if ($fullPath === false || strpos($fullPath, $pluginBase) !== 0 || !is_file($fullPath) || !is_readable($fullPath)) {
$sendError(404, 'Asset not found');
}
$extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$contentTypes = [
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'woff' => 'font/woff',
'woff2'=> 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject'
];
header('Content-Type: ' . ($contentTypes[$extension] ?? 'application/octet-stream'));
header('Content-Length: ' . filesize($fullPath));
header('Cache-Control: public, max-age=86400');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT');
readfile($fullPath);

View File

@ -14,20 +14,6 @@
$action = $_REQUEST['action'] ?? '';
$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 ($_SERVER['REQUEST_METHOD'] == 'POST') {
@ -44,11 +30,11 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Apply rate limiting for profile operations
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'profile', $userId);
checkRateLimit($dbWeb, 'profile', $user_id);
// avatar removal
if ($item === 'avatar' && $action === 'remove') {
$validator = new Validator(['user_id' => $userId]);
$validator = new Validator(['user_id' => $user_id]);
$rules = [
'user_id' => [
'required' => true,
@ -62,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
exit();
}
$result = $userObject->removeAvatar($userId, $config['avatars_path'].$userDetails[0]['avatar']);
$result = $userObject->removeAvatar($user_id, $config['avatars_path'].$userDetails[0]['avatar']);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Avatar for user \"{$userDetails[0]['username']}\" is removed.");
} else {
@ -103,54 +89,50 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
'timezone' => htmlspecialchars($_POST['timezone'] ?? ''),
'bio' => htmlspecialchars($_POST['bio'] ?? ''),
];
$result = $userObject->editUser($userId, $updatedUser);
$result = $userObject->editUser($user_id, $updatedUser);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "User details for \"{$userDetails[0]['username']}\" are edited.");
Feedback::flash('NOTICE', 'DEFAULT', "User details for \"{$updatedUser['name']}\" are edited.");
} else {
Feedback::flash('ERROR', 'DEFAULT', "Editing the user details failed. Error: $result");
}
// update the rights
// Get current rights IDs
$userRightsIds = array_column($userRights, 'right_id');
if (isset($_POST['rights'])) {
$validator = new Validator(['rights' => $_POST['rights']]);
$rules = [
'rights' => [
'array' => true
]
];
// If no rights are selected, remove all rights
if (!isset($_POST['rights'])) {
$_POST['rights'] = [];
}
$validator = new Validator(['rights' => $_POST['rights']]);
$rules = [
'rights' => [
'array' => true
]
];
if (!$validator->validate($rules)) {
Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
header("Location: $app_root?page=profile");
exit();
}
$newRights = $_POST['rights'];
// what rights we need to add
$rightsToAdd = array_diff($newRights, $userRightsIds);
if (!empty($rightsToAdd)) {
foreach ($rightsToAdd as $rightId) {
$userObject->addUserRight($userId, $rightId);
if (!$validator->validate($rules)) {
Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
header("Location: $app_root?page=profile");
exit();
}
}
// what rights we need to remove
$rightsToRemove = array_diff($userRightsIds, $newRights);
if (!empty($rightsToRemove)) {
foreach ($rightsToRemove as $rightId) {
$userObject->removeUserRight($userId, $rightId);
$newRights = $_POST['rights'];
// extract the new right_ids
$userRightsIds = array_column($userRights, 'right_id');
// what rights we need to add
$rightsToAdd = array_diff($newRights, $userRightsIds);
if (!empty($rightsToAdd)) {
foreach ($rightsToAdd as $rightId) {
$userObject->addUserRight($user_id, $rightId);
}
}
// what rights we need to remove
$rightsToRemove = array_diff($userRightsIds, $newRights);
if (!empty($rightsToRemove)) {
foreach ($rightsToRemove as $rightId) {
$userObject->removeUserRight($user_id, $rightId);
}
}
}
// update the avatar
if (!empty($_FILES['avatar_file']['tmp_name'])) {
$result = $userObject->changeAvatar($userId, $_FILES['avatar_file'], $config['avatars_path']);
$result = $userObject->changeAvatar($user_id, $_FILES['avatar_file'], $config['avatars_path']);
}
header("Location: $app_root?page=profile");
@ -174,7 +156,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isTimezoneSet = !empty($userDetails[0]['timezone']);
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/profile-edit.php';
@ -182,7 +164,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
default:
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/profile.php';

View File

@ -8,31 +8,24 @@
* and redirects to the login page on success or displays an error message on failure.
*/
// Define plugin base path if not already defined
if (!defined('PLUGIN_REGISTER_PATH')) {
define('PLUGIN_REGISTER_PATH', dirname(__FILE__, 2) . '/');
}
require_once PLUGIN_REGISTER_PATH . 'models/register.php';
require_once dirname(__FILE__, 4) . '/app/classes/user.php';
require_once dirname(__FILE__, 4) . '/app/classes/validator.php';
require_once dirname(__FILE__, 4) . '/app/helpers/security.php';
// registration is allowed, go on
if ($config['registration_enabled'] == true) {
try {
global $db, $logObject, $userObject;
global $dbWeb, $logObject, $userObject;
if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) {
// Apply rate limiting
require_once dirname(__FILE__, 4) . '/app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'register');
require '../app/includes/rate_limit_middleware.php';
checkRateLimit($dbWeb, 'register');
require_once '../app/classes/validator.php';
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Sanitize input
$formData = $security->sanitizeArray($_POST, ['username', 'password', 'confirm_password', 'csrf_token', 'terms']);
$formData = $security->sanitizeArray($_POST, ['username', 'password', 'confirm_password', 'csrf_token']);
// Validate CSRF token
if (!$security->verifyCsrfToken($formData['csrf_token'] ?? '')) {
@ -54,10 +47,6 @@ if ($config['registration_enabled'] == true) {
'confirm_password' => [
'required' => true,
'matches' => 'password'
],
'terms' => [
'required' => true,
'equals' => 'on'
]
];
@ -67,42 +56,41 @@ if ($config['registration_enabled'] == true) {
$password = $formData['password'];
// registering
$register = new Register($db);
$result = $register->register($username, $password);
$result = $userObject->register($username, $password);
// redirect to login
if ($result === true) {
// Get the new user's ID for logging
$userId = $userObject->getUserId($username)[0]['id'];
$logObject->log('info', "Registration: New user \"$username\" registered successfully. IP: $user_IP", ['user_id' => $userId, 'scope' => 'user']);
$user_id = $userObject->getUserId($username)[0]['id'];
$logObject->insertLog($user_id, "Registration: New user \"$username\" registered successfully. IP: $user_IP", 'user');
Feedback::flash('NOTICE', 'DEFAULT', "Registration successful. You can log in now.");
header('Location: ' . htmlspecialchars($app_root . '?page=login'));
header('Location: ' . htmlspecialchars($app_root));
exit();
// registration fail, redirect to login
} else {
$logObject->log('error', "Registration: Failed registration attempt for user \"$username\". IP: $user_IP. Reason: $result", ['user_id' => null, 'scope' => 'system']);
$logObject->insertLog(0, "Registration: Failed registration attempt for user \"$username\". IP: $user_IP. Reason: $result", 'system');
Feedback::flash('ERROR', 'DEFAULT', "Registration failed. $result");
header('Location: ' . htmlspecialchars($app_root . '?page=register'));
header('Location: ' . htmlspecialchars($app_root));
exit();
}
} else {
$error = $validator->getFirstError();
$logObject->log('error', "Registration: Failed validation for user \"" . ($username ?? 'unknown') . "\". IP: $user_IP. Reason: $error", ['user_id' => null, 'scope' => 'system']);
$logObject->insertLog(0, "Registration: Failed validation for user \"" . ($username ?? 'unknown') . "\". IP: $user_IP. Reason: $error", 'system');
Feedback::flash('ERROR', 'DEFAULT', $error);
header('Location: ' . htmlspecialchars($app_root . '?page=register'));
exit();
}
}
} catch (Exception $e) {
$logObject->log('error', "Registration: System error. IP: $user_IP. Error: " . $e->getMessage(), ['user_id' => null, 'scope' => 'system']);
$logObject->insertLog(0, "Registration: System error. IP: $user_IP. Error: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
}
// Get any new feedback messages
include_once dirname(__FILE__, 4) . '/app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include PLUGIN_REGISTER_PATH . 'views/form-register.php';
include '../app/templates/form-register.php';
// registration disabled
} else {

View File

@ -1,10 +1,15 @@
<?php
// Check if user has any of the required rights
if (!($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'edit whitelist') ||
$userObject->hasRight($userId, 'edit blacklist') ||
$userObject->hasRight($userId, 'edit ratelimiting'))) {
if (!($userObject->hasRight($user_id, 'superuser') ||
$userObject->hasRight($user_id, 'edit whitelist') ||
$userObject->hasRight($user_id, 'edit blacklist') ||
$userObject->hasRight($user_id, 'edit ratelimiting'))) {
include '../app/templates/error-unauthorized.php';
exit;
}
if (!isset($currentUser)) {
include '../app/templates/error-unauthorized.php';
exit;
}
@ -14,7 +19,7 @@ $section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section'
// Initialize RateLimiter
require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($db);
$rateLimiter = new RateLimiter($dbWeb);
// Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
@ -22,7 +27,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
// Apply rate limiting for security operations
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'security', $userId);
checkRateLimit($dbWeb, 'security', $user_id);
$action = $_POST['action'];
$validator = new Validator($_POST);
@ -30,7 +35,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
try {
switch ($action) {
case 'add_whitelist':
if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit whitelist')) {
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit whitelist')) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
}
@ -49,7 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if ($validator->validate($rules)) {
$is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on';
if (!$rateLimiter->addToWhitelist($_POST['ip_address'], $is_network, $_POST['description'] ?? '', $currentUser, $userId)) {
if (!$rateLimiter->addToWhitelist($_POST['ip_address'], $is_network, $_POST['description'] ?? '', $currentUser, $user_id)) {
Feedback::flash('SECURITY', 'WHITELIST_ADD_FAILED');
} else {
Feedback::flash('SECURITY', 'WHITELIST_ADD_SUCCESS');
@ -60,7 +65,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
break;
case 'remove_whitelist':
if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit whitelist')) {
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit whitelist')) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
}
@ -74,7 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
];
if ($validator->validate($rules)) {
if (!$rateLimiter->removeFromWhitelist($_POST['ip_address'], $currentUser, $userId)) {
if (!$rateLimiter->removeFromWhitelist($_POST['ip_address'], $currentUser, $user_id)) {
Feedback::flash('SECURITY', 'WHITELIST_REMOVE_FAILED');
} else {
Feedback::flash('SECURITY', 'WHITELIST_REMOVE_SUCCESS');
@ -85,7 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
break;
case 'add_blacklist':
if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit blacklist')) {
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit blacklist')) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
}
@ -111,7 +116,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
$is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on';
$expiry_hours = !empty($_POST['expiry_hours']) ? (int)$_POST['expiry_hours'] : null;
if (!$rateLimiter->addToBlacklist($_POST['ip_address'], $is_network, $_POST['reason'], $currentUser, $userId, $expiry_hours)) {
if (!$rateLimiter->addToBlacklist($_POST['ip_address'], $is_network, $_POST['reason'], $currentUser, $user_id, $expiry_hours)) {
Feedback::flash('SECURITY', 'BLACKLIST_ADD_FAILED');
} else {
Feedback::flash('SECURITY', 'BLACKLIST_ADD_SUCCESS');
@ -122,7 +127,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
break;
case 'remove_blacklist':
if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit blacklist')) {
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit blacklist')) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
}
@ -136,7 +141,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
];
if ($validator->validate($rules)) {
if (!$rateLimiter->removeFromBlacklist($_POST['ip_address'], $currentUser, $userId)) {
if (!$rateLimiter->removeFromBlacklist($_POST['ip_address'], $currentUser, $user_id)) {
Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_FAILED');
} else {
Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_SUCCESS');
@ -168,7 +173,7 @@ $whitelisted = $rateLimiter->getWhitelistedIps();
$blacklisted = $rateLimiter->getBlacklistedIps();
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/security.php';

View File

@ -12,11 +12,7 @@ $isAjax = isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Initialize security helper
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
include '../app/helpers/feedback.php';
$action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? '';
@ -25,8 +21,8 @@ $host = $_REQUEST['host'] ?? '';
require '../app/classes/host.php';
require '../app/classes/agent.php';
$hostObject = new Host($db);
$agentObject = new Agent($db);
$hostObject = new Host($dbWeb);
$agentObject = new Agent($dbWeb);
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
/**
@ -35,7 +31,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Apply rate limiting for profile operations
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'profile', $userId);
checkRateLimit($dbWeb, 'profile', $user_id);
// Get hash from URL if present
$hash = parse_url($_SERVER['REQUEST_URI'], PHP_URL_FRAGMENT) ?? '';
@ -174,7 +170,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
* Handles GET requests to display templates.
*/
if ($userObject->hasRight($userId, 'view settings') || $userObject->hasRight($userId, 'superuser')) {
if ($userObject->hasRight($user_id, 'view settings')) {
$jilo_agent_types = $agentObject->getAgentTypes();
include '../app/templates/settings.php';
} else {

View File

@ -9,12 +9,12 @@
*/
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
include '../app/helpers/feedback.php';
require '../app/classes/agent.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
$agentObject = new Agent($dbWeb);
$hostObject = new Host($dbWeb);
include '../app/templates/status-server.php';
@ -22,7 +22,7 @@ include '../app/templates/status-server.php';
foreach ($platformsAll as $platform) {
// check if we can connect to the jilo database
$response = connectJiloDB($config, $platform['jilo_database'], $platform['id']);
$response = connectDB($config, 'jilo', $platform['jilo_database'], $platform['id']);
if ($response['error'] !== null) {
$jilo_database_status = $response['error'];
} else {

View File

@ -1,10 +0,0 @@
<?php
/**
* Theme Asset handler
*
* Serves theme assets through the main application router.
* This provides a secure way to serve theme files that are outside the web root.
*/
// Include the theme asset handler
require_once __DIR__ . '/../helpers/theme-asset.php';

View File

@ -1,80 +0,0 @@
<?php
/**
* Theme Management Controller
*
* Handles theme switching and management functionality.
* Allows users to view available themes and change the active theme.
*
* Actions:
* - switch_to: Changes the active theme for the current user
*/
// Initialize security
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Only allow access to logged-in users
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Get any old feedback messages
include_once '../app/helpers/feedback.php';
// Handle theme switching
if (isset($_GET['switch_to'])) {
$themeName = $_GET['switch_to'];
// Validate CSRF token for state-changing operations
if (!$security->verifyCsrfToken($_GET['csrf_token'] ?? '')) {
Feedback::flash('SECURITY', 'CSRF_INVALID');
header("Location: $app_root?page=theme");
exit();
}
if (\App\Helpers\Theme::setCurrentTheme($themeName)) {
// Set success message
Feedback::flash('THEME', 'THEME_CHANGED');
} else {
// Set error message
Feedback::flash('THEME', 'THEME_CHANGE_FAILED');
}
// Redirect back to prevent form resubmission
$redirect = $app_root . '?page=theme';
header("Location: $redirect");
exit;
}
// Get available themes and current theme for the view
$themes = \App\Helpers\Theme::getAvailableThemes();
$currentTheme = \App\Helpers\Theme::getCurrentThemeName();
// Prepare theme data with screenshot URLs and metadata for the view
$themeData = [];
foreach ($themes as $id => $name) {
$meta = \App\Helpers\Theme::getThemeMetadata($id);
$themeData[$id] = [
'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'),
'isActive' => $id === $currentTheme
];
}
// Make theme data available to the view
$themes = $themeData;
// Generate CSRF token for the form
$csrf_token = $security->generateCsrfToken();
// Load the template
include '../app/templates/theme.php';

View File

@ -1,224 +0,0 @@
<?php
/** @var bool $maintenance_enabled */
/** @var string $maintenance_message */
/** @var array $pending */
/** @var array $applied */
/** @var string $csrf_token */
?>
<!-- admin tools page -->
<section class="tm-hero">
<div class="tm-hero-card">
<div class="tm-hero-body">
<div class="tm-hero-heading">
<h1 class="tm-hero-title">Admin tools</h1>
<p class="tm-hero-subtitle">Centralized maintenance and database utilities to keep <?= htmlspecialchars($config['site_name']) ?> healthy.</p>
</div>
<div class="tm-hero-meta">
<span class="tm-hero-pill <?= $maintenance_enabled ? 'pill-danger' : 'pill-success' ?>">
<i class="fas fa-power-off"></i>
Maintenance <?= $maintenance_enabled ? 'enabled' : 'not enabled' ?>
</span>
<span class="tm-hero-pill <?= empty($pending) ? 'pill-neutral' : 'pill-danger' ?>">
<i class="fas fa-database"></i>
<?= count($pending) ?> pending migration<?= count($pending) === 1 ? '' : 's' ?>
</span>
</div>
</div>
<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>
</section>
<section class="tm-admin">
<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 team 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">
<input type="text" id="maintenance_message" name="maintenance_message" class="tm-admin-message-input" value="<?= htmlspecialchars($maintenance_message) ?>" placeholder="Upgrading database">
<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>
<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">
</form>
</div>
</article>
<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">
<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">
<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">
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
</form>
</article>
</div>
</section>
<!-- 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>
<?php
$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;
}
?>
<div class="modal-body p-0">
<pre class="tm-admin-modal-code"><code style="border-radius: 0.5rem;"><?= htmlspecialchars($content) ?></code></pre>
</div>
<?php
$isModalNext = (!empty($next_pending) && $next_pending === $name);
$modalResult = (!empty($migration_modal_result) && ($migration_modal_result['name'] ?? '') === $name) ? $migration_modal_result : null;
?>
<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) ?>">
<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; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form.tm-confirm').forEach(function (form) {
form.addEventListener('submit', function (event) {
const message = form.getAttribute('data-confirm') || 'Are you sure?';
if (!confirm(message)) {
event.preventDefault();
}
});
});
});
</script>

View File

@ -1,586 +0,0 @@
<?php
/** @var bool $maintenance_enabled */
/** @var string $maintenance_message */
/** @var array $pending */
/** @var array $applied */
/** @var string $csrf_token */
/** @var string|null $next_pending */
/** @var array $migration_contents */
/** @var array $migration_records */
/** @var bool $test_migrations_exist */
/** @var array|null $migration_modal_result */
/** @var string|null $modal_to_open */
/** @var string|null $migration_error */
/** @var array $adminOverviewPills */
/** @var array $adminOverviewStatuses */
/** @var array $sectionState */
?>
<?php
$preselectModalId = null;
if (!empty($modal_to_open)) {
$preselectModalId = 'migrationModal' . md5($modal_to_open);
}
$tabs = $adminTabs ?? [];
if (empty($tabs)) {
$tabs = [
'overview' => [
'label' => 'Overview',
'url' => $sectionUrls['overview'] ?? ($app_root . '?page=admin'),
'type' => 'core',
'hook' => null,
'position' => 100,
],
];
}
$heroPills = [
[
'label' => 'Maintenance',
'value' => $maintenance_enabled ? 'enabled' : 'off',
'icon' => 'fas fa-power-off',
'tone' => $maintenance_enabled ? 'danger' : 'success',
],
[
'label' => 'Migrations',
'value' => count($pending) . ' pending',
'icon' => 'fas fa-database',
'tone' => empty($pending) ? 'neutral' : 'warning',
],
];
if (!empty($adminOverviewPills) && is_array($adminOverviewPills)) {
foreach ($adminOverviewPills as $pill) {
if (!is_array($pill)) {
continue;
}
$heroPills[] = [
'label' => (string)($pill['label'] ?? 'Status'),
'value' => (string)($pill['value'] ?? ''),
'icon' => (string)($pill['icon'] ?? 'fas fa-info-circle'),
'tone' => (string)($pill['tone'] ?? 'info'),
];
}
}
$statusItems = [
[
'label' => 'Maintenance mode',
'description' => $maintenance_enabled ? 'Live site shows downtime banner.' : 'Visitors see the normal experience.',
'value' => $maintenance_enabled ? 'ON' : 'OFF',
'tone' => $maintenance_enabled ? 'warning' : 'success',
],
[
'label' => 'Schema migrations',
'description' => empty($pending) ? 'Database matches code.' : 'Pending updates detected.',
'value' => count($pending) . ' pending',
'tone' => empty($pending) ? 'success' : 'warning',
],
];
if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) {
foreach ($adminOverviewStatuses as $status) {
if (!is_array($status)) {
continue;
}
$statusItems[] = [
'label' => (string)($status['label'] ?? 'Status'),
'description' => (string)($status['description'] ?? ''),
'value' => (string)($status['value'] ?? ''),
'tone' => (string)($status['tone'] ?? 'info'),
];
}
}
?>
<section class="tm-hero">
<div class="tm-hero-card tm-hero-card--admin">
<div class="tm-hero-body">
<div class="tm-hero-heading">
<h1 class="tm-hero-title">Admin control center</h1>
<p class="tm-hero-subtitle">
Centralized administration dashboard for system-wide management.
</p>
</div>
<div class="tm-hero-meta tm-hero-meta--stacked">
<?php foreach ($heroPills as $pill):
$toneClass = 'pill-' . preg_replace('/[^a-z0-9_-]/i', '', $pill['tone'] ?? 'info');
?>
<div class="tm-hero-pill <?= htmlspecialchars($toneClass) ?>">
<i class="<?= htmlspecialchars($pill['icon']) ?>"></i>
<?= htmlspecialchars($pill['label']) ?> <?= htmlspecialchars($pill['value']) ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="tm-hero-actions">
<a class="btn btn-primary tm-directory-cta" href="<?= htmlspecialchars($app_root) ?>?page=dashboard">
<i class="fas fa-arrow-left"></i> Back to dashboard
</a>
</div>
</div>
</section>
<section class="tm-admin tm-admin--dashboard">
<div class="tm-admin-tabs" role="tablist">
<?php foreach ($tabs as $sectionKey => $tabMeta):
$isActive = $activeSection === $sectionKey;
$tabUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin&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

@ -17,8 +17,7 @@
<i class="fas fa-wrench me-2 text-secondary"></i>
<?= htmlspecialchars($config['site_name']) ?> app configuration
</h5>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'edit config file')) { ?>
<?php if ($userObject->hasRight($user_id, 'edit config file')) { ?>
<div>
<button type="button" class="btn btn-outline-primary btn-sm toggle-edit" <?= !$isWritable ? 'disabled' : '' ?>>
<i class="fas fa-edit me-2"></i>Edit
@ -38,7 +37,7 @@
<div class="card-body p-4">
<form id="configForm">
<?php
include CSRF_TOKEN_INCLUDE;
include 'csrf_token.php';
function renderConfigItem($key, $value, $path = '') {
$fullPath = $path ? $path . '[' . $key . ']' : $key;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,51 @@
<!-- Two-Factor Authentication -->
<div class="card mt-4">
<div class="card-header">
<h3>Two-factor authentication</h3>
</div>
<div class="card-body">
<?php if ($has2fa): ?>
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<p class="mb-0">
<i class="fas fa-shield-alt text-success"></i>
Two-factor authentication is enabled
</p>
<small class="text-muted">
Your account is protected with an authenticator app
</small>
</div>
<form method="post" class="ml-3">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<input type="hidden" name="item" value="2fa">
<input type="hidden" name="action" value="disable">
<button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.')">
Disable 2FA
</button>
</form>
</div>
<?php else: ?>
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<p class="mb-0">
<i class="fas fa-shield-alt text-muted"></i>
Two-factor authentication is not enabled
</p>
<small class="text-muted">
Add an extra layer of security to your account by requiring both your password and an authentication code
</small>
</div>
<form method="post" class="ml-3">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<input type="hidden" name="item" value="2fa">
<input type="hidden" name="action" value="enable">
<button type="submit" class="btn btn-primary">
Enable 2FA
</button>
</form>
</div>
<?php endif; ?>
</div>
</div>

View File

@ -1,65 +0,0 @@
<section class="tm-widget-card tm-call-widget">
<div class="tm-widget-header">
<div>
<p class="tm-widget-eyebrow">Conferences</p>
<h3 class="tm-widget-title">
<?= $widget['title'] ?>
</h3>
<?php if ($time_range_specified) { ?>
<p class="m-1 mb-0" style="font-size: 0.75rem;">time period:
<strong>
<?= $from_time == '0000-01-01' ? 'beginning' : date('d M Y', strtotime($from_time)) ?> - <?= $until_time == '9999-12-31' ? 'now' : date('d M Y', strtotime($until_time)) ?>
</strong>
</p>
<?php } ?>
</div>
<div class="tm-widget-tools">
<?php if ($widget['filter'] === true) { include '../app/templates/block-results-filter.php'; } ?>
</div>
</div>
<!-- calls -->
<div class="tm-widget-body">
<div class="table-responsive">
<table class="table tm-widget-table" style="font-size: 0.75rem;">
<thead>
<tr>
<th scope="col"></th>
<?php foreach ($widget['records'] as $record) { ?>
<th scope="col"><?= htmlspecialchars($record['table_headers']) ?></th>
<?php } ?>
</tr>
</thead>
<tbody>
<?php if (!empty($widget['records'])) { ?>
<tr>
<td>conferences</td>
<?php foreach ($widget['records'] as $record) { ?>
<td><?php if (!empty($record['conferences'])) { ?>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['conferences']) ?></a> <?php } else { ?>
0<?php } ?>
</td>
<?php } ?>
</tr>
<tr>
<td>participants</td>
<?php foreach ($widget['records'] as $record) { ?>
<td><?php if (!empty($record['participants'])) { ?>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=participants&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['participants']) ?></a> <?php } else { ?>
0<?php } ?>
</td>
<?php } ?>
</tr>
<?php } else { ?>
<tr>
<td colspan="6">
<p class="tm-widget-empty">No matching records found.</p>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
<!-- /monthly conferences -->
</section>

View File

@ -1,17 +0,0 @@
Dear user,
We received a request to reset your password for your {{site_name}} account.
To set a new password, please click the link below:
{{reset_link}}
This link will expire in 1 hour for security reasons.
If you did not request this password reset, please ignore this email. Your account remains secure.
Best regards,
The {{site_name}} team
{{#site_slogan}}
:: {{site_slogan}} ::
{{/site_slogan}}

View File

@ -1,54 +1,31 @@
<!-- login form -->
<div class="action-card">
<div class="action-card-header">
<p class="action-eyebrow">Welcome back</p>
<h2 class="action-title">Sign in</h2>
<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; ?>
<div class="action-form-group">
<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 _"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
required />
</div>
<div class="card text-center w-50 mx-auto">
<h2 class="card-header">Login</h2>
<div class="card-body">
<p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login">
<?php include 'csrf_token.php'; ?>
<div class="form-group mb-3">
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
required autofocus />
</div>
<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"
required />
</div>
<div class="form-group mb-3">
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
pattern=".{5,}" title="Eight or more characters"
required />
</div>
<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 class="form-group mb-3">
<label for="remember_me">
<input type="checkbox" id="remember_me" name="remember_me" />
remember me
</label>
</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; ?>
<input type="submit" class="btn btn-primary" value="Login" />
</form>
<div class="mt-3">
<a href="?page=login&action=forgot">forgot password?</a>
</div>
</div>
</div>
<!-- /login form -->

View File

@ -1,39 +1,32 @@
<div class="action-card">
<div class="action-card-header">
<p class="action-eyebrow">Account recovery</p>
<h2 class="action-title">Forgot password</h2>
<p class="action-subtitle">Enter your email address and we'll send you reset instructions if it exists in our records</p>
</div>
<div class="action-card-body">
<form method="post" action="?page=login&action=forgot" class="action-form" novalidate>
<?php include CSRF_TOKEN_INCLUDE; ?>
<div class="action-form-group">
<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"
class="form-control action-form-control"
id="email"
name="email"
placeholder="you@example.com"
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
required
autocomplete="email">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-body">
<h3 class="card-title mb-4">Reset password</h3>
<p>Enter your email address and we will send you<br />
instructions to reset your password.</p>
<form method="post" action="?page=login&action=forgot">
<?php include 'csrf_token.php'; ?>
<div class="form-group">
<label for="email">email address:</label>
<input type="email"
class="form-control"
id="email"
name="email"
required
autocomplete="email">
</div>
<button type="submit" class="btn btn-primary btn-block mt-4">
Send reset instructions
</button>
</form>
<div class="mt-3 text-center">
<a href="?page=login">back to login</a>
</div>
</div>
</div>
</div>
</div>
<div class="action-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane me-2"></i>Send reset instructions
</button>
</div>
</form>
<div class="mt-4 text-center">
<a href="?page=login" class="text-decoration-none"> Back to login</a>
</div>
</div>
</div>

View File

@ -1,53 +1,38 @@
<div class="action-card">
<div class="action-card-header">
<p class="action-eyebrow">Account recovery</p>
<h2 class="action-title">Reset password</h2>
<p class="action-subtitle">Create a new password that is at least 8 characters long</p>
</div>
<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; ?>
<div class="action-form-group">
<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"
class="form-control action-form-control"
id="new_password"
name="new_password"
placeholder="Enter new password"
required
minlength="8"
autocomplete="new-password">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-5">
<div class="card-body">
<h3 class="card-title mb-4">Set new password</h3>
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>">
<?php include 'csrf_token.php'; ?>
<div class="form-group">
<label for="new_password">new password:</label>
<input type="password"
class="form-control"
id="new_password"
name="new_password"
required
minlength="8"
autocomplete="new-password">
</div>
<div class="form-group mt-3">
<label for="confirm_password">confirm password:</label>
<input type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
required
minlength="8"
autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary btn-block mt-4">
Set new password
</button>
</form>
</div>
</div>
<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"
class="form-control action-form-control"
id="confirm_password"
name="confirm_password"
placeholder="Confirm new password"
required
minlength="8"
autocomplete="new-password">
</div>
</div>
<div class="action-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-lock me-2"></i>Update password
</button>
</div>
</form>
<div class="mt-4 text-center">
<a href="?page=login" class="text-decoration-none"> Back to login</a>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
<!-- registration form -->
<div class="card text-center w-50 mx-auto">
<h2 class="card-header">Register</h2>
<div class="card-body">
<p class="card-text">Enter credentials for registration:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=register">
<?php include 'csrf_token.php'; ?>
<div class="form-group mb-3">
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
required autofocus />
</div>
<div class="form-group mb-3">
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
pattern=".{8,}" title="Eight or more characters"
required />
</div>
<div class="form-group mb-3">
<input type="password" class="form-control w-50 mx-auto" name="confirm_password" placeholder="Confirm password"
pattern=".{8,}" title="Eight or more characters"
required />
</div>
<input type="submit" class="btn btn-primary" value="Register" />
</form>
</div>
</div>
<!-- /registration form -->

View File

@ -0,0 +1,109 @@
<!-- log events -->
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-md-6">
<h2 class="mb-0">Log events</h2>
<small>events recorded in the Jilo monitoring platform</small>
</div>
</div>
<!-- Tabs navigation -->
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link <?= $scope === 'user' ? 'active' : '' ?>" href="?page=logs&tab=user">
Logs for current user
</a>
</li>
<?php if ($has_system_access) { ?>
<li class="nav-item">
<a class="nav-link <?= $scope === 'system' ? 'active' : '' ?>" href="?page=logs&tab=system">
Logs for all users
</a>
</li>
<?php } ?>
</ul>
<!-- logs filter -->
<div class="card mb-3">
<div class="card-body">
<form method="GET" action="" class="row g-3 align-items-end">
<input type="hidden" name="page" value="logs">
<input type="hidden" name="tab" value="<?= htmlspecialchars($scope) ?>">
<div class="col-md-3">
<label for="from_time" class="form-label">From date</label>
<input type="date" class="form-control" id="from_time" name="from_time" value="<?= htmlspecialchars($_REQUEST['from_time'] ?? '') ?>">
</div>
<div class="col-md-3">
<label for="until_time" class="form-label">Until date</label>
<input type="date" class="form-control" id="until_time" name="until_time" value="<?= htmlspecialchars($_REQUEST['until_time'] ?? '') ?>">
</div>
<?php if ($scope === 'system') { ?>
<div class="col-md-2">
<label for="id" class="form-label">User ID</label>
<input type="text" class="form-control" id="id" name="id" value="<?= htmlspecialchars($_REQUEST['id'] ?? '') ?>" placeholder="Enter user ID">
</div>
<?php } ?>
<div class="col-md">
<label for="message" class="form-label">Message</label>
<input type="text" class="form-control" id="message" name="message" value="<?= htmlspecialchars($_REQUEST['message'] ?? '') ?>" placeholder="Search in log messages">
</div>
<div class="col-md-auto">
<button type="submit" class="btn btn-primary me-2">
<i class="fas fa-search me-2"></i>Search
</button>
<a href="?page=logs&tab=<?= htmlspecialchars($scope) ?>" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Clear
</a>
</div>
</form>
</div>
</div>
<!-- /logs filter -->
<!-- logs -->
<?php if ($time_range_specified) { ?>
<div class="alert alert-info m-3">
<i class="fas fa-calendar-alt me-2"></i>Time period: <strong><?= htmlspecialchars($from_time) ?> - <?= htmlspecialchars($until_time) ?></strong>
</div>
<?php } ?>
<div class="mb-5">
<?php if (!empty($logs['records'])) { ?>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<?php if ($scope === 'system') { ?>
<th>Username (id)</th>
<?php } ?>
<th>Time</th>
<th>Log message</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs['records'] as $row) { ?>
<tr>
<?php if ($scope === 'system') { ?>
<td><strong><?= htmlspecialchars($row['username']) ?> (<?= htmlspecialchars($row['userID']) ?>)</strong></td>
<?php } ?>
<td><span class="text-muted"><?= date('d M Y H:i', strtotime($row['time'])) ?></span></td>
<td><?= htmlspecialchars($row['log message']) ?></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php include '../app/templates/pagination.php'; ?>
<?php } else { ?>
<div class="alert alert-info m-3">
<i class="fas fa-info-circle me-2"></i>No log entries found for the specified criteria.
</div>
<?php } ?>
</div>
</div>
<!-- /log events -->

View File

@ -1,25 +0,0 @@
<?php
/**
* Maintenance mode page
*/
?>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card border-warning">
<div class="card-header bg-warning text-dark">
<strong>Maintenance mode</strong>
</div>
<div class="card-body">
<p class="lead">The site is temporarily unavailable due to maintenance.</p>
<?php $mm = \App\Core\Maintenance::getMessage(); ?>
<?php if ($mm): ?>
<p class="mb-0"><em><?= htmlspecialchars($mm) ?></em></p>
<?php else: ?>
<p class="text-muted">Please try again later.</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>

View File

@ -12,7 +12,7 @@
</div>
<?php if (Session::getUsername() && $page !== 'logout') { ?>
<?php if (isset($currentUser) && $page !== 'logout') { ?>
<script src="static/js/sidebar.js"></script>
<?php } ?>

View File

@ -10,7 +10,7 @@
<link rel="stylesheet" type="text/css" href="<?= htmlspecialchars($app_root) ?>static/css/main.css">
<link rel="stylesheet" type="text/css" href="<?= htmlspecialchars($app_root) ?>static/css/messages.css">
<script src="<?= htmlspecialchars($app_root) ?>static/js/messages.js"></script>
<?php if (Session::getUsername()) { ?>
<?php if (isset($currentUser)) { ?>
<script>
// restore sidebar state before the page is rendered
(function () {
@ -36,33 +36,12 @@
<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>
<?php } ?>
<?php if ($page === 'admin') {
// Use local highlight.js assets if available
$hlBaseFs = __DIR__ . '/../../public_html/static/libs/highlightjs';
$hlBaseUrl = htmlspecialchars($app_root) . 'static/libs/highlightjs/';
$hlCss = $hlBaseFs . '/styles/github.min.css';
$hlJs = $hlBaseFs . '/highlight.min.js';
$hlSql = $hlBaseFs . '/languages/sql.min.js';
if (is_file($hlCss)) { echo '<link rel="stylesheet" href="' . $hlBaseUrl . 'styles/github.min.css">'; }
if (is_file($hlJs)) { echo '<script src="' . $hlBaseUrl . 'highlight.min.js"></script>'; }
if (is_file($hlSql)) { echo '<script src="' . $hlBaseUrl . 'languages/sql.min.js"></script>'; }
?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.hljs) { hljs.highlightAll(); }
});
</script>
<?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>Jilo Web</title>
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($app_root) ?>static/favicon.ico">
</head>
<body>
<div id="messages-container" class="container-fluid"></div>
<div id="messages-container" class="container-fluid mt-2"></div>
<div class="container-fluid">
<div class="row">
<div class="col">

View File

@ -1,121 +1,68 @@
<div class="container-fluid p-0">
<div class="container-fluid">
<!-- Modern Menu -->
<!-- Menu -->
<div class="menu-container">
<div class="modern-header-content">
<div class="logo-section">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="modern-logo-link">
<div class="modern-logo">
<img src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="<?= htmlspecialchars($config['site_name']); ?>"/>
</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>
</a>
<ul class="menu-left">
<div class="container">
<div class="row">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="logo-link">
<div class="col-4">
<img class="logo" src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="JILO"/>
</div>
</a>
</div>
</div>
<?php if (Session::isValidSession()) { ?>
<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']) ?>
</li>
<?php if (isset($_SESSION['username']) && isset($_SESSION['user_id'])) { ?>
<?php foreach ($platformsAll as $platform) {
$platform_switch_url = switchPlatform($platform['id']);
?>
<div>
<li style="margin-right: 3px;">
<?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?>
Jitsi platforms:
<button class="btn modern-header-btn" type="button" aria-expanded="false">
<span style="background-color: #fff; border: 1px solid #111; color: #111; border-bottom-color: #fff; padding-bottom: 12px;">
<?= htmlspecialchars($platform['name']) ?>
</button>
</span>
<?php } else { ?>
<a href="<?= htmlspecialchars($platform_switch_url) ?>">
<?= htmlspecialchars($platform['name']) ?>
</a>
<?php } ?>
</div>
</li>
<?php } ?>
<?php } ?>
</ul>
<div class="header-actions">
<?php if (Session::isValidSession()) { ?>
<div class="dropdown">
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
<i class="fas fa-user-circle me-2"></i><?= htmlspecialchars($currentUser) ?>
</button>
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header modern-dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme">
<i class="fas fa-paint-brush"></i>Change theme
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
<i class="fas fa-id-card"></i>Profile details
</a>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
<i class="fas fa-shield-alt"></i>Login credentials
</a>
<?php do_hook('account_menu', ['app_root' => $app_root]); ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
<i class="fas fa-sign-out-alt"></i>Logout
</a>
</div>
<ul class="menu-right">
<?php if (isset($_SESSION['username']) && isset($_SESSION['user_id'])) { ?>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
<i class="fas fa-user"></i>
</a>
<div class="dropdown-menu dropdown-menu-right">
<h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
<i class="fas fa-id-card"></i>Profile details
</a>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
<i class="fas fa-shield-alt"></i>Login credentials
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
<i class="fas fa-sign-out-alt"></i>Logout
</a>
</div>
<div class="dropdown">
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
<i class="fas fa-cog"></i>
</button>
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header modern-dropdown-header">settings</h6>
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin-tools">
<i class="fas fa-toolbox"></i>Admin tools
</a>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin">
<i class="fas fa-toolbox"></i>Admin
</a>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file')) {?>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=config">
<i class="fas fa-wrench"></i>Configuration
</a>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file') ||
$userObject->hasRight($userId, 'edit config file') ||
$userObject->hasRight($userId, 'edit whitelist') ||
$userObject->hasRight($userId, 'edit blacklist') ||
$userObject->hasRight($userId, 'edit ratelimiting')) { ?>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=security">
<i class="fas fa-shield-alt"></i>Security
</a>
<?php do_hook('main_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
</div>
</div>
<?php } ?>
</li>
<?php } else { ?>
<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]); ?>
<li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li>
<li><a href="<?= htmlspecialchars($app_root) ?>?page=register">register</a></li>
<?php } ?>
<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>
</button>
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header modern-dropdown-header">resources</h6>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=help">
<i class="fas fa-question-circle"></i>Help
</a>
</div>
</div>
</div>
</div>
</ul>
</div>
<!-- /Modern Menu -->
<!-- /Menu -->

View File

@ -1,16 +1,16 @@
<div class="row" style="padding-right: 0.75rem;">
<div class="row">
<!-- Sidebar -->
<div class="col-md-3 sidebar-wrapper" id="sidebar">
<div class="text-center" id="time_now">
<div class="col-md-3 mb-5 sidebar-wrapper bg-light" id="sidebar">
<div class="text-center" style="border: 1px solid #0dcaf0; height: 22px;" id="time_now">
<?php
$timeNow = new DateTime('now', new DateTimeZone($userTimezone));
?>
<span><?= htmlspecialchars($timeNow->format('H:i')) ?>&nbsp;&nbsp;<?= htmlspecialchars($userTimezone) ?></span>
<span style="vertical-align: top; font-size: 12px;"><?= htmlspecialchars($timeNow->format('H:i')) ?>&nbsp;&nbsp;<?= htmlspecialchars($userTimezone) ?></span>
</div>
<div class="col-4"><button class="btn btn-sm btn-info toggle-sidebar-button" type="button" id="toggleSidebarButton" value=">>"></button></div>
<div class="sidebar-content card mt-0">
<div class="sidebar-content card ml-3 mt-3">
<ul class="list-group">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=dashboard">
@ -19,7 +19,7 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
</li>
</a>
<li class="list-group-item sidebar-section-title-first">logs statistics</li>
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>logs statistics</small></p></li>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences">
<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>
</a>
<li class="list-group-item sidebar-section-title">live data</li>
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>live data</small></p></li>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=graphs">
<li class="list-group-item<?php if ($page === 'graphs') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
@ -65,18 +65,52 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
</li>
</a>
<li class="list-group-item sidebar-section-title">jitsi platforms settings</li>
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>jitsi platforms settings</small></p></li>
<a href="<?= htmlspecialchars($app_root) ?>?page=settings">
<li class="list-group-item<?php if ($page === 'settings') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-cog" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="jilo settings"></i>settings
</li>
</a>
<li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>system</small></p></li>
<?php if ($userObject->hasRight($user_id, 'view config file')) {?>
<a href="<?= htmlspecialchars($app_root) ?>?page=config">
<li class="list-group-item<?php if ($page === 'config') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-wrench" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="app config"></i>config
</li>
</a>
<?php } ?>
<?php if ($userObject->hasRight($user_id, 'superuser') ||
$userObject->hasRight($user_id, 'edit whitelist') ||
$userObject->hasRight($user_id, 'edit blacklist') ||
$userObject->hasRight($user_id, 'edit ratelimiting')) { ?>
<a href="<?= htmlspecialchars($app_root) ?>?page=security">
<li class="list-group-item<?php if ($page === 'security') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-shield-alt" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="security"></i>security
</li>
</a>
<?php } ?>
<a href="<?= htmlspecialchars($app_root) ?>?page=status">
<li class="list-group-item<?php if ($page === 'status' && $item === '') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-heartbeat" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="status"></i>status
</li>
</a>
<?php if ($userObject->hasRight($user_id, 'view app logs')) {?>
<a href="<?= htmlspecialchars($app_root) ?>?page=logs">
<li class="list-group-item<?php if ($page === 'logs') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-shoe-prints" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="logs"></i>logs
</li>
</a>
<?php } ?>
<a href="<?= htmlspecialchars($app_root) ?>?page=help">
<li class="list-group-item<?php if ($page === 'help') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-question-circle" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="help"></i>help
</li>
</a>
</ul>
</div>
</div>

View File

@ -6,14 +6,11 @@
* $totalPages - Total number of pages
*/
// Validate required pagination variables
// Ensure required variables are set
if (!isset($currentPage) || !isset($totalPages)) {
return;
}
// Ensure valid values
$currentPage = max(1, min($currentPage, $totalPages));
// Number of page links to show before and after current page
$range = 2;
?>

View File

@ -1,81 +1,94 @@
<!-- user profile -->
<div class="action-card">
<div class="action-card-header">
<p class="action-eyebrow">Account</p>
<h2 class="action-title">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></h2>
<p class="action-subtitle">Update your personal details, avatar, and access rights in one streamlined view.</p>
</div>
<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; ?>
<div class="row g-4 align-items-start">
<div class="col-lg-4">
<div class="tm-profile-avatar card h-100">
<div class="avatar-wrapper">
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
</div>
<div class="avatar-btn-group">
<label for="avatar-upload" class="btn btn-outline-primary w-100">
<i class="fas fa-upload me-2"></i>Upload new
</label>
<input type="file" id="avatar-upload" name="avatar_file" accept="image/*" style="display:none;">
<!-- user profile -->
<div class="card text-center w-50 mx-auto">
<p class="h4 card-header">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></p>
<div class="card-body">
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile" enctype="multipart/form-data">
<div class="row">
<p class="border rounded bg-light mb-4"><small>edit the profile fields</small></p>
<div class="col-md-4 avatar-container">
<div class="avatar-wrapper">
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
<div class="avatar-btn-container">
<label for="avatar-upload" class="avatar-btn avatar-btn-select btn btn-primary">
<i class="fas fa-folder" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="select new avatar"></i>
</label>
<input type="file" id="avatar-upload" name="avatar_file" accept="image/*" style="display:none;">
<?php if ($default_avatar) { ?>
<button type="button" class="btn btn-outline-secondary w-100" data-toggle="modal" data-target="#confirmDeleteModal" disabled>
<button type="button" class="avatar-btn avatar-btn-remove btn btn-secondary" data-toggle="modal" data-target="#confirmDeleteModal" disabled>
<?php } else { ?>
<button type="button" class="btn btn-outline-danger w-100" data-toggle="modal" data-target="#confirmDeleteModal">
<button type="button" class="avatar-btn avatar-btn-remove btn btn-danger" data-toggle="modal" data-target="#confirmDeleteModal">
<?php } ?>
<i class="fas fa-trash me-2"></i>Remove avatar
</button>
</div>
<p class="avatar-hint">PNG, JPG up to 500 KB.</p>
</div>
</div>
<div class="col-lg-8">
<div class="tm-profile-section">
<h3 class="tm-profile-section-title">Personal info</h3>
<div class="row g-3">
<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 />
<i class="fas fa-trash" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="remove current avatar"></i>
</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="action-form-group">
<label for="email" class="action-form-label">Email address</label>
<input class="form-control action-form-control" type="text" name="email" id="email" value="<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>" />
</div>
</div>
</div>
</div>
<div class="tm-profile-section">
<h3 class="tm-profile-section-title">Timezone</h3>
<div class="action-form-group">
<label for="timezone" class="action-form-label">Preferred timezone</label>
<select class="form-control action-form-control" name="timezone" id="timezone">
<div class="col-md-8">
<!--div class="row mb-3">
<div class="col-md-4 text-end">
<label for="username" class="form-label"><small>username:</small></label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8 text-start bg-light">
<input class="form-control" type="text" name="username" value="<?= htmlspecialchars($userDetails[0]['username']) ?>" required />
</div>
</div-->
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="name" class="form-label"><small>name:</small></label>
</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 class="row mb-3">
<div class="col-md-4 text-end">
<label for="email" class="form-label"><small>email:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<input class="form-control" type="text" name="email" value="<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>" />
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="timezone" class="form-label"><small>timezone:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<select class="form-control" name="timezone" id="timezone">
<?php foreach ($allTimezones as $timezone) { ?>
<option value="<?= htmlspecialchars($timezone) ?>" <?= $timezone === $userTimezone ? 'selected' : '' ?>>
<?= htmlspecialchars($timezone) ?>&nbsp;&nbsp;(<?= htmlspecialchars(getUTCOffset($timezone)) ?>)
</option>
<option value="<?= htmlspecialchars($timezone) ?>" <?= $timezone === $userTimezone ? 'selected' : '' ?>>
<?= htmlspecialchars($timezone) ?>&nbsp;&nbsp;(<?= htmlspecialchars(getUTCOffset($timezone)) ?>)
</option>
<?php } ?>
</select>
</div>
</div>
</select>
</div>
</div>
<div class="tm-profile-section">
<h3 class="tm-profile-section-title">Bio</h3>
<div class="action-form-group">
<textarea class="form-control action-form-control" name="bio" rows="6" placeholder="Share something about yourself, your role, or preferences."><?= htmlspecialchars($userDetails[0]['bio'] ?? '') ?></textarea>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="bio" class="form-label"><small>bio:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<textarea class="form-control" name="bio" rows="10"><?= htmlspecialchars($userDetails[0]['bio'] ?? '') ?></textarea>
</div>
</div>
<div class="tm-profile-section">
<h3 class="tm-profile-section-title">Rights</h3>
<p class="tm-profile-section-helper">Toggle the permissions that should be associated with this user.</p>
<div class="tm-rights-grid">
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="rights" class="form-label"><small>rights:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<?php foreach ($allRights as $right) {
// Check if the current right exists in $userRights
$isChecked = false;
foreach ($userRights as $userRight) {
if ($userRight['right_id'] === $right['right_id']) {
@ -83,80 +96,83 @@
break;
}
} ?>
<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' : '' ?> />
<label class="form-check-label" for="right_<?= htmlspecialchars($right['right_id']) ?>"><?= htmlspecialchars($right['right_name']) ?></label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="rights[]" value="<?= htmlspecialchars($right['right_id']) ?>" id="right_<?= htmlspecialchars($right['right_id']) ?>" <?= $isChecked ? 'checked' : '' ?> />
<label class="form-check-label" for="right_<?= htmlspecialchars($right['right_id']) ?>"><?= htmlspecialchars($right['right_name']) ?></label>
</div>
<?php } ?>
</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>
</form>
</div>
<!-- avatar removal modal confirmation -->
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-labelledby="confirmDeleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmDeleteModalLabel">Confirm Avatar Deletion</h5>
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
<p class="mt-3 mb-0">Are you sure you want to delete your avatar?<br />This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<form id="remove-avatar-form" data-action="remove-avatar" method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile&action=remove&item=avatar">
<?php include CSRF_TOKEN_INCLUDE; ?>
<button type="button" class="btn btn-danger" id="confirm-delete">Delete Avatar</button>
<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>
</form>
<!-- avatar removal modal confirmation -->
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-labelledby="confirmDeleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmDeleteModalLabel">Confirm Avatar Deletion</h5>
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
<br />
Are you sure you want to delete your avatar?
<br />
This action cannot be undone.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<form id="remove-avatar-form" data-action="remove-avatar" method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile&action=remove&item=avatar">
<button type="button" class="btn btn-danger" id="confirm-delete">Delete Avatar</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /user profile -->
<!-- /user profile -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Preview the uploaded avatar
document.getElementById('avatar-upload').addEventListener('change', function(event) {
const reader = new FileReader();
reader.onload = function() {
document.querySelector('.avatar-img').src = reader.result;
};
reader.readAsDataURL(event.target.files[0]);
});
// Preview the uploaded avatar
document.getElementById('avatar-upload').addEventListener('change', function(event) {
const reader = new FileReader();
reader.onload = function() {
document.querySelector('.avatar-img').src = reader.result;
};
reader.readAsDataURL(event.target.files[0]);
});
// Avatar file size and type control
document.getElementById('avatar-upload').addEventListener('change', function() {
const maxFileSize = 500 * 1024; // 500 KB in bytes
const currentAvatar = '<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>'; // current avatar
const file = this.files[0];
// Avatar file size and type control
document.getElementById('avatar-upload').addEventListener('change', function() {
const maxFileSize = 500 * 1024; // 500 KB in bytes
const currentAvatar = '<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>'; // current avatar
const file = this.files[0];
if (file) {
// Check file size
if (file.size > maxFileSize) {
alert('File size exceeds 500 KB. Please select a smaller file.');
this.value = ''; // Clear the file input
document.querySelector('.avatar-img').src = currentAvatar;
}
if (file) {
// Check file size
if (file.size > maxFileSize) {
alert('File size exceeds 500 KB. Please select a smaller file.');
this.value = ''; // Clear the file input
document.querySelector('.avatar-img').src = currentAvatar;
}
});
}
});
// Submitting the avatar deletion confirmation modal form
document.getElementById('confirm-delete').addEventListener('click', function(event) {
event.preventDefault(); // Prevent the outer form from submitting
document.getElementById('remove-avatar-form').submit();
});
// Submitting the avatar deletion confirmation modal form
document.getElementById('confirm-delete').addEventListener('click', function(event) {
event.preventDefault(); // Prevent the outer form from submitting
document.getElementById('remove-avatar-form').submit();
});
// Function to detect user's timezone and select it in the dropdown

View File

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

View File

@ -5,17 +5,17 @@
<h2 class="mb-0">Security settings</h2>
<small>network restrictions to control flooding and brute force attacks</small>
<ul class="nav nav-tabs mt-5">
<?php if ($userObject->hasRight($userId, 'superuser') || $userObject->hasRight($userId, 'edit whitelist')) { ?>
<?php if ($userObject->hasRight($user_id, 'superuser') || $userObject->hasRight($user_id, 'edit whitelist')) { ?>
<li class="nav-item">
<a class="nav-link <?= $section === 'whitelist' ? 'active' : '' ?>" href="?page=security&section=whitelist">IP whitelist</a>
</li>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') || $userObject->hasRight($userId, 'edit blacklist')) { ?>
<?php if ($userObject->hasRight($user_id, 'superuser') || $userObject->hasRight($user_id, 'edit blacklist')) { ?>
<li class="nav-item">
<a class="nav-link <?= $section === 'blacklist' ? 'active' : '' ?>" href="?page=security&section=blacklist">IP blacklist</a>
</li>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') || $userObject->hasRight($userId, 'edit ratelimiting')) { ?>
<?php if ($userObject->hasRight($user_id, 'superuser') || $userObject->hasRight($user_id, 'edit ratelimiting')) { ?>
<li class="nav-item">
<a class="nav-link <?= $section === 'ratelimit' ? 'active' : '' ?>" href="?page=security&section=ratelimit">Rate limiting</a>
</li>
@ -24,7 +24,7 @@
</div>
</div>
<?php if ($section === 'whitelist' && ($userObject->hasRight($userId, 'superuser') || $userObject->hasRight($userId, 'edit whitelist'))) { ?>
<?php if ($section === 'whitelist' && ($userObject->hasRight($user_id, 'superuser') || $userObject->hasRight($user_id, 'edit whitelist'))) { ?>
<!-- whitelist section -->
<div class="row mb-4">
<div class="col">
@ -35,7 +35,7 @@
</div>
<div class="card-body">
<form method="POST" class="mb-4">
<?php include CSRF_TOKEN_INCLUDE; ?>
<?php include 'csrf_token.php'; ?>
<input type="hidden" name="action" value="add_whitelist">
<div class="row g-3">
<div class="col-md-4">
@ -77,7 +77,7 @@
<td><?= htmlspecialchars($ip['created_at']) ?></td>
<td>
<form method="POST" style="display: inline;">
<?php include CSRF_TOKEN_INCLUDE; ?>
<?php include 'csrf_token.php'; ?>
<input type="hidden" name="action" value="remove_whitelist">
<input type="hidden" name="ip_address" value="<?= htmlspecialchars($ip['ip_address']) ?>">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to remove this IP from whitelist?')">Remove</button>
@ -93,7 +93,7 @@
</div>
<?php } ?>
<?php if ($section === 'blacklist' && ($userObject->hasRight($userId, 'superuser') || $userObject->hasRight($userId, 'edit blacklist'))) { ?>
<?php if ($section === 'blacklist' && ($userObject->hasRight($user_id, 'superuser') || $userObject->hasRight($user_id, 'edit blacklist'))) { ?>
<!-- blacklist section -->
<div class="row mb-4">
<div class="col">
@ -104,7 +104,7 @@
</div>
<div class="card-body">
<form method="POST" class="mb-4">
<?php include CSRF_TOKEN_INCLUDE; ?>
<?php include 'csrf_token.php'; ?>
<input type="hidden" name="action" value="add_blacklist">
<div class="row g-3">
<div class="col-md-3">
@ -151,7 +151,7 @@
<td><?= $ip['expiry_time'] ? htmlspecialchars($ip['expiry_time']) : 'Never' ?></td>
<td>
<form method="POST" style="display: inline;">
<?php include CSRF_TOKEN_INCLUDE; ?>
<?php include 'csrf_token.php'; ?>
<input type="hidden" name="action" value="remove_blacklist">
<input type="hidden" name="ip_address" value="<?= htmlspecialchars($ip['ip_address']) ?>">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to remove this IP from blacklist?')">Remove</button>
@ -167,7 +167,7 @@
</div>
<?php } ?>
<?php if ($section === 'ratelimit' && ($userObject->hasRight($userId, 'superuser') || $userObject->hasRight($userId, 'edit ratelimiting'))) { ?>
<?php if ($section === 'ratelimit' && ($userObject->hasRight($user_id, 'superuser') || $userObject->hasRight($user_id, 'edit ratelimiting'))) { ?>
<!-- rate limiting section -->
<div class="row mb-4">
<div class="col">

View File

@ -57,7 +57,7 @@
<button type="button" class="btn btn-outline-secondary cancel-edit platform-edit-mode" style="display: none;">
<i class="fas fa-times me-1"></i>Cancel
</button>
<?php if ($userObject->hasRight($userId, 'delete platform')): ?>
<?php if ($userObject->hasRight($user_id, 'delete platform')): ?>
<button type="button" class="btn btn-outline-danger platform-view-mode" onclick="showDeletePlatformModal(<?= htmlspecialchars($platform['id']) ?>, '<?= htmlspecialchars(addslashes($platform['name'])) ?>', '<?= htmlspecialchars(addslashes($platform['jitsi_url'])) ?>', '<?= htmlspecialchars(addslashes($platform['jilo_database'])) ?>')">
<i class="fas fa-trash me-1"></i>Delete platform
</button>

View File

@ -1,133 +0,0 @@
<?php
/**
* Theme switcher template
*
* Displays available themes and allows the user to switch between them.
*
* @var array $themes List of available themes with their data
* - name: Display name
* - screenshotUrl: URL to the screenshot (or null if not available)
* - isActive: Whether this is the current theme
*/
?>
<?php
$activeThemeName = 'Default';
foreach ($themes as $themeData) {
if (!empty($themeData['isActive'])) {
$activeThemeName = $themeData['name'];
break;
}
}
$totalThemes = count($themes);
?>
<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 class="tm-hero-meta">
<span class="tm-hero-pill pill-neutral">
<i class="fas fa-layer-group"></i>
<?= $totalThemes ?> available
</span>
<span class="tm-hero-pill pill-primary">
<i class="fas fa-check-circle"></i>
Active: <?= htmlspecialchars($activeThemeName) ?>
</span>
</div>
</div>
</div>
</div>
<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>
</article>
<?php endforeach; ?>
</div>
</div>
</section>

View File

@ -0,0 +1,64 @@
<div class="row">
<?php if ($widget['collapsible'] === true) { ?>
<a style="text-decoration: none;" data-toggle="collapse" href="#collapse<?= htmlspecialchars($widget['name']) ?>" role="button" aria-expanded="true" aria-controls="collapse<?= htmlspecialchars($widget['name']) ?>">
<div class="card w-auto bg-light card-body" style="flex-direction: row;"><?= $widget['title'] ?></div>
<?php } else { ?>
<div class="card w-auto bg-light border-light card-body" style="flex-direction: row;"><?= $widget['title'] ?></div>
<?php } ?>
<?php if ($widget['filter'] === true) {
include '../app/templates/block-results-filter.php'; } ?>
<?php if ($widget['collapsible'] === true) { ?>
</a>
<?php } ?>
</div>
<!-- widget "<?= htmlspecialchars($widget['name']) ?>" -->
<div class="collapse show" id="collapse<?= htmlspecialchars($widget['name']) ?>">
<?php if ($time_range_specified) { ?>
<p class="m-3">time period:
<strong>
<?= $from_time == '0000-01-01' ? 'beginning' : date('d M Y', strtotime($from_time)) ?> - <?= $until_time == '9999-12-31' ? 'now' : date('d M Y', strtotime($until_time)) ?>
</strong>
</p>
<?php } ?>
<div class="mb-5">
<?php if ($widget['full'] === true) { ?>
<table class="table table-results table-striped table-hover table-bordered">
<thead class="thead-dark">
<tr>
<th scope="col"></th>
<?php foreach ($widget['records'] as $record) { ?>
<th scope="col"><?= htmlspecialchars($record['table_headers']) ?></th>
<?php } ?>
</tr>
</thead>
<tbody>
<tr>
<td>conferences</td>
<?php foreach ($widget['records'] as $record) { ?>
<td><?php if (!empty($record['conferences'])) { ?>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['conferences']) ?></a> <?php } else { ?>
0<?php } ?>
</td>
<?php } ?>
</tr>
<tr>
<td>participants</td>
<?php foreach ($widget['records'] as $record) { ?>
<td><?php if (!empty($record['participants'])) { ?>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=participants&from_time=<?= htmlspecialchars($record['from_time']) ?>&until_time=<?= htmlspecialchars($record['until_time']) ?>"><?= htmlspecialchars($record['participants']) ?></a> <?php } else { ?>
0<?php } ?>
</td>
<?php } ?>
</tr>
</tbody>
</table>
<?php } else { ?>
<p class="m-3">No matching records found.</p>
<?php } ?>
</div>
</div>
<!-- /widget "<?= htmlspecialchars($widget['name']) ?>" -->

View File

@ -1,88 +0,0 @@
# Database migrations
This app ships with a lightweight SQL migration system to safely upgrade a running site when code changes require database changes.
## Concepts
- Migrations live in `doc/database/migrations/` and are plain `.sql` files.
- They are named in a sortable order, e.g. `YYYYMMDD_HHMMSS_description.sql`.
- Applied migrations are tracked in a DB table called `migrations`.
- Use the CLI script `scripts/migrate.php` to inspect and apply migrations.
## Usage
1. Show current status
```bash
php scripts/migrate.php status
```
2. Apply all pending migrations
```bash
php scripts/migrate.php up
```
3. Typical deployment steps
- Pull new code from git.
- Put the site in maintenance mode (recommended): `php scripts/maintenance.php on "Upgrading database"`.
- Run `php scripts/migrate.php status`.
- If there are pending migrations, run `php scripts/migrate.php up`.
- Disable maintenance mode: `php scripts/maintenance.php off`.
- Clear opcache if applicable and resume traffic.
## Maintenance mode
Enable maintenance mode to temporarily block non-admin traffic during upgrades. Superusers (user ID 1 or with `superuser` right) can still access the site.
Commands:
```bash
# Turn on with optional message
php scripts/maintenance.php on "Upgrading database"
# Turn off
php scripts/maintenance.php off
# Status
php scripts/maintenance.php status
```
Notes:
- The maintenance flag is stored at `app/.maintenance.flag`.
- You can also control maintenance via environment variables (useful when the filesystem is read-only):
- `JILO_MAINTENANCE=1` enables maintenance mode
- `JILO_MAINTENANCE_MESSAGE="Your message"` sets the banner message
## Authoring new migrations
1. Create a new SQL file in `doc/database/migrations/`, e.g.:
```
doc/database/migrations/20250924_170001_add_user_meta_theme.sql
```
2. Write forward-only SQL. Avoid destructive changes unless absolutely necessary.
3. Prefer idempotent SQL. For MySQL 8.0+ you can use `ADD COLUMN IF NOT EXISTS`. For older MySQL/MariaDB versions, either:
- Check existence in PHP and conditionally run DDL, or
- Write migrations that are safe to run once and tracked by the `migrations` table.
## Notes
- The application checks for pending migrations at runtime and shows a warning banner but will not auto-apply changes.
- The `migrations` table is created automatically by the runner if missing.
- The runner executes each migration inside a single transaction (when supported by the storage engine for the statements used). If any statement fails, the migration batch is rolled back and no migration is marked as applied.
## Example migration
This repo includes an example migration that adds a per-user theme column:
```
20250924_170001_add_user_meta_theme.sql
```
It adds `user_meta.theme` used to store each user's preferred theme.

View File

@ -1,244 +0,0 @@
-- Time: 13.03.2025, 15:52
-- Server: 11.4.5-MariaDB-1
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
SET NAMES utf8mb4;
--
-- User profiles
--
-- --------------------------------------------------------
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `user` (`id`, `username`, `password`) VALUES
(1,'demo','$2y$12$AtIKs3eVxD4wTT1IWwJujuuHyGhhmfBJYqSfIrPFFPMDfKu3Rcsx6'),
(2,'demo1','$2y$12$ELwYyhQ8XDkVvX9Xsb0mlORqeQHNFaBOvaBuPQym4n4IomA/DgvLC');
-- --------------------------------------------------------
CREATE TABLE `user_meta` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`email` varchar(256) DEFAULT NULL,
`timezone` varchar(255) DEFAULT NULL,
`theme` varchar(64) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`bio` text DEFAULT NULL,
PRIMARY KEY (`id`,`user_id`) USING BTREE,
KEY `user_id` (`user_id`),
CONSTRAINT `user_meta_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `user_meta` (`id`, `user_id`, `name`, `email`, `timezone`, `avatar`, `bio`) VALUES
(1,1,'demo admin user','admin@example.com',NULL,NULL,'This is a demo user of the demo install of Jilo Web'),
(2,2,'demo user','demo@example.com',NULL,NULL,'This is a demo user of the demo install of Jilo Web');
-- --------------------------------------------------------
CREATE TABLE `right` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `right` (`id`, `name`) VALUES
(1, 'superuser'),
(2, 'edit users'),
(3, 'view config file'),
(4, 'edit config file'),
(5, 'view own profile'),
(6, 'edit own profile'),
(7, 'view all profiles'),
(8, 'edit all profiles'),
(9, 'view app logs'),
(10, 'manage plugins'),
(11,'view all platforms'),
(12,'edit all platforms'),
(13,'view all agents'),
(14,'edit all agents'),
(15,'view jilo config');
-- --------------------------------------------------------
CREATE TABLE `user_right` (
`user_id` int(11) NOT NULL,
`right_id` int(11) NOT NULL,
PRIMARY KEY (`user_id`,`right_id`),
KEY `fk_right_id` (`right_id`),
CONSTRAINT `fk_right_id` FOREIGN KEY (`right_id`) REFERENCES `right` (`id`),
CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `user_2fa` (
`user_id` int(11) NOT NULL,
`secret_key` varchar(64) NOT NULL,
`backup_codes` text,
`enabled` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL,
`last_used` datetime DEFAULT NULL,
PRIMARY KEY (`user_id`),
CONSTRAINT `fk_user_2fa_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `user_2fa_temp` (
`user_id` int(11) NOT NULL,
`code` varchar(6) NOT NULL,
`created_at` datetime NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`user_id`, `code`),
CONSTRAINT `fk_user_2fa_temp_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `user_password_reset` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` int(11) NOT NULL,
`token` varchar(64) NOT NULL,
`expires` int(11) NOT NULL,
`used` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `fk_user_password_reset` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`),
UNIQUE KEY `token_idx` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
--
-- Login security
--
-- --------------------------------------------------------
CREATE TABLE `security_rate_auth` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip_address` varchar(45) NOT NULL,
`username` varchar(255) NOT NULL,
`attempted_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_ip_username` (`ip_address`,`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `security_rate_page` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip_address` varchar(45) NOT NULL,
`endpoint` varchar(255) NOT NULL,
`request_time` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_ip_endpoint` (`ip_address`,`endpoint`),
KEY `idx_request_time` (`request_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `security_ip_blacklist` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip_address` varchar(45) NOT NULL,
`is_network` tinyint(1) DEFAULT 0,
`reason` varchar(255) DEFAULT NULL,
`expiry_time` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`created_by` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_ip` (`ip_address`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `security_ip_blacklist` (`id`, `ip_address`, `is_network`, `reason`, `expiry_time`, `created_at`, `created_by`) VALUES
(1, '0.0.0.0/8', 1, 'Reserved address space - RFC 1122', NULL, '2025-01-03 16:40:15', 'system'),
(2, '100.64.0.0/10', 1, 'Carrier-grade NAT space - RFC 6598', NULL, '2025-01-03 16:40:15', 'system'),
(3, '192.0.2.0/24', 1, 'TEST-NET-1 Documentation space - RFC 5737', NULL, '2025-01-03 16:40:15', 'system'),
(4, '198.51.100.0/24', 1, 'TEST-NET-2 Documentation space - RFC 5737', NULL, '2025-01-03 16:40:15', 'system'),
(5, '203.0.113.0/24', 1, 'TEST-NET-3 Documentation space - RFC 5737', NULL, '2025-01-03 16:40:15', 'system');
-- --------------------------------------------------------
CREATE TABLE `security_ip_whitelist` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip_address` varchar(45) NOT NULL,
`is_network` tinyint(1) DEFAULT 0,
`description` varchar(255) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`created_by` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_ip` (`ip_address`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `security_ip_whitelist` (`id`, `ip_address`, `is_network`, `description`, `created_at`, `created_by`) VALUES
(1, '127.0.0.1', 0, 'localhost IPv4', '2025-01-03 16:40:15', 'system'),
(2, '::1', 0, 'localhost IPv6', '2025-01-03 16:40:15', 'system'),
(3, '10.0.0.0/8', 1, 'Private network (Class A)', '2025-01-03 16:40:15', 'system'),
(4, '172.16.0.0/12', 1, 'Private network (Class B)', '2025-01-03 16:40:15', 'system'),
(5, '192.168.0.0/16', 1, 'Private network (Class C)', '2025-01-03 16:40:15', 'system');
--
-- Jilo
--
-- --------------------------------------------------------
CREATE TABLE `jilo_agent_type` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`description` varchar(255),
`endpoint` varchar(255),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `jilo_agent_type` (`id`, `description`, `endpoint`) VALUES
(1,'jvb','/jvb'),
(2,'jicofo','/jicofo'),
(3,'prosody','/prosody'),
(4,'nginx','/nginx'),
(5,'jibri','/jibri');
-- --------------------------------------------------------
CREATE TABLE `platform` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`jitsi_url` varchar(255) NOT NULL,
`jilo_database` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
INSERT INTO `platform` (`id`, `name`, `jitsi_url`, `jilo_database`) VALUES
(1,'example.com','https://meet.example.com','../../jilo/jilo.db');
-- --------------------------------------------------------
CREATE TABLE `host` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`address` varchar(255) NOT NULL,
`platform_id` int(11) NOT NULL,
`name` varchar(255),
PRIMARY KEY (`id`),
CONSTRAINT `host_ibfk_1` FOREIGN KEY (`platform_id`) REFERENCES `platform` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `jilo_agent` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`host_id` int(11) NOT NULL,
`agent_type_id` int(11) NOT NULL,
`url` varchar(255) NOT NULL,
`secret_key` varchar(255),
`check_period` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
CONSTRAINT `jilo_agent_ibfk_1` FOREIGN KEY (`agent_type_id`) REFERENCES `jilo_agent_type` (`id`),
CONSTRAINT `jilo_agent_ibfk_2` FOREIGN KEY (`host_id`) REFERENCES `host` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
-- --------------------------------------------------------
CREATE TABLE `jilo_agent_check` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`agent_id` int(11),
`timestamp` datetime DEFAULT current_timestamp(),
`status_code` int(11),
`response_time_ms` int(11),
`response_content` varchar(255),
PRIMARY KEY (`id`),
CONSTRAINT `jilo_agent_check_ibfk_1` FOREIGN KEY (`agent_id`) REFERENCES `jilo_agent` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
COMMIT;

View File

@ -1,3 +1,4 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
@ -24,95 +25,43 @@ CREATE TABLE rights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS "jilo_agent_types" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT,
endpoint TEXT
);
INSERT INTO rights VALUES(1,'superuser');
INSERT INTO rights VALUES(2,'edit users');
INSERT INTO rights VALUES(3,'view settings');
INSERT INTO rights VALUES(4,'edit settings');
INSERT INTO rights VALUES(5,'view config file');
INSERT INTO rights VALUES(6,'edit config file');
INSERT INTO rights VALUES(7,'view own profile');
INSERT INTO rights VALUES(8,'edit own profile');
INSERT INTO rights VALUES(9,'view all profiles');
INSERT INTO rights VALUES(10,'edit all profiles');
INSERT INTO rights VALUES(11,'view app logs');
INSERT INTO rights VALUES(12,'view all platforms');
INSERT INTO rights VALUES(13,'edit all platforms');
INSERT INTO rights VALUES(14,'view all agents');
INSERT INTO rights VALUES(15,'edit all agents');
CREATE TABLE platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
jitsi_url TEXT NOT NULL,
jilo_database TEXT NOT NULL
);
CREATE TABLE hosts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
address TEXT NOT NULL,
platform_id INTEGER NOT NULL,
name TEXT,
FOREIGN KEY(platform_id) REFERENCES platforms(id)
);
CREATE TABLE jilo_agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
agent_type_id INTEGER NOT NULL,
url TEXT NOT NULL,
secret_key TEXT,
check_period INTEGER DEFAULT 0,
FOREIGN KEY(agent_type_id) REFERENCES jilo_agent_types(id),
FOREIGN KEY(host_id) REFERENCES hosts(id)
);
CREATE TABLE jilo_agent_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
status_code INTEGER,
response_time_ms INTEGER,
response_content TEXT,
FOREIGN KEY(agent_id) REFERENCES jilo_agents(id)
);
CREATE TABLE ip_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
description TEXT,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
);
CREATE TABLE ip_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
reason TEXT,
expiry_time TEXT NULL,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
);
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
time TEXT DEFAULT (DATETIME('now')),
scope TEXT NOT NULL,
message TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE pages_rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
username TEXT NOT NULL,
attempted_at TEXT DEFAULT (DATETIME('now'))
);
CREATE TABLE user_2fa (
user_id INTEGER NOT NULL PRIMARY KEY,
secret_key TEXT NOT NULL,
backup_codes TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_used TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
user_id INTEGER NOT NULL PRIMARY KEY,
secret_key TEXT NOT NULL,
backup_codes TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_used TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE user_2fa_temp (
user_id INTEGER NOT NULL PRIMARY KEY,
code TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
user_id INTEGER NOT NULL,
code TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
PRIMARY KEY (user_id, code),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE user_password_reset (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -121,5 +70,85 @@ CREATE TABLE user_password_reset (
expires INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
time TEXT DEFAULT (DATETIME('now')),
scope TEXT NOT NULL,
message TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS "jilo_agent_types" (
"id" INTEGER,
"description" TEXT,
"endpoint" TEXT,
PRIMARY KEY("id" AUTOINCREMENT)
);
INSERT INTO jilo_agent_types VALUES(1,'jvb','/jvb');
INSERT INTO jilo_agent_types VALUES(2,'jicofo','/jicofo');
INSERT INTO jilo_agent_types VALUES(3,'prosody','/prosody');
INSERT INTO jilo_agent_types VALUES(4,'nginx','/nginx');
INSERT INTO jilo_agent_types VALUES(5,'jibri','/jibri');
CREATE TABLE jilo_agent_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
status_code INTEGER,
response_time_ms INTEGER,
response_content TEXT,
FOREIGN KEY(agent_id) REFERENCES jilo_agents(id)
);
CREATE TABLE IF NOT EXISTS "jilo_agents" (
"id" INTEGER,
"host_id" INTEGER NOT NULL,
"agent_type_id" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"secret_key" TEXT,
"check_period" INTEGER DEFAULT 0,
PRIMARY KEY("id" AUTOINCREMENT),
FOREIGN KEY("agent_type_id") REFERENCES "jilo_agent_types"("id"),
FOREIGN KEY("host_id") REFERENCES "hosts"("id")
);
CREATE TABLE IF NOT EXISTS "hosts" (
"id" INTEGER NOT NULL,
"address" TEXT NOT NULL,
"platform_id" INTEGER NOT NULL,
"name" TEXT,
PRIMARY KEY("id" AUTOINCREMENT),
FOREIGN KEY("platform_id") REFERENCES "platforms"("id")
);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
username TEXT NOT NULL,
attempted_at TEXT DEFAULT (DATETIME('now'))
);
CREATE TABLE ip_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
description TEXT,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
);
INSERT INTO ip_whitelist VALUES(1,'127.0.0.1',0,'localhost IPv4','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(2,'::1',0,'localhost IPv6','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(3,'10.0.0.0/8',1,'Private network (Class A)','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(4,'172.16.0.0/12',1,'Private network (Class B)','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(5,'192.168.0.0/16',1,'Private network (Class C)','2025-01-04 11:39:08','system');
CREATE TABLE ip_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
reason TEXT,
expiry_time TEXT NULL,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
);
INSERT INTO ip_blacklist VALUES(1,'0.0.0.0/8',1,'Reserved address space - RFC 1122',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(2,'100.64.0.0/10',1,'Carrier-grade NAT space - RFC 6598',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(3,'192.0.2.0/24',1,'TEST-NET-1 Documentation space - RFC 5737',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(4,'198.51.100.0/24',1,'TEST-NET-2 Documentation space - RFC 5737',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(5,'203.0.113.0/24',1,'TEST-NET-3 Documentation space - RFC 5737',NULL,'2025-01-04 11:39:08','system');

View File

@ -5,38 +5,24 @@ INSERT INTO users VALUES(2,'demo1','$2y$10$LtV9m.rMCJ.K/g45e6tzDexZ8C/9xxu3qFCkv
INSERT INTO users_meta VALUES(1,1,'demo admin user','admin@example.com',NULL,NULL,'This is a demo user of the demo install of Jilo Web');
INSERT INTO users_meta VALUES(2,2,'demo user','demo@example.com',NULL,NULL,'This is a demo user of the demo install of Jilo Web');
INSERT INTO rights VALUES(1,'superuser');
INSERT INTO rights VALUES(2,'edit users');
INSERT INTO rights VALUES(3,'view settings');
INSERT INTO rights VALUES(4,'edit settings');
INSERT INTO rights VALUES(5,'view own profile');
INSERT INTO rights VALUES(6,'edit own profile');
INSERT INTO rights VALUES(7,'view all profiles');
INSERT INTO rights VALUES(8,'edit all profiles');
INSERT INTO rights VALUES(9,'view app logs');
INSERT INTO rights VALUES(10,'manage plugins');
INSERT INTO rights VALUES(11,'view all platforms');
INSERT INTO rights VALUES(12,'edit all platforms');
INSERT INTO rights VALUES(13,'view all agents');
INSERT INTO rights VALUES(14,'edit all agents');
INSERT INTO rights VALUES(15,'view jilo config');
INSERT INTO platforms VALUES(1,'meet.lindeas.com','https://meet.lindeas.com','../jilo-meet.lindeas.db');
INSERT INTO platforms VALUES(2,'example.com','https://meet.example.com','../jilo.db');
INSERT INTO jilo_agent_types VALUES(1,'jvb','/jvb');
INSERT INTO jilo_agent_types VALUES(2,'jicofo','/jicofo');
INSERT INTO jilo_agent_types VALUES(3,'prosody','/prosody');
INSERT INTO jilo_agent_types VALUES(4,'nginx','/nginx');
INSERT INTO jilo_agent_types VALUES(5,'jibri','/jibri');
INSERT INTO logs VALUES(1,2,'2024-09-30 09:54:50','user','Logout: User "demo" logged out. IP: 151.237.101.43');
INSERT INTO logs VALUES(2,2,'2024-09-30 09:54:54','user','Login: User "demo" logged in. IP: 151.237.101.43');
INSERT INTO logs VALUES(3,2,'2024-10-03 16:34:49','user','Logout: User "demo" logged out. IP: 151.237.101.43');
INSERT INTO logs VALUES(4,2,'2024-10-03 16:34:56','user','Login: User "demo" logged in. IP: 151.237.101.43');
INSERT INTO logs VALUES(5,2,'2024-10-09 11:08:16','user','Logout: User "demo" logged out. IP: 151.237.101.43');
INSERT INTO logs VALUES(6,2,'2024-10-09 11:08:20','user','Login: User "demo" logged in. IP: 151.237.101.43');
INSERT INTO logs VALUES(7,2,'2024-10-17 16:22:57','user','Logout: User "demo" logged out. IP: 151.237.101.43');
INSERT INTO logs VALUES(8,2,'2024-10-17 16:23:08','user','Login: User "demo" logged in. IP: 151.237.101.43');
INSERT INTO logs VALUES(9,2,'2024-10-18 08:07:25','user','Login: User "demo" logged in. IP: 42.104.201.119');
INSERT INTO platforms VALUES(1,'example.com','https://meet.example.com','../../jilo/jilo.db');
INSERT INTO jilo_agents VALUES(1,1,1,'https://meet.lindeas.com:8081','mysecretkey',5);
INSERT INTO jilo_agents VALUES(4,1,2,'https://meet.lindeas.com:8081','mysecretkey',5);
INSERT INTO jilo_agents VALUES(7,1,3,'http://meet.lindeas.com:8081','mysecretkey',5);
INSERT INTO jilo_agents VALUES(8,1,4,'http://meet.lindeas.com:8081','mysecretkey',5);
INSERT INTO ip_whitelist VALUES(1,'127.0.0.1',0,'localhost IPv4','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(2,'::1',0,'localhost IPv6','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(3,'10.0.0.0/8',1,'Private network (Class A)','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(4,'172.16.0.0/12',1,'Private network (Class B)','2025-01-04 11:39:08','system');
INSERT INTO ip_whitelist VALUES(5,'192.168.0.0/16',1,'Private network (Class C)','2025-01-04 11:39:08','system');
INSERT INTO hosts VALUES(1,'meet.lindeas.com',2,'main machine');
INSERT INTO hosts VALUES(2,'meet.example.com',2,'test');
INSERT INTO ip_blacklist VALUES(1,'0.0.0.0/8',1,'Reserved address space - RFC 1122',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(2,'100.64.0.0/10',1,'Carrier-grade NAT space - RFC 6598',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(3,'192.0.2.0/24',1,'TEST-NET-1 Documentation space - RFC 5737',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(4,'198.51.100.0/24',1,'TEST-NET-2 Documentation space - RFC 5737',NULL,'2025-01-04 11:39:08','system');
INSERT INTO ip_blacklist VALUES(5,'203.0.113.0/24',1,'TEST-NET-3 Documentation space - RFC 5737',NULL,'2025-01-04 11:39:08','system');

Some files were not shown because too many files have changed in this diff Show More