Compare commits

..

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

147 changed files with 2415 additions and 12138 deletions

View File

@ -10,104 +10,21 @@ jobs:
test: test:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: '8.2' php-version: '8.1'
extensions: pdo, pdo_mysql, xdebug extensions: pdo, pdo_sqlite
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
- name: Install dependencies - name: Install dependencies
run: | run: |
cd tests cd tests
composer install 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 - name: Run test suite
run: | run: |
cd tests cd tests
./vendor/bin/phpunit ./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 jilo-web.db
packaging/deb-package/ packaging/deb-package/
packaging/rpm-package/ packaging/rpm-package/
/public_html/uploads/avatars/

View File

@ -4,44 +4,19 @@ All notable changes to this project will be documented in this file.
--- ---
## 0.4.1 - 2025-11-13 ## Unreleased
#### Links #### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...v0.4.1 - upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...HEAD
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...v0.4.1 - codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...HEAD
- github: https://github.com/lindeas/jilo-web/compare/v0.4...v0.4.1 - github: https://github.com/lindeas/jilo-web/compare/v0.4...HEAD
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...v0.4.1 - gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...HEAD
### Added ### 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 ### 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
- 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 ## version
Current version: **0.4.1** released on **2025-11-13** Current version: **0.3** released on **2025-01-15**
## license ## 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. 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 ## requirements
- web server (deb: apache | nginx) - web server (deb: apache | nginx)

View File

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

View File

@ -94,11 +94,11 @@ class Component {
$result = $stmt->fetchAll(PDO::FETCH_ASSOC); $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($result)) { 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; return $result;
} catch (PDOException $e) { } 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 []; return [];
} }
} }
@ -162,7 +162,7 @@ class Component {
$result = $stmt->fetch(PDO::FETCH_ASSOC); $result = $stmt->fetch(PDO::FETCH_ASSOC);
return (int)$result['total']; return (int)$result['total'];
} catch (PDOException $e) { } 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; 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. * @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) { public function editConfigFile($updatedConfig, $config_file) {
global $logObject, $userId; global $logObject, $user_id;
$allLogs = []; $allLogs = [];
$updated = []; $updated = [];
@ -140,7 +140,7 @@ class Config {
} }
if (!empty($allLogs)) { if (!empty($allLogs)) {
$logObject->log('info', implode("\n", $allLogs), ['user_id' => $userId, 'scope' => 'system']); $logObject->insertLog($user_id, implode("\n", $allLogs), 'system');
} }
return [ return [
@ -148,7 +148,7 @@ class Config {
'updated' => $updated 'updated' => $updated
]; ];
} catch (Exception $e) { } 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 [ return [
'success' => false, 'success' => false,
'error' => $e->getMessage() 'error' => $e->getMessage()

View File

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

View File

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

View File

@ -1,40 +1,122 @@
<?php <?php
/** /**
* Log wrapper that delegates to plugin Log or NullLogger fallback. * class Log
* Used when code does require_once '../app/classes/log.php'. *
* 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 { 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) { public function __construct($database) {
global $logObject; $this->db = $database->getConnection();
if (isset($logObject) && method_exists($logObject, 'insertLog')) { }
$this->logger = $logObject;
} else { /**
$this->logger = new \App\Core\NullLogger(); * 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 * Retrieve log entries from the database.
* @param string $level *
* @param string $message * @param int $user_id The ID of the user whose logs are being retrieved.
* @param array $context * @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 { public function readLog($user_id, $scope, $offset=0, $items_per_page='', $filters=[]) {
$this->logger->log($level, $message, $context); $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 // Check if email exists
$query = $this->db->prepare(" $query = $this->db->prepare("
SELECT u.id, um.email SELECT u.id, um.email
FROM user u FROM users u
JOIN user_meta um ON u.id = um.user_id JOIN users_meta um ON u.id = um.user_id
WHERE um.email = :email" WHERE um.email = :email"
); );
$query->bindParam(':email', $email); $query->bindParam(':email', $email);
@ -67,23 +67,28 @@ class PasswordReset {
// Send email with reset link // Send email with reset link
$to = $user['email']; $to = $user['email'];
// Load email helper
require_once __DIR__ . '/../helpers/email_helper.php';
$subject = "{$config['site_name']} - Password reset request"; $subject = "{$config['site_name']} - Password reset request";
$message = "Dear user,\n\n";
$message .= "We received a request to reset your password for your {$config['site_name']} account.\n\n";
$message .= "To set a new password, please click the link below:\n\n";
$message .= $resetLink . "\n\n";
$message .= "This link will expire in 1 hour for security reasons.\n\n";
$message .= "If you did not request this password reset, please ignore this email. Your account remains secure.\n\n";
if (!empty($config['site_name'])) {
$message .= "Best regards,\n";
$message .= "The {$config['site_name']} team\n";
if (!empty($config['site_slogan'])) {
$message .= ":: {$config['site_slogan']} ::";
}
}
$variables = [ $headers = [
'site_name' => $config['site_name'],
'reset_link' => $resetLink,
'site_slogan' => $config['site_slogan'] ?? ''
];
$additionalHeaders = [
'From' => "noreply@{$config['domain']}", 'From' => "noreply@{$config['domain']}",
'Reply-To' => "noreply@{$config['domain']}" 'Reply-To' => "noreply@{$config['domain']}",
'X-Mailer' => 'PHP/' . phpversion()
]; ];
if (!sendTemplateEmail($to, $subject, 'password_reset', $variables, $config, $additionalHeaders)) { if (!mail($to, $subject, $message, $headers)) {
return ['success' => false, 'message' => 'Failed to send reset email']; return ['success' => false, 'message' => 'Failed to send reset email'];
} }

View File

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

View File

@ -1,20 +1,16 @@
<?php <?php
use App\Core\NullLogger;
class RateLimiter { class RateLimiter {
public $db; public $db;
private $database; private $log;
/** @var mixed NullLogger (or PSR-3 logger) or plugin Log */
private $logger;
public $maxAttempts = 5; // Maximum login attempts public $maxAttempts = 5; // Maximum login attempts
public $decayMinutes = 15; // Time window in minutes public $decayMinutes = 15; // Time window in minutes
public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist
public $autoBlacklistDuration = 24; // Hours to blacklist for public $autoBlacklistDuration = 24; // Hours to blacklist for
public $authRatelimitTable = 'security_rate_auth'; // For rate limiting username/password attempts public $authRatelimitTable = 'login_attempts'; // For username/password attempts
public $pagesRatelimitTable = 'security_rate_page'; // For rate limiting page requests public $pagesRatelimitTable = 'pages_rate_limits'; // For page requests
public $whitelistTable = 'security_ip_whitelist'; // For whitelisting IPs and network ranges public $whitelistTable = 'ip_whitelist';
public $blacklistTable = 'security_ip_blacklist'; // For blacklisting IPs and network ranges public $blacklistTable = 'ip_blacklist';
private $pageLimits = [ private $pageLimits = [
// Default rate limits per minute // Default rate limits per minute
'default' => 60, 'default' => 60,
@ -26,23 +22,9 @@ class RateLimiter {
'config' => 10 'config' => 10
]; ];
/** public function __construct($database) {
* @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;
$this->db = $database->getConnection(); $this->db = $database->getConnection();
// Initialize logger (plugin Log if present or NullLogger otherwise) $this->log = new Log($database);
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->createTablesIfNotExist(); $this->createTablesIfNotExist();
} }
@ -50,47 +32,42 @@ class RateLimiter {
private function createTablesIfNotExist() { private function createTablesIfNotExist() {
// Authentication attempts table // Authentication attempts table
$sql = "CREATE TABLE IF NOT EXISTS {$this->authRatelimitTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->authRatelimitTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address VARCHAR(45) NOT NULL, ip_address TEXT NOT NULL,
username VARCHAR(255) NOT NULL, username TEXT NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP, attempted_at TEXT DEFAULT (DATETIME('now'))
INDEX idx_ip_username (ip_address, username)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
// Pages rate limits table // Pages rate limits table
$sql = "CREATE TABLE IF NOT EXISTS {$this->pagesRatelimitTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->pagesRatelimitTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address VARCHAR(45) NOT NULL, ip_address TEXT NOT NULL,
endpoint VARCHAR(255) NOT NULL, endpoint TEXT NOT NULL,
request_time DATETIME DEFAULT CURRENT_TIMESTAMP, request_time DATETIME DEFAULT CURRENT_TIMESTAMP
INDEX idx_ip_endpoint (ip_address, endpoint),
INDEX idx_request_time (request_time)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
// IP whitelist table // IP whitelist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address VARCHAR(45) NOT NULL, ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT FALSE, is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
description VARCHAR(255), description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT (DATETIME('now')),
created_by VARCHAR(255), created_by TEXT
UNIQUE KEY unique_ip (ip_address)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
// IP blacklist table // IP blacklist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address VARCHAR(45) NOT NULL, ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT FALSE, is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
reason VARCHAR(255), reason TEXT,
expiry_time TIMESTAMP NULL, expiry_time TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT (DATETIME('now')),
created_by VARCHAR(255), created_by TEXT
UNIQUE KEY unique_ip (ip_address)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
@ -104,7 +81,7 @@ class RateLimiter {
]; ];
// Insert default whitelisted IPs if they don't exist // 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) (ip_address, is_network, description, created_by)
VALUES (?, ?, ?, 'system')"); VALUES (?, ?, ?, 'system')");
foreach ($defaultIps as $ip) { foreach ($defaultIps as $ip) {
@ -120,7 +97,7 @@ class RateLimiter {
['203.0.113.0/24', true, 'TEST-NET-3 Documentation space - RFC 5737'] ['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) (ip_address, is_network, reason, created_by)
VALUES (?, ?, ?, 'system')"); VALUES (?, ?, ?, 'system')");
@ -135,7 +112,7 @@ class RateLimiter {
*/ */
public function getRecentAttempts($ip) { public function getRecentAttempts($ip) {
$stmt = $this->db->prepare("SELECT COUNT(*) as attempts FROM {$this->authRatelimitTable} $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]); $stmt->execute([$ip, $this->decayMinutes]);
$result = $stmt->fetch(PDO::FETCH_ASSOC); $result = $stmt->fetch(PDO::FETCH_ASSOC);
return intval($result['attempts']); return intval($result['attempts']);
@ -232,19 +209,15 @@ class RateLimiter {
if ($this->isIpBlacklisted($ip)) { if ($this->isIpBlacklisted($ip)) {
$message = "Cannot whitelist {$ip} - IP is currently blacklisted"; $message = "Cannot whitelist {$ip} - IP is currently blacklisted";
if ($userId) { 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); Feedback::flash('ERROR', 'DEFAULT', $message);
} }
return false; 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) (ip_address, is_network, description, created_by)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)");
ON DUPLICATE KEY UPDATE
is_network = VALUES(is_network),
description = VALUES(description),
created_by = VALUES(created_by)");
$result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]); $result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]);
@ -256,14 +229,14 @@ class RateLimiter {
$createdBy, $createdBy,
$description $description
); );
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']); $this->log->insertLog($userId ?? 0, $logMessage, 'system');
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { 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()); Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage());
} }
return false; return false;
@ -291,14 +264,14 @@ class RateLimiter {
$removedBy, $removedBy,
$ipDetails['created_by'] $ipDetails['created_by']
); );
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']); $this->log->insertLog($userId ?? 0, $logMessage, 'system');
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { 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()); Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage());
} }
return false; return false;
@ -311,7 +284,7 @@ class RateLimiter {
if ($this->isIpWhitelisted($ip)) { if ($this->isIpWhitelisted($ip)) {
$message = "Cannot blacklist {$ip} - IP is currently whitelisted"; $message = "Cannot blacklist {$ip} - IP is currently whitelisted";
if ($userId) { 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); Feedback::flash('ERROR', 'DEFAULT', $message);
} }
return false; return false;
@ -319,14 +292,9 @@ class RateLimiter {
$expiryTime = $expiryHours ? date('Y-m-d H:i:s', strtotime("+{$expiryHours} hours")) : null; $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) (ip_address, is_network, reason, expiry_time, created_by)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)");
ON DUPLICATE KEY UPDATE
is_network = VALUES(is_network),
reason = VALUES(reason),
expiry_time = VALUES(expiry_time),
created_by = VALUES(created_by)");
$result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]); $result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]);
@ -339,13 +307,13 @@ class RateLimiter {
$reason, $reason,
$expiryTime ?? 'never' $expiryTime ?? 'never'
); );
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']); $this->log->insertLog($userId ?? 0, $logMessage, 'system');
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { 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()); Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage());
} }
return false; return false;
@ -373,13 +341,13 @@ class RateLimiter {
$ipDetails['created_by'], $ipDetails['created_by'],
$ipDetails['reason'] $ipDetails['reason']
); );
$this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']); $this->log->insertLog($userId ?? 0, $logMessage, 'system');
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { 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()); Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage());
} }
return false; return false;
@ -404,17 +372,17 @@ class RateLimiter {
try { try {
// Remove expired blacklist entries // Remove expired blacklist entries
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable} $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(); $stmt->execute();
// Clean old login attempts // Clean old login attempts
$stmt = $this->db->prepare("DELETE FROM {$this->authRatelimitTable} $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]); $stmt->execute([':minutes' => $this->decayMinutes]);
return true; return true;
} catch (Exception $e) { } 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()); Feedback::flash('ERROR', 'DEFAULT', "Failed to cleanup expired entries: " . $e->getMessage());
return false; return false;
} }
@ -443,7 +411,7 @@ class RateLimiter {
$sql = "SELECT COUNT(*) as total_attempts $sql = "SELECT COUNT(*) as total_attempts
FROM {$this->authRatelimitTable} FROM {$this->authRatelimitTable}
WHERE ip_address = :ip 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 = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
':ip' => $ipAddress, ':ip' => $ipAddress,
@ -455,12 +423,7 @@ class RateLimiter {
return $result['total_attempts'] < $this->autoBlacklistThreshold; return $result['total_attempts'] < $this->autoBlacklistThreshold;
} }
public function attempt($username, $ipAddress, $failed = true) { public function attempt($username, $ipAddress) {
// Only record failed attempts
if (!$failed) {
return true;
}
// Record this attempt // Record this attempt
$sql = "INSERT INTO {$this->authRatelimitTable} (ip_address, username) VALUES (:ip, :username)"; $sql = "INSERT INTO {$this->authRatelimitTable} (ip_address, username) VALUES (:ip, :username)";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
@ -481,7 +444,7 @@ class RateLimiter {
FROM {$this->authRatelimitTable} FROM {$this->authRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND username = :username 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 = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -517,7 +480,7 @@ class RateLimiter {
public function clearOldAttempts() { public function clearOldAttempts() {
$sql = "DELETE FROM {$this->authRatelimitTable} $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 = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -530,7 +493,7 @@ class RateLimiter {
FROM {$this->authRatelimitTable} FROM {$this->authRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND username = :username 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 = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -572,7 +535,7 @@ class RateLimiter {
FROM {$this->pagesRatelimitTable} FROM {$this->pagesRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND endpoint = :endpoint 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 = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -603,7 +566,7 @@ class RateLimiter {
*/ */
private function cleanOldPageRequests() { private function cleanOldPageRequests() {
$sql = "DELETE FROM {$this->pagesRatelimitTable} $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 = $this->db->prepare($sql);
$stmt->execute(); $stmt->execute();
@ -615,10 +578,8 @@ class RateLimiter {
private function getPageLimitForEndpoint($endpoint, $userId = null) { private function getPageLimitForEndpoint($endpoint, $userId = null) {
// Admin users get higher limits // Admin users get higher limits
if ($userId) { if ($userId) {
// Check admin rights directly from database $userObj = new User($this->db);
$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 = ?'); if ($userObj->hasRight($userId, 'admin')) {
$stmt->execute([$userId, 'superuser']);
if ($stmt->fetchColumn() > 0) {
return $this->pageLimits['admin']; return $this->pageLimits['admin'];
} }
} }
@ -654,7 +615,7 @@ class RateLimiter {
FROM {$this->pagesRatelimitTable} FROM {$this->pagesRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND endpoint = :endpoint 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 = $this->db->prepare($sql);
$stmt->execute([ $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 <?php
// Already required in index.php, but we require it here,
// because this class could be used standalone
require_once __DIR__ . '/../helpers/logger_loader.php';
/** /**
* Class TwoFactorAuthentication * Class TwoFactorAuthentication
* *
@ -16,7 +12,7 @@ class TwoFactorAuthentication {
private $period = 30; // Time step in seconds (T0) private $period = 30; // Time step in seconds (T0)
private $digits = 6; // Number of digits in TOTP code private $digits = 6; // Number of digits in TOTP code
private $algorithm = 'sha1'; // HMAC algorithm private $algorithm = 'sha1'; // HMAC algorithm
private $issuer = 'Jilo'; private $issuer = 'TotalMeet';
private $window = 1; // Time window of 1 step before/after private $window = 1; // Time window of 1 step before/after
/** /**
@ -102,10 +98,7 @@ class TwoFactorAuthentication {
if ($code !== null) { if ($code !== null) {
// Verify the setup code // Verify the setup code
if (!$this->verify($userId, $code)) { if (!$this->verify($userId, $code)) {
app_log('warning', '2FA setup code verification failed', [ error_log("Code verification failed");
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
@ -124,10 +117,7 @@ class TwoFactorAuthentication {
if ($this->db->inTransaction()) { if ($this->db->inTransaction()) {
$this->db->rollBack(); $this->db->rollBack();
} }
app_log('error', '2FA enable error: ' . $e->getMessage(), [ error_log('2FA enable error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -167,10 +157,7 @@ class TwoFactorAuthentication {
return false; return false;
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', '2FA verification error: ' . $e->getMessage(), [ error_log('2FA verification error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -364,10 +351,7 @@ class TwoFactorAuthentication {
return false; return false;
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', 'Backup code verification error: ' . $e->getMessage(), [ error_log('Backup code verification error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -394,10 +378,7 @@ class TwoFactorAuthentication {
return $stmt->execute([$userId]); return $stmt->execute([$userId]);
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', '2FA disable error: ' . $e->getMessage(), [ error_log('2FA disable error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -416,10 +397,7 @@ class TwoFactorAuthentication {
return $result && $result['enabled']; return $result && $result['enabled'];
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', '2FA status check error: ' . $e->getMessage(), [ error_log('2FA status check error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -435,10 +413,7 @@ class TwoFactorAuthentication {
return $stmt->fetch(PDO::FETCH_ASSOC); return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', 'Failed to get user 2FA settings: ' . $e->getMessage(), [ error_log('Failed to get user 2FA settings: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return null; return null;
} }
} }

View File

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

View File

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

View File

@ -10,18 +10,8 @@ return [
'domain' => 'localhost', 'domain' => 'localhost',
// subfolder for the web app, if any // subfolder for the web app, if any
'folder' => '/jilo-web/', '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', '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 // set to false to disable new registrations
'registration_enabled' => true, 'registration_enabled' => true,
// will be displayed on login screen // will be displayed on login screen
@ -32,26 +22,18 @@ return [
//******************************************* //*******************************************
// database // database
'db_type' => 'mariadb', 'db' => [
// DB type for the web app, currently only "sqlite" is used
'sqlite' => [ 'db_type' => 'sqlite',
// default is ../app/jilo-web.db
'sqlite_file' => '../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
'avatars_path' => 'uploads/avatars/', 'avatars_path' => 'uploads/avatars/',
// default avatar // default avatar
'default_avatar' => 'static/default_avatar.png', 'default_avatar' => 'static/default_avatar.png',
// system info // system info
'version' => '0.4.1', 'version' => '0.4',
// development has verbose error messages, production has not // development has verbose error messages, production has not
'environment' => 'development', '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,37 +0,0 @@
<?php
namespace App\Core;
class PluginManager
{
/**
* Loads all enabled plugins from the given directory.
*
* @param string $pluginsDir
* @return array<string, array{path: string, meta: array}>
*/
public static function load(string $pluginsDir): array
{
$enabled = [];
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginPath) {
$manifest = $pluginPath . '/plugin.json';
if (!file_exists($manifest)) {
continue;
}
$meta = json_decode(file_get_contents($manifest), true);
if (empty($meta['enabled'])) {
continue;
}
$name = basename($pluginPath);
$enabled[$name] = [
'path' => $pluginPath,
'meta' => $meta,
];
$bootstrap = $pluginPath . '/bootstrap.php';
if (file_exists($bootstrap)) {
include_once $bootstrap;
}
}
return $enabled;
}
}

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,60 +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
* @return string Rendered template content
*/
function renderEmailTemplate($templateName, $variables = []) {
$templateFile = __DIR__ . '/../templates/' . $templateName . '.txt';
if (!file_exists($templateFile)) {
throw new RuntimeException("Email template '$templateName' not found");
}
$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
* @return bool Success status
*/
function sendTemplateEmail($to, $subject, $templateName, $variables, $config, $additionalHeaders = []) {
try {
$message = renderEmailTemplate($templateName, $variables);
$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 // Get any flash messages from previous request
$flash_messages = Feedback::getFlash(); $flash_messages = Feedback::getFlash();
if (!empty($flash_messages)) { if (!empty($flash_messages)) {
$system_messages = array_merge($system_messages ?? [], array_map(function($flash) { $system_messages = array_merge($system_messages ?? [], array_map(function($flash) {
return [ return [
@ -16,8 +15,7 @@ if (!empty($flash_messages)) {
'key' => $flash['key'], 'key' => $flash['key'],
'custom_message' => $flash['custom_message'] ?? null, 'custom_message' => $flash['custom_message'] ?? null,
'dismissible' => $flash['dismissible'] ?? false, 'dismissible' => $flash['dismissible'] ?? false,
'small' => $flash['small'] ?? false, 'small' => $flash['small'] ?? false
'sanitize' => $flash['sanitize'] ?? true
]; ];
}, $flash_messages)); }, $flash_messages));
} }
@ -30,8 +28,7 @@ if (isset($system_messages) && is_array($system_messages)) {
$msg['key'], $msg['key'],
$msg['custom_message'] ?? null, $msg['custom_message'] ?? null,
$msg['dismissible'] ?? false, $msg['dismissible'] ?? false,
$msg['small'] ?? false, $msg['small'] ?? false
$msg['sanitize'] ?? true
); );
} }
} }

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 <?php
/**
* Pagination helper
* @param string $url Base URL for pagination links
* @param int $browse_page Current page number
* @param int $page_count Total number of pages
*/
function renderPagination($url, $browse_page = 1, $page_count = 1) {
$param = ''; $param = '';
// calls if (isset($_REQUEST['id'])) {
$param .= '&id=' . htmlspecialchars($_REQUEST['id']);
}
if (isset($_REQUEST['name'])) { if (isset($_REQUEST['name'])) {
$param .= '&name=' . htmlspecialchars($_REQUEST['name']); $param .= '&name=' . htmlspecialchars($_REQUEST['name']);
} }
if (isset($_REQUEST['invitees'])) { if (isset($_REQUEST['ip'])) {
$param .= '&invitees=' . htmlspecialchars($_REQUEST['invitees']); $param .= '&ip=' . htmlspecialchars($_REQUEST['ip']);
} }
if (isset($_REQUEST['description'])) { if (isset($_REQUEST['event'])) {
$param .= '&description=' . htmlspecialchars($_REQUEST['description']); $param .= '&event=' . htmlspecialchars($_REQUEST['event']);
} }
if (isset($_REQUEST['filter'])) {
$param .= '&filter=' . htmlspecialchars($_REQUEST['filter']);
}
// contacts
if (isset($_REQUEST['name'])) {
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
}
if (isset($_REQUEST['phone'])) {
$param .= '&phone=' . htmlspecialchars($_REQUEST['phone']);
}
if (isset($_REQUEST['email'])) {
$param .= '&email=' . htmlspecialchars($_REQUEST['email']);
}
// messages
if (isset($_REQUEST['from'])) {
$param .= '&from=' . htmlspecialchars($_REQUEST['from']);
}
if (isset($_REQUEST['to'])) {
$param .= '&to=' . htmlspecialchars($_REQUEST['to']);
}
if (isset($_REQUEST['subject'])) {
$param .= '&subject=' . htmlspecialchars($_REQUEST['subject']);
}
// notifications
if (isset($_REQUEST['message'])) {
$param .= '&message=' . htmlspecialchars($_REQUEST['message']);
}
// time period
if (isset($_REQUEST['from_time'])) { if (isset($_REQUEST['from_time'])) {
$param .= '&from_time=' . htmlspecialchars($_REQUEST['from_time']); $param .= '&from_time=' . htmlspecialchars($from_time);
} }
if (isset($_REQUEST['until_time'])) { if (isset($_REQUEST['until_time'])) {
$param .= '&until_time=' . htmlspecialchars($_REQUEST['until_time']); $param .= '&until_time=' . htmlspecialchars($until_time);
} }
$max_visible_pages = 10; $max_visible_pages = 10;
$step_pages = 10; $step_pages = 10;
echo '<div class="tm-pagination text-center"><div class="pagination">';
if ($browse_page > 1) { if ($browse_page > 1) {
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=1' . $param . '">first</a>'; echo '<span><a href="' . htmlspecialchars($url) . '&p=1">first</a></span>';
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page - 1) . $param . '">&laquo;</a>';
} else { } else {
echo '<span class="pagination-link disabled">first</span>'; echo '<span>first</span>';
echo '<span class="pagination-link disabled">&laquo;</span>';
} }
for ($i = 1; $i <= $page_count; $i++) { for ($i = 1; $i <= $page_count; $i++) {
// always show the first, last, step pages (10, 20, 30, etc.), // always show the first, last, step pages (10, 20, 30, etc.),
// and pages around current page // and the pages close to the current one
if ($i == 1 || $i == $page_count || if (
$i % $step_pages == 0 || $i === 1 || // first page
abs($i - $browse_page) < $max_visible_pages / 2) { $i === $page_count || // last page
$i === $browse_page || // current page
$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) { if ($browse_page < $page_count) {
echo '<span class="pagination-link active">' . $i . '</span>'; echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) +1) . '">>></a></span>';
} else {
echo '<span>>></span>';
}
} else { } 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 || // show ellipses between distant pages
($i > $browse_page + $max_visible_pages / 2 && $i % $step_pages == 1) || } elseif (
($i < $browse_page - $max_visible_pages / 2 && $i % $step_pages == $step_pages - 1)) { $i === $browse_page -3 ||
echo '<span class="pagination-link pagination-ellipsis disabled">...</span>'; $i === $browse_page +3
) {
echo '<span>...</span>';
} }
} }
if ($browse_page < $page_count) { if ($browse_page < $page_count) {
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page + 1) . $param . '">&raquo;</a>'; echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($page_count)) . '">last</a></span>';
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $page_count . $param . '">last</a>';
} else { } else {
echo '<span class="pagination-link disabled">&raquo;</span>'; echo '<span>last</span>';
echo '<span class="pagination-link disabled">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 <?php
require_once __DIR__ . '/../helpers/security.php'; require_once __DIR__ . '/../helpers/security.php';
require_once __DIR__ . '/../helpers/logs.php';
function applyCsrfMiddleware() { function applyCsrfMiddleware() {
global $logObject, $user_IP; global $logObject;
$security = SecurityHelper::getInstance(); $security = SecurityHelper::getInstance();
// Skip CSRF check for GET requests // Skip CSRF check for GET requests
@ -33,14 +34,14 @@ function applyCsrfMiddleware() {
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!$security->verifyCsrfToken($token)) { if (!$security->verifyCsrfToken($token)) {
// Log CSRF attempt // Log CSRF attempt
$ipAddress = $user_IP; $ipAddress = getUserIP();
$logMessage = sprintf( $logMessage = sprintf(
"CSRF attempt detected - IP: %s, Page: %s, User: %s", "CSRF attempt detected - IP: %s, Page: %s, User: %s",
$ipAddress, $ipAddress,
$_GET['page'] ?? 'unknown', $_GET['page'] ?? 'unknown',
$_SESSION['username'] ?? 'anonymous' $_SESSION['username'] ?? 'anonymous'
); );
$logObject->log('error', $logMessage, ['user_id' => null, 'scope' => 'system']); $logObject->insertLog(null, $logMessage, 'system');
// Return error message // Return error message
http_response_code(403); http_response_code(403);

View File

@ -1,63 +1,61 @@
<?php <?php
// connect to database // connect to database
function connectDB($config) { function connectDB($config, $database = '', $dbFile = '', $platformId = '') {
// sqlite database file
if ($config['db_type'] === 'sqlite') { // connecting ti a jilo sqlite database
if ($database === 'jilo') {
try { try {
$dbFile = $config['sqlite']['sqlite_file'] ?? null;
if (!$dbFile || !file_exists($dbFile)) { 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([ $db = new Database([
'type' => $config['db_type'], 'type' => 'sqlite',
'dbFile' => $dbFile, 'dbFile' => $dbFile,
]); ]);
$pdo = $db->getConnection(); return ['db' => $db, 'error' => null];
} catch (Exception $e) { } catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to DB.', $e->getMessage())); return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
return false;
} }
return $db;
// mysql/mariadb database // connecting to a jilo-web database of the web app
} 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
} else { } else {
Feedback::flash('ERROR', 'DEFAULT', getError("Error: unknown database type \"{$config['db_type']}\""));
return false;
}
} // sqlite database file
if ($config['db']['db_type'] === 'sqlite') {
// connect to Jilo database try {
function connectJiloDB($config, $dbFile = '', $platformId = '') { $db = new Database([
try { 'type' => $config['db']['db_type'],
if (!$dbFile || !file_exists($dbFile)) { 'dbFile' => $config['db']['sqlite_file'],
throw new Exception(getError("Invalid platform ID \"{$platformId}\", database file \"{$dbFile}\" not found.")); ]);
$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 <?php
require_once __DIR__ . '/../classes/ratelimiter.php'; require_once __DIR__ . '/../classes/ratelimiter.php';
require_once __DIR__ . '/../helpers/logs.php';
/** /**
* Rate limit middleware for page requests * 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 * @return bool True if request is allowed, false if rate limited
*/ */
function checkRateLimit($database, $endpoint, $userId = null, $existingRateLimiter = null) { function checkRateLimit($database, $endpoint, $userId = null, $existingRateLimiter = null) {
global $app_root, $user_IP; global $app_root;
$isTest = defined('PHPUNIT_RUNNING'); $isTest = defined('PHPUNIT_RUNNING');
$rateLimiter = $existingRateLimiter ?? new RateLimiter($database); $rateLimiter = $existingRateLimiter ?? new RateLimiter($database);
$ipAddress = $user_IP; $ipAddress = getUserIP();
// Check if request is allowed // Check if request is allowed
if (!$rateLimiter->isPageRequestAllowed($ipAddress, $endpoint, $userId)) { 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_SUCCESS' => 'Login successful.',
'LOGIN_FAILED' => 'Login failed. Please check your credentials.', 'LOGIN_FAILED' => 'Login failed. Please check your credentials.',
'LOGOUT_SUCCESS' => 'Logout successful. You can log in again.', '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_BLACKLISTED' => 'Access denied. Your IP address is blacklisted.',
'IP_NOT_WHITELISTED' => 'Access denied. Your IP address is not whitelisted.', 'IP_NOT_WHITELISTED' => 'Access denied. Your IP address is not whitelisted.',
'TOO_MANY_ATTEMPTS' => 'Too many login attempts. Please try again later.', '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' => [ 'SECURITY' => [
'WHITELIST_ADD_SUCCESS' => 'IP address successfully added to whitelist.', 'WHITELIST_ADD_SUCCESS' => 'IP address successfully added to whitelist.',
'WHITELIST_ADD_FAILED' => 'Failed to add IP 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.', 'PERMISSION_DENIED' => 'Permission denied. You do not have the required rights.',
'IP_REQUIRED' => 'IP address is required.', 'IP_REQUIRED' => 'IP address is required.',
], ],
'THEME' => [ 'REGISTER' => [
'THEME_CHANGE_SUCCESS' => 'Theme has been changed successfully.', 'SUCCESS' => 'Registration successful. You can log in now.',
'THEME_CHANGE_FAILED' => 'Failed to change theme. The selected theme may not be available.', 'FAILED' => 'Registration failed: %s',
'DISABLED' => 'Registration is disabled.',
], ],
'SYSTEM' => [ 'SYSTEM' => [
'DB_ERROR' => 'Error connecting to the database: %s', 'DB_ERROR' => 'Error connecting to the database: %s',
'DB_CONNECT_ERROR' => 'Error connecting to DB: %s', 'DB_CONNECT_ERROR' => 'Error connecting to DB: %s',
'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"', 'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"',
'MIGRATIONS_PENDING' => '%s',
'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

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

View File

@ -9,7 +9,7 @@
*/ */
// connect to database // 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 DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
@ -101,7 +101,7 @@ if ($response['db'] === null) {
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// display the widget // display the widget
include '../app/templates/components.php'; include '../app/templates/components.php';

View File

@ -9,7 +9,7 @@
*/ */
// connect to database // 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 DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
@ -160,7 +160,7 @@ if ($response['db'] === null) {
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// display the widget // display the widget
include '../app/templates/conferences.php'; include '../app/templates/conferences.php';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,13 +19,13 @@ unset($error);
try { try {
// connect to database // connect to database
$db = connectDB($config); $db = connectDB($config)['db'];
// Initialize RateLimiter // Initialize RateLimiter
require_once '../app/classes/ratelimiter.php'; require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($db); $rateLimiter = new RateLimiter($db);
// Get user IP // Get user IP
require_once '../app/helpers/ip_helper.php';
$user_IP = getUserIP(); $user_IP = getUserIP();
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
@ -33,23 +33,21 @@ try {
if ($action === 'verify' && isset($_SESSION['2fa_pending_user_id'])) { if ($action === 'verify' && isset($_SESSION['2fa_pending_user_id'])) {
// Handle 2FA verification // Handle 2FA verification
$code = $_POST['code'] ?? ''; $code = $_POST['code'] ?? '';
$pending2FA = Session::get2FAPending(); $userId = $_SESSION['2fa_pending_user_id'];
$username = $_SESSION['2fa_pending_username'];
if (!$pending2FA) { $rememberMe = isset($_SESSION['2fa_pending_remember']);
header('Location: ' . htmlspecialchars($app_root) . '?page=login');
exit();
}
require_once '../app/classes/twoFactorAuth.php'; require_once '../app/classes/twoFactorAuth.php';
$twoFactorAuth = new TwoFactorAuthentication($db); $twoFactorAuth = new TwoFactorAuthentication($db);
if ($twoFactorAuth->verify($pending2FA['user_id'], $code)) { if ($twoFactorAuth->verify($userId, $code)) {
// Complete login // Complete login
handleSuccessfulLogin($pending2FA['user_id'], $pending2FA['username'], handleSuccessfulLogin($userId, $username, $rememberMe, $config, $logObject, $user_IP);
$pending2FA['remember_me'], $config, $app_root, $logObject, $user_IP);
// Clean up 2FA session data // Clean up 2FA session data
Session::clear2FAPending(); unset($_SESSION['2fa_pending_user_id']);
unset($_SESSION['2fa_pending_username']);
unset($_SESSION['2fa_pending_remember']);
exit(); exit();
} }
@ -60,10 +58,7 @@ try {
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Make userId available to template
$userId = $pending2FA['user_id'];
// Load the 2FA verification template // Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php'; include '../app/templates/credentials-2fa-verify.php';
@ -97,7 +92,7 @@ try {
// Process reset request // Process reset request
require_once '../app/classes/passwordReset.php'; require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db, $config); $resetHandler = new PasswordReset($db);
$result = $resetHandler->requestReset($email); $result = $resetHandler->requestReset($email);
// Always show same message whether email exists or not for security // Always show same message whether email exists or not for security
@ -115,7 +110,7 @@ try {
$security->generateCsrfToken(); $security->generateCsrfToken();
// Load the forgot password form // Load the forgot password form
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
include '../app/templates/form-password-forgot.php'; include '../app/templates/form-password-forgot.php';
exit(); exit();
@ -123,7 +118,7 @@ try {
// Handle password reset // Handle password reset
try { try {
require_once '../app/classes/passwordReset.php'; require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db, $config); $resetHandler = new PasswordReset($db);
$token = $_GET['token']; $token = $_GET['token'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@ -175,7 +170,7 @@ try {
} }
// Show reset password form // Show reset password form
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
include '../app/templates/form-password-reset.php'; include '../app/templates/form-password-reset.php';
exit(); exit();
@ -201,7 +196,8 @@ try {
], ],
'password' => [ 'password' => [
'type' => 'string', 'type' => 'string',
'required' => true 'required' => true,
'min' => 5
] ]
]; ];
@ -224,6 +220,9 @@ try {
if ($rateLimiter->tooManyAttempts($username, $user_IP)) { if ($rateLimiter->tooManyAttempts($username, $user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']); throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
} }
// Record this attempt
$rateLimiter->attempt($username, $user_IP);
} }
// Attempt login // Attempt login
@ -233,8 +232,11 @@ try {
switch ($loginResult['status']) { switch ($loginResult['status']) {
case 'requires_2fa': case 'requires_2fa':
// Store pending 2FA info // Store pending 2FA info
Session::store2FAPending($loginResult['user_id'], $loginResult['username'], $_SESSION['2fa_pending_user_id'] = $loginResult['user_id'];
isset($formData['remember_me'])); $_SESSION['2fa_pending_username'] = $loginResult['username'];
if (isset($formData['remember_me'])) {
$_SESSION['2fa_pending_remember'] = true;
}
// Redirect to 2FA verification // Redirect to 2FA verification
header('Location: ?page=login&action=verify'); header('Location: ?page=login&action=verify');
@ -243,7 +245,7 @@ try {
case 'success': case 'success':
// Complete login // Complete login
handleSuccessfulLogin($loginResult['user_id'], $loginResult['username'], 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(); exit();
default: default:
@ -256,9 +258,8 @@ try {
// Log the failed attempt // Log the failed attempt
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
if (isset($username)) { if (isset($username)) {
$userId = $userObject->getUserId($username)[0]['id'] ?? 0; $user_id = $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']); $logObject->insertLog($user_id, "Login: Failed login attempt for user \"$username\". IP: $user_IP. Reason: {$e->getMessage()}", 'user');
$rateLimiter->attempt($username, $user_IP);
} }
} }
} }
@ -268,11 +269,11 @@ try {
// Show configured login message if any // Show configured login message if any
if (!empty($config['login_message'])) { 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 // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/form-login.php'; 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 * Handle successful login by setting up session and cookies
*/ */
function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $app_root, $logObject, $userIP) { function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $logObject, $userIP) {
// Create authenticated session if ($rememberMe) {
Session::createAuthSession($userId, $username, $rememberMe, $config); // 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 // 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'); Feedback::flash('LOGIN', 'LOGIN_SUCCESS');
header('Location: ' . htmlspecialchars($app_root));
// 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();
} }

View File

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

View File

@ -9,7 +9,7 @@
*/ */
// connect to database // 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 DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
@ -170,7 +170,7 @@ if ($response['db'] === null) {
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// display the widget // display the widget
include '../app/templates/participants.php'; 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'] ?? ''; $action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? ''; $item = $_REQUEST['item'] ?? '';
// pass the user details to the profile hooks
$profileHooksContext = [
'userId' => $userId ?? null,
'db' => $db ?? null,
'app_root' => $app_root ?? '/',
'user' => $userDetails[0] ?? null,
];
if (class_exists('\\App\\Core\\HookDispatcher')) {
$profileHooksContext = \App\Core\HookDispatcher::applyFilters('profile.context', $profileHooksContext);
}
// plugins can add additional panels to the profile page
$profilePanelsContext = $profileHooksContext;
// if a form is submitted, it's from the edit page // if a form is submitted, it's from the edit page
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
@ -44,11 +30,11 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Apply rate limiting for profile operations // Apply rate limiting for profile operations
require_once '../app/includes/rate_limit_middleware.php'; require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'profile', $userId); checkRateLimit($dbWeb, 'profile', $user_id);
// avatar removal // avatar removal
if ($item === 'avatar' && $action === 'remove') { if ($item === 'avatar' && $action === 'remove') {
$validator = new Validator(['user_id' => $userId]); $validator = new Validator(['user_id' => $user_id]);
$rules = [ $rules = [
'user_id' => [ 'user_id' => [
'required' => true, 'required' => true,
@ -62,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
exit(); 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) { if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Avatar for user \"{$userDetails[0]['username']}\" is removed."); Feedback::flash('NOTICE', 'DEFAULT', "Avatar for user \"{$userDetails[0]['username']}\" is removed.");
} else { } else {
@ -103,54 +89,50 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
'timezone' => htmlspecialchars($_POST['timezone'] ?? ''), 'timezone' => htmlspecialchars($_POST['timezone'] ?? ''),
'bio' => htmlspecialchars($_POST['bio'] ?? ''), 'bio' => htmlspecialchars($_POST['bio'] ?? ''),
]; ];
$result = $userObject->editUser($userId, $updatedUser); $result = $userObject->editUser($user_id, $updatedUser);
if ($result === true) { 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 { } else {
Feedback::flash('ERROR', 'DEFAULT', "Editing the user details failed. Error: $result"); Feedback::flash('ERROR', 'DEFAULT', "Editing the user details failed. Error: $result");
} }
// update the rights // update the rights
// Get current rights IDs if (isset($_POST['rights'])) {
$userRightsIds = array_column($userRights, 'right_id'); $validator = new Validator(['rights' => $_POST['rights']]);
$rules = [
'rights' => [
'array' => true
]
];
// If no rights are selected, remove all rights if (!$validator->validate($rules)) {
if (!isset($_POST['rights'])) { Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
$_POST['rights'] = []; header("Location: $app_root?page=profile");
} exit();
$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);
} }
}
// what rights we need to remove $newRights = $_POST['rights'];
$rightsToRemove = array_diff($userRightsIds, $newRights); // extract the new right_ids
if (!empty($rightsToRemove)) { $userRightsIds = array_column($userRights, 'right_id');
foreach ($rightsToRemove as $rightId) { // what rights we need to add
$userObject->removeUserRight($userId, $rightId); $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 // update the avatar
if (!empty($_FILES['avatar_file']['tmp_name'])) { 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"); header("Location: $app_root?page=profile");
@ -174,7 +156,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isTimezoneSet = !empty($userDetails[0]['timezone']); $isTimezoneSet = !empty($userDetails[0]['timezone']);
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/profile-edit.php'; include '../app/templates/profile-edit.php';
@ -182,7 +164,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
default: default:
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/profile.php'; 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. * 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 // registration is allowed, go on
if ($config['registration_enabled'] == true) { if ($config['registration_enabled'] == true) {
try { try {
global $db, $logObject, $userObject; global $dbWeb, $logObject, $userObject;
if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) { if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) {
// Apply rate limiting // Apply rate limiting
require_once dirname(__FILE__, 4) . '/app/includes/rate_limit_middleware.php'; require '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'register'); checkRateLimit($dbWeb, 'register');
require_once '../app/classes/validator.php';
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance(); $security = SecurityHelper::getInstance();
// Sanitize input // 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 // Validate CSRF token
if (!$security->verifyCsrfToken($formData['csrf_token'] ?? '')) { if (!$security->verifyCsrfToken($formData['csrf_token'] ?? '')) {
@ -54,10 +47,6 @@ if ($config['registration_enabled'] == true) {
'confirm_password' => [ 'confirm_password' => [
'required' => true, 'required' => true,
'matches' => 'password' 'matches' => 'password'
],
'terms' => [
'required' => true,
'equals' => 'on'
] ]
]; ];
@ -67,42 +56,41 @@ if ($config['registration_enabled'] == true) {
$password = $formData['password']; $password = $formData['password'];
// registering // registering
$register = new Register($db); $result = $userObject->register($username, $password);
$result = $register->register($username, $password);
// redirect to login // redirect to login
if ($result === true) { if ($result === true) {
// Get the new user's ID for logging // Get the new user's ID for logging
$userId = $userObject->getUserId($username)[0]['id']; $user_id = $userObject->getUserId($username)[0]['id'];
$logObject->log('info', "Registration: New user \"$username\" registered successfully. IP: $user_IP", ['user_id' => $userId, 'scope' => 'user']); $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."); Feedback::flash('NOTICE', 'DEFAULT', "Registration successful. You can log in now.");
header('Location: ' . htmlspecialchars($app_root . '?page=login')); header('Location: ' . htmlspecialchars($app_root));
exit(); exit();
// registration fail, redirect to login // registration fail, redirect to login
} else { } 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"); Feedback::flash('ERROR', 'DEFAULT', "Registration failed. $result");
header('Location: ' . htmlspecialchars($app_root . '?page=register')); header('Location: ' . htmlspecialchars($app_root));
exit(); exit();
} }
} else { } else {
$error = $validator->getFirstError(); $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); Feedback::flash('ERROR', 'DEFAULT', $error);
header('Location: ' . htmlspecialchars($app_root . '?page=register')); header('Location: ' . htmlspecialchars($app_root . '?page=register'));
exit(); exit();
} }
} }
} catch (Exception $e) { } 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()); Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
} }
// Get any new feedback messages // Get any new feedback messages
include_once dirname(__FILE__, 4) . '/app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include PLUGIN_REGISTER_PATH . 'views/form-register.php'; include '../app/templates/form-register.php';
// registration disabled // registration disabled
} else { } else {

View File

@ -1,10 +1,15 @@
<?php <?php
// Check if user has any of the required rights // Check if user has any of the required rights
if (!($userObject->hasRight($userId, 'superuser') || if (!($userObject->hasRight($user_id, 'superuser') ||
$userObject->hasRight($userId, 'edit whitelist') || $userObject->hasRight($user_id, 'edit whitelist') ||
$userObject->hasRight($userId, 'edit blacklist') || $userObject->hasRight($user_id, 'edit blacklist') ||
$userObject->hasRight($userId, 'edit ratelimiting'))) { $userObject->hasRight($user_id, 'edit ratelimiting'))) {
include '../app/templates/error-unauthorized.php';
exit;
}
if (!isset($currentUser)) {
include '../app/templates/error-unauthorized.php'; include '../app/templates/error-unauthorized.php';
exit; exit;
} }
@ -14,7 +19,7 @@ $section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section'
// Initialize RateLimiter // Initialize RateLimiter
require_once '../app/classes/ratelimiter.php'; require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($db); $rateLimiter = new RateLimiter($dbWeb);
// Handle form submissions // Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { 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 // Apply rate limiting for security operations
require_once '../app/includes/rate_limit_middleware.php'; require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'security', $userId); checkRateLimit($dbWeb, 'security', $user_id);
$action = $_POST['action']; $action = $_POST['action'];
$validator = new Validator($_POST); $validator = new Validator($_POST);
@ -30,7 +35,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
try { try {
switch ($action) { switch ($action) {
case 'add_whitelist': 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'); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break; break;
} }
@ -49,7 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if ($validator->validate($rules)) { if ($validator->validate($rules)) {
$is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on'; $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'); Feedback::flash('SECURITY', 'WHITELIST_ADD_FAILED');
} else { } else {
Feedback::flash('SECURITY', 'WHITELIST_ADD_SUCCESS'); Feedback::flash('SECURITY', 'WHITELIST_ADD_SUCCESS');
@ -60,7 +65,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
break; break;
case 'remove_whitelist': 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'); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break; break;
} }
@ -74,7 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
]; ];
if ($validator->validate($rules)) { 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'); Feedback::flash('SECURITY', 'WHITELIST_REMOVE_FAILED');
} else { } else {
Feedback::flash('SECURITY', 'WHITELIST_REMOVE_SUCCESS'); Feedback::flash('SECURITY', 'WHITELIST_REMOVE_SUCCESS');
@ -85,7 +90,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
break; break;
case 'add_blacklist': 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'); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break; break;
} }
@ -111,7 +116,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
$is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on'; $is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on';
$expiry_hours = !empty($_POST['expiry_hours']) ? (int)$_POST['expiry_hours'] : null; $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'); Feedback::flash('SECURITY', 'BLACKLIST_ADD_FAILED');
} else { } else {
Feedback::flash('SECURITY', 'BLACKLIST_ADD_SUCCESS'); Feedback::flash('SECURITY', 'BLACKLIST_ADD_SUCCESS');
@ -122,7 +127,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
break; break;
case 'remove_blacklist': 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'); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break; break;
} }
@ -136,7 +141,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
]; ];
if ($validator->validate($rules)) { 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'); Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_FAILED');
} else { } else {
Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_SUCCESS'); Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_SUCCESS');
@ -168,7 +173,7 @@ $whitelisted = $rateLimiter->getWhitelistedIps();
$blacklisted = $rateLimiter->getBlacklistedIps(); $blacklisted = $rateLimiter->getBlacklistedIps();
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/security.php'; 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'; strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Initialize security helper
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? ''; $agent = $_REQUEST['agent'] ?? '';
@ -25,8 +21,8 @@ $host = $_REQUEST['host'] ?? '';
require '../app/classes/host.php'; require '../app/classes/host.php';
require '../app/classes/agent.php'; require '../app/classes/agent.php';
$hostObject = new Host($db); $hostObject = new Host($dbWeb);
$agentObject = new Agent($db); $agentObject = new Agent($dbWeb);
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
/** /**
@ -35,7 +31,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Apply rate limiting for profile operations // Apply rate limiting for profile operations
require_once '../app/includes/rate_limit_middleware.php'; require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'profile', $userId); checkRateLimit($dbWeb, 'profile', $user_id);
// Get hash from URL if present // Get hash from URL if present
$hash = parse_url($_SERVER['REQUEST_URI'], PHP_URL_FRAGMENT) ?? ''; $hash = parse_url($_SERVER['REQUEST_URI'], PHP_URL_FRAGMENT) ?? '';
@ -174,7 +170,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
* Handles GET requests to display templates. * 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(); $jilo_agent_types = $agentObject->getAgentTypes();
include '../app/templates/settings.php'; include '../app/templates/settings.php';
} else { } else {

View File

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

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

View File

@ -4,92 +4,93 @@
*/ */
?> ?>
<div class="action-card"> <div class="container mt-4">
<div class="action-card-header"> <div class="row justify-content-center">
<p class="action-eyebrow">Security</p> <div class="col-md-8">
<h2 class="action-title">Set up two-factor authentication</h2> <div class="card">
<p class="action-subtitle">Protect your account with an extra verification step whenever you sign in.</p> <div class="card-header">
</div> <h3>Set up two-factor authentication</h3>
<div class="action-card-body"> </div>
<div class="alert alert-info"> <div class="card-body">
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="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> </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>
</div> </div>

View File

@ -4,73 +4,74 @@
*/ */
?> ?>
<div class="action-card"> <div class="container mt-4">
<div class="action-card-header"> <div class="row justify-content-center">
<p class="action-eyebrow">Security check</p> <div class="col-md-6">
<h2 class="action-title">Two-factor authentication</h2> <div class="card">
<p class="action-subtitle">Enter the 6-digit code from your authenticator app to continue.</p> <div class="card-header">
</div> <h3>Two-factor authentication</h3>
<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> </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"> <form method="post" action="?page=login&action=verify" class="mt-3">
<button type="submit" class="btn btn-secondary"> <div class="form-group">
Use backup code <input type="text"
</button> 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> </div>
</form> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,86 +5,87 @@
*/ */
?> ?>
<div class="action-card"> <div class="container mt-4">
<div class="action-card-header"> <div class="row justify-content-center">
<p class="action-eyebrow">Security</p> <div class="col-md-8">
<h2 class="action-title">Manage credentials</h2> <!-- Password Management -->
<p class="action-subtitle">Update your password and keep two-factor authentication status in one place.</p> <div class="card mb-4">
</div> <div class="card-header">
<div class="action-card-body"> <h3>change password</h3>
<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> </div>
<form method="post" action="?page=credentials&item=password" class="action-form" novalidate> <div class="card-body">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>"> <form method="post" action="?page=credentials&item=password">
<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">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>"> <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<div class="action-actions">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Disable two-factor authentication? This will make your account less secure.')"> <div class="form-group">
Disable 2FA <label for="current_password">current password</label>
</button> <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> </div>
</form> </form>
<?php else: ?> </div>
<div class="alert alert-warning d-flex align-items-center gap-2"> </div>
<i class="fas fa-lock"></i>
<span>Two-factor authentication is not enabled yet.</span> <!-- 2FA Management -->
</div> <div class="card">
<form method="post" action="?page=credentials&item=2fa&action=setup" class="action-form"> <div class="card-header">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>"> <h3>two-factor authentication</h3>
<div class="action-actions"> </div>
<button type="submit" class="btn btn-outline-primary"> <div class="card-body">
Set up 2FA <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>
</button>
<?php if ($has2fa): ?>
<div class="alert alert-success">
<i class="fas fa-check-circle"></i> two-factor authentication is enabled
</div> </div>
</form> <form method="post" action="?page=credentials&item=2fa&action=disable">
<?php endif; ?> <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
</section> <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> </div>
</div> </div>
@ -96,5 +97,4 @@ document.getElementById('confirm_password').addEventListener('input', function()
} else { } else {
this.setCustomValidity(''); 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,54 +1,31 @@
<!-- login form --> <!-- login form -->
<div class="action-card"> <div class="card text-center w-50 mx-auto">
<div class="action-card-header"> <h2 class="card-header">Login</h2>
<p class="action-eyebrow">Welcome back</p> <div class="card-body">
<h2 class="action-title">Sign in</h2> <p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p>
<p class="action-subtitle">Enter your credentials to continue to <?= htmlspecialchars($config['site_name']); ?></p> <form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login">
</div> <?php include 'csrf_token.php'; ?>
<div class="form-group mb-3">
<div class="action-card-body"> <input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login" class="action-form" novalidate> pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
<?php include CSRF_TOKEN_INCLUDE; ?> required autofocus />
<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> </div>
<div class="form-group mb-3">
<div class="action-form-group"> <input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
<label for="password" class="action-form-label">Password</label> pattern=".{5,}" title="Eight or more characters"
<div class="input-group"> required />
<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> </div>
<div class="form-group mb-3">
<div class="d-flex justify-content-between align-items-center mb-4"> <label for="remember_me">
<div class="form-check"> <input type="checkbox" id="remember_me" name="remember_me" />
<input type="checkbox" id="remember" name="remember" class="form-check-input" <?= isset($_POST['remember']) ? 'checked' : '' ?>> remember me
<label for="remember" class="form-check-label">Remember me</label> </label>
</div>
<a href="<?= htmlspecialchars($app_root) ?>?page=login&action=forgot" class="text-decoration-none">Forgot password?</a>
</div> </div>
<input type="submit" class="btn btn-primary" value="Login" />
<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; ?>
</form> </form>
<div class="mt-3">
<a href="?page=login&action=forgot">forgot password?</a>
</div>
</div> </div>
</div> </div>
<!-- /login form --> <!-- /login form -->

View File

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

View File

@ -1,53 +1,38 @@
<div class="action-card"> <div class="container">
<div class="action-card-header"> <div class="row justify-content-center">
<p class="action-eyebrow">Account recovery</p> <div class="col-md-6">
<h2 class="action-title">Reset password</h2> <div class="card mt-5">
<p class="action-subtitle">Create a new password that is at least 8 characters long</p> <div class="card-body">
</div> <h3 class="card-title mb-4">Set new password</h3>
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>">
<div class="action-card-body"> <?php include 'csrf_token.php'; ?>
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>" class="action-form" novalidate> <div class="form-group">
<?php include CSRF_TOKEN_INCLUDE; ?> <label for="new_password">new password:</label>
<div class="action-form-group"> <input type="password"
<label for="new_password" class="action-form-label">New password</label> class="form-control"
<div class="input-group"> id="new_password"
<span class="input-group-text"><i class="fas fa-key"></i></span> name="new_password"
<input type="password" required
class="form-control action-form-control" minlength="8"
id="new_password" autocomplete="new-password">
name="new_password" </div>
placeholder="Enter new password" <div class="form-group mt-3">
required <label for="confirm_password">confirm password:</label>
minlength="8" <input type="password"
autocomplete="new-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> </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> </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> </div>
<?php if (Session::getUsername() && $page !== 'logout') { ?> <?php if (isset($currentUser) && $page !== 'logout') { ?>
<script src="static/js/sidebar.js"></script> <script src="static/js/sidebar.js"></script>
<?php } ?> <?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/main.css">
<link rel="stylesheet" type="text/css" href="<?= htmlspecialchars($app_root) ?>static/css/messages.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> <script src="<?= htmlspecialchars($app_root) ?>static/js/messages.js"></script>
<?php if (Session::getUsername()) { ?> <?php if (isset($currentUser)) { ?>
<script> <script>
// restore sidebar state before the page is rendered // restore sidebar state before the page is rendered
(function () { (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-adapter-moment.min.js"></script>
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script> <script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script>
<?php } ?> <?php } ?>
<?php if ($page === 'admin-tools') { <title>Jilo Web</title>
// 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>
<link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($app_root) ?>static/favicon.ico"> <link rel="icon" type="image/x-icon" href="<?= htmlspecialchars($app_root) ?>static/favicon.ico">
</head> </head>
<body> <body>
<div id="messages-container" class="container-fluid"></div> <div id="messages-container" class="container-fluid mt-2"></div>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col"> <div class="col">

View File

@ -1,118 +1,68 @@
<div class="container-fluid p-0"> <div class="container-fluid">
<!-- Modern Menu --> <!-- Menu -->
<div class="menu-container"> <div class="menu-container">
<div class="modern-header-content"> <ul class="menu-left">
<div class="logo-section"> <div class="container">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="modern-logo-link"> <div class="row">
<div class="modern-logo"> <a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="logo-link">
<img src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="<?= htmlspecialchars($config['site_name']); ?>"/> <div class="col-4">
</div> <img class="logo" src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="JILO"/>
<div class="brand-info"> </div>
<h1 class="brand-name"><?= htmlspecialchars($config['site_name']); ?></h1> </a>
<?php if (!empty($config['site_slogan'])): ?> </div>
<div class="brand-slogan"><?= htmlspecialchars($config['site_slogan']); ?></div>
<?php endif; ?>
</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) { <?php foreach ($platformsAll as $platform) {
$platform_switch_url = switchPlatform($platform['id']); $platform_switch_url = switchPlatform($platform['id']);
?> ?>
<div> <li style="margin-right: 3px;">
<?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?> <?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?>
Jitsi platforms: <span style="background-color: #fff; border: 1px solid #111; color: #111; border-bottom-color: #fff; padding-bottom: 12px;">
<button class="btn modern-header-btn" type="button" aria-expanded="false">
<?= htmlspecialchars($platform['name']) ?> <?= htmlspecialchars($platform['name']) ?>
</button> </span>
<?php } else { ?> <?php } else { ?>
<a href="<?= htmlspecialchars($platform_switch_url) ?>"> <a href="<?= htmlspecialchars($platform_switch_url) ?>">
<?= htmlspecialchars($platform['name']) ?> <?= htmlspecialchars($platform['name']) ?>
</a> </a>
<?php } ?> <?php } ?>
</div> </li>
<?php } ?> <?php } ?>
<?php } ?> <?php } ?>
</ul>
<div class="header-actions"> <ul class="menu-right">
<?php if (Session::isValidSession()) { ?> <?php if (isset($_SESSION['username']) && isset($_SESSION['user_id'])) { ?>
<div class="dropdown"> <li class="dropdown">
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
<i class="fas fa-user-circle me-2"></i><?= htmlspecialchars($currentUser) ?> <i class="fas fa-user"></i>
</button> </a>
<div class="dropdown-menu dropdown-menu-right modern-dropdown"> <div class="dropdown-menu dropdown-menu-right">
<h6 class="dropdown-header modern-dropdown-header"><?= htmlspecialchars($currentUser) ?></h6> <h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme"> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
<i class="fas fa-paint-brush"></i>Change theme <i class="fas fa-id-card"></i>Profile details
</a> </a>
<div class="dropdown-divider"></div> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile"> <i class="fas fa-shield-alt"></i>Login credentials
<i class="fas fa-id-card"></i>Profile details </a>
</a> <div class="dropdown-divider"></div>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials"> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
<i class="fas fa-shield-alt"></i>Login credentials <i class="fas fa-sign-out-alt"></i>Logout
</a> </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>
</div> </div>
<div class="dropdown"> </li>
<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>
<?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 } ?>
<?php } else { ?> <?php } else { ?>
<button class="btn modern-header-btn" onclick="window.location.href='<?= htmlspecialchars($app_root) ?>?page=login'"> <li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li>
<i class="fas fa-sign-in-alt me-2"></i>Login <li><a href="<?= htmlspecialchars($app_root) ?>?page=register">register</a></li>
</button>
<?php do_hook('main_public_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
<?php } ?> <?php } ?>
</ul>
<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>
</div> </div>
<!-- /Modern Menu --> <!-- /Menu -->

View File

@ -1,16 +1,16 @@
<div class="row" style="padding-right: 0.75rem;"> <div class="row">
<!-- Sidebar --> <!-- Sidebar -->
<div class="col-md-3 sidebar-wrapper" id="sidebar"> <div class="col-md-3 mb-5 sidebar-wrapper bg-light" id="sidebar">
<div class="text-center" id="time_now"> <div class="text-center" style="border: 1px solid #0dcaf0; height: 22px;" id="time_now">
<?php <?php
$timeNow = new DateTime('now', new DateTimeZone($userTimezone)); $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
?> ?>
<span><?= htmlspecialchars($timeNow->format('H:i')) ?>&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>
<div class="col-4"><button class="btn btn-sm btn-info toggle-sidebar-button" type="button" id="toggleSidebarButton" value=">>"></button></div> <div class="col-4"><button class="btn btn-sm btn-info toggle-sidebar-button" type="button" id="toggleSidebarButton" value=">>"></button></div>
<div class="sidebar-content card mt-0"> <div class="sidebar-content card ml-3 mt-3">
<ul class="list-group"> <ul class="list-group">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=dashboard"> <a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=dashboard">
@ -19,7 +19,7 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
</li> </li>
</a> </a>
<li class="list-group-item sidebar-section-title-first">logs statistics</li> <li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>logs statistics</small></p></li>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences"> <a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences">
<li class="list-group-item<?php if ($page === 'conferences') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>"> <li class="list-group-item<?php if ($page === 'conferences') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
@ -37,7 +37,7 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
</li> </li>
</a> </a>
<li class="list-group-item sidebar-section-title">live data</li> <li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>live data</small></p></li>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=graphs"> <a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=graphs">
<li class="list-group-item<?php if ($page === 'graphs') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>"> <li class="list-group-item<?php if ($page === 'graphs') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
@ -65,18 +65,52 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
</li> </li>
</a> </a>
<li class="list-group-item sidebar-section-title">jitsi platforms settings</li> <li class="list-group-item bg-light" style="border: none;"><p class="text-end mb-0"><small>jitsi platforms settings</small></p></li>
<a href="<?= htmlspecialchars($app_root) ?>?page=settings"> <a href="<?= htmlspecialchars($app_root) ?>?page=settings">
<li class="list-group-item<?php if ($page === 'settings') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>"> <li class="list-group-item<?php if ($page === 'settings') echo ' list-group-item-secondary'; else echo ' list-group-item-action'; ?>">
<i class="fas fa-cog" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="jilo settings"></i>settings <i class="fas fa-cog" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="jilo settings"></i>settings
</li> </li>
</a> </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"> <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'; ?>"> <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 <i class="fas fa-heartbeat" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="status"></i>status
</li> </li>
</a> </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> </ul>
</div> </div>
</div> </div>

View File

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

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

View File

@ -1,126 +1,87 @@
<?php <!-- user profile -->
$user = $userDetails[0] ?? []; <div class="card text-center w-50 mx-auto">
$username = $user['username'] ?? '';
$name = $user['name'] ?? '';
$email = $user['email'] ?? '';
$timezoneName = $user['timezone'] ?? '';
$timezoneOffset = $timezoneName ? getUTCOffset($timezoneName) : '';
$bio = trim($user['bio'] ?? '');
$rightsNames = array_map(function ($right) {
return trim($right['right_name'] ?? '');
}, $userRights);
$rightsNames = array_filter($rightsNames, function ($label) {
return $label !== '';
});
$rightsCount = count($rightsNames);
$displayName = $name ?: $username ?: 'User profile';
$timezoneDisplay = '';
if ($timezoneName) {
if ($timezoneOffset !== '') {
$offsetLabel = stripos($timezoneOffset, 'UTC') === 0 ? $timezoneOffset : 'UTC' . $timezoneOffset;
$timezoneDisplay = sprintf('%s (%s)', $timezoneName, $offsetLabel);
} else {
$timezoneDisplay = $timezoneName;
}
}
?> <p class="h4 card-header">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></p>
<div class="card-body">
<section class="tm-directory tm-profile-view"> <div class="row">
<div class="tm-hero-card tm-hero-card--stacked tm-profile-hero">
<div class="tm-profile-hero-main"> <div class="col-md-4 avatar-container">
<div class="tm-profile-avatar-frame"> <div>
<img src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="Avatar of <?= htmlspecialchars($displayName) ?>" /> <img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
</div>
<div class="tm-profile-hero-body">
<h1 class="tm-profile-title"><?= htmlspecialchars($displayName) ?></h1>
<p class="tm-profile-subtitle">Personal details and access summary for this TotalMeet account.</p>
<div class="tm-profile-hero-meta">
<?php if ($username): ?>
<span class="tm-hero-pill pill-neutral">
<i class="fas fa-user"></i>
@<?= htmlspecialchars($username) ?>
</span>
<?php endif; ?>
<?php if ($timezoneDisplay): ?>
<span class="tm-hero-pill pill-primary">
<i class="fas fa-clock"></i>
<?= htmlspecialchars($timezoneDisplay) ?>
</span>
<?php endif; ?>
<span class="tm-hero-pill pill-accent">
<i class="fas fa-shield-alt"></i>
<?= $rightsCount ?> <?= $rightsCount === 1 ? 'Right' : 'Rights' ?>
</span>
</div> </div>
</div> </div>
<div class="tm-profile-hero-actions">
<a class="btn btn-primary" href="<?= htmlspecialchars($app_root) ?>?page=profile&amp;action=edit"> <div class="col-md-8">
<i class="fas fa-edit"></i> Edit profile
</a> <!--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> </div>
<p>
<a href="<?= htmlspecialchars($app_root) ?>?page=profile&action=edit" class="btn btn-primary">Edit</a>
</p>
</div> </div>
</div> </div>
</div>
<div class="tm-profile-panels"> <!-- /user profile -->
<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>

View File

@ -5,17 +5,17 @@
<h2 class="mb-0">Security settings</h2> <h2 class="mb-0">Security settings</h2>
<small>network restrictions to control flooding and brute force attacks</small> <small>network restrictions to control flooding and brute force attacks</small>
<ul class="nav nav-tabs mt-5"> <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"> <li class="nav-item">
<a class="nav-link <?= $section === 'whitelist' ? 'active' : '' ?>" href="?page=security&section=whitelist">IP whitelist</a> <a class="nav-link <?= $section === 'whitelist' ? 'active' : '' ?>" href="?page=security&section=whitelist">IP whitelist</a>
</li> </li>
<?php } ?> <?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"> <li class="nav-item">
<a class="nav-link <?= $section === 'blacklist' ? 'active' : '' ?>" href="?page=security&section=blacklist">IP blacklist</a> <a class="nav-link <?= $section === 'blacklist' ? 'active' : '' ?>" href="?page=security&section=blacklist">IP blacklist</a>
</li> </li>
<?php } ?> <?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"> <li class="nav-item">
<a class="nav-link <?= $section === 'ratelimit' ? 'active' : '' ?>" href="?page=security&section=ratelimit">Rate limiting</a> <a class="nav-link <?= $section === 'ratelimit' ? 'active' : '' ?>" href="?page=security&section=ratelimit">Rate limiting</a>
</li> </li>
@ -24,7 +24,7 @@
</div> </div>
</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 --> <!-- whitelist section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
@ -35,7 +35,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" class="mb-4"> <form method="POST" class="mb-4">
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include 'csrf_token.php'; ?>
<input type="hidden" name="action" value="add_whitelist"> <input type="hidden" name="action" value="add_whitelist">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
@ -77,7 +77,7 @@
<td><?= htmlspecialchars($ip['created_at']) ?></td> <td><?= htmlspecialchars($ip['created_at']) ?></td>
<td> <td>
<form method="POST" style="display: inline;"> <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="action" value="remove_whitelist">
<input type="hidden" name="ip_address" value="<?= htmlspecialchars($ip['ip_address']) ?>"> <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> <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> </div>
<?php } ?> <?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 --> <!-- blacklist section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
@ -104,7 +104,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" class="mb-4"> <form method="POST" class="mb-4">
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include 'csrf_token.php'; ?>
<input type="hidden" name="action" value="add_blacklist"> <input type="hidden" name="action" value="add_blacklist">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
@ -151,7 +151,7 @@
<td><?= $ip['expiry_time'] ? htmlspecialchars($ip['expiry_time']) : 'Never' ?></td> <td><?= $ip['expiry_time'] ? htmlspecialchars($ip['expiry_time']) : 'Never' ?></td>
<td> <td>
<form method="POST" style="display: inline;"> <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="action" value="remove_blacklist">
<input type="hidden" name="ip_address" value="<?= htmlspecialchars($ip['ip_address']) ?>"> <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> <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> </div>
<?php } ?> <?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 --> <!-- rate limiting section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <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;"> <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 <i class="fas fa-times me-1"></i>Cancel
</button> </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'])) ?>')"> <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 <i class="fas fa-trash me-1"></i>Delete platform
</button> </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 ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@ -24,95 +25,43 @@ CREATE TABLE rights (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE name TEXT NOT NULL UNIQUE
); );
CREATE TABLE IF NOT EXISTS "jilo_agent_types" ( INSERT INTO rights VALUES(1,'superuser');
id INTEGER PRIMARY KEY AUTOINCREMENT, INSERT INTO rights VALUES(2,'edit users');
description TEXT, INSERT INTO rights VALUES(3,'view settings');
endpoint TEXT 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 ( CREATE TABLE platforms (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
jitsi_url TEXT NOT NULL, jitsi_url TEXT NOT NULL,
jilo_database 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 ( CREATE TABLE user_2fa (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
secret_key TEXT NOT NULL, secret_key TEXT NOT NULL,
backup_codes TEXT, backup_codes TEXT,
enabled INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_used TEXT, last_used TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE user_2fa_temp ( CREATE TABLE user_2fa_temp (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL,
code TEXT NOT NULL, code TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) PRIMARY KEY (user_id, code),
FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE user_password_reset ( CREATE TABLE user_password_reset (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -121,5 +70,85 @@ CREATE TABLE user_password_reset (
expires INTEGER NOT NULL, expires INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0, used INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 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(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 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 platforms VALUES(1,'meet.lindeas.com','https://meet.lindeas.com','../jilo-meet.lindeas.db');
INSERT INTO rights VALUES(2,'edit users'); INSERT INTO platforms VALUES(2,'example.com','https://meet.example.com','../jilo.db');
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 jilo_agent_types VALUES(1,'jvb','/jvb'); 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 jilo_agent_types VALUES(2,'jicofo','/jicofo'); 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 jilo_agent_types VALUES(3,'prosody','/prosody'); 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 jilo_agent_types VALUES(4,'nginx','/nginx'); 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 jilo_agent_types VALUES(5,'jibri','/jibri'); 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 hosts VALUES(1,'meet.lindeas.com',2,'main machine');
INSERT INTO ip_whitelist VALUES(2,'::1',0,'localhost IPv6','2025-01-04 11:39:08','system'); INSERT INTO hosts VALUES(2,'meet.example.com',2,'test');
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 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

@ -1,29 +0,0 @@
BSD 3-Clause License
Copyright (c) 2006, Ivan Sagalaev.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,62 +0,0 @@
# Logger plugin
## Overview
The Logger plugin provides a modular, pluggable logging system for the application.
It logs user and system events to a MySQL table named `log`.
## Installation
1. Copy the entire `logger` folder into your project's `plugins/` directory.
2. Ensure `"enabled": true` in `plugins/logger/plugin.json`.
3. On first initialization, the plugin will create the `log` table if it does not already exist.
## Database Schema
The plugin defines the following table (auto-created):
```sql
CREATE TABLE IF NOT EXISTS `log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`scope` SET('user','system') NOT NULL,
`message` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
CONSTRAINT `log_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
```
## Hook API
Core must call:
```php
// After DB connect:
do_hook('logger.system_init', ['db' => $db]);
```
The plugin listens on `logger.system_init`, runs auto-migration, then sets:
```php
$GLOBALS['logObject']; // instance of Log
$GLOBALS['user_IP']; // current user IP
```
Then in the code use:
```php
$logObject->insertLog($userId, 'Your message', 'user');
$data = $logObject->readLog($userId, 'user', $offset, $limit, $filters);
```
## File Structure
```
plugins/logger/
├─ bootstrap.php # registers hook
├─ plugin.json # metadata & enabled flag
├─ README.md # this documentation
├─ models/
│ ├─ Log.php # main Log class
│ └─ LoggerFactory.php# migration + factory
├─ helpers/
│ └─ logs.php # user IP helper
└─ migrations/
└─ create_log_table.sql
```
## Uninstall / Disable
- Set `"enabled": false` in `plugin.json` or delete the `plugins/logger/` folder.
- Core code will default to `NullLogger` and no logs will be written.

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