Compare commits

..

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

126 changed files with 1550 additions and 6183 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

View File

@ -13,8 +13,6 @@ All notable changes to this project will be documented in this file.
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...HEAD - gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...HEAD
### Added ### Added
- Added CSS and JS to the default theme
- Added change theme menu entry
### Changed ### Changed

View File

@ -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);

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

@ -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) {
@ -477,13 +457,13 @@ class User {
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. ';
@ -513,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);
@ -525,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) {
@ -547,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) {
@ -568,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);
@ -589,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

@ -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,20 +22,12 @@ 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

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 __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,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,119 +0,0 @@
<?php
namespace App\Core;
use PDO;
use Exception;
class MigrationRunner
{
private PDO $pdo;
private string $migrationsDir;
/**
* @param mixed $db Either a PDO instance or the application's Database wrapper
* @param string $migrationsDir Directory containing .sql migrations
*/
public function __construct($db, string $migrationsDir)
{
// Normalize to PDO
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->ensureMigrationsTable();
}
private function ensureMigrationsTable(): void
{
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration TEXT NOT NULL UNIQUE,
applied_at TEXT NOT 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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$this->pdo->exec($sql);
}
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();
return array_values(array_diff($all, $applied));
}
public function hasPendingMigrations(): bool
{
return count($this->listPendingMigrations()) > 0;
}
public function applyPendingMigrations(): array
{
$pending = $this->listPendingMigrations();
$appliedNow = [];
if (empty($pending)) {
return $appliedNow;
}
try {
$this->pdo->beginTransaction();
foreach ($pending as $migration) {
$path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path);
if ($sql === false) {
throw new Exception("Unable to read migration file: {$migration}");
}
// Split on ; at line ends, but allow inside procedures? Keep simple for our use-cases
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
foreach ($statements as $stmtSql) {
if ($stmtSql === '') continue;
$this->pdo->exec($stmtSql);
}
$ins = $this->pdo->prepare('INSERT INTO migrations (migration, applied_at) VALUES (:m, NOW())');
$ins->execute([':m' => $migration]);
$appliedNow[] = $migration;
}
$this->pdo->commit();
} catch (Exception $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
return $appliedNow;
}
}

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

@ -15,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));
} }
@ -29,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,15 +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();
}

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,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,389 +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 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,148 +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 __DIR__ . '/../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();
if (empty($applied)) {
Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
} else {
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migrations: ' . implode(', ', $applied), true);
}
}
} catch (Throwable $e) {
Feedback::flash('ERROR', 'DEFAULT', 'Action failed: ' . $e->getMessage(), false);
}
header('Location: ' . $app_root . '?page=admin-tools');
exit;
}
// Prepare data for view
require_once __DIR__ . '/../core/Maintenance.php';
$maintenance_enabled = \App\Core\Maintenance::isEnabled();
$maintenance_message = \App\Core\Maintenance::getMessage();
require_once __DIR__ . '/../core/MigrationRunner.php';
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
$pending = [];
$applied = [];
$migration_contents = [];
try {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$pending = $runner->listPendingMigrations();
$applied = $runner->listAppliedMigrations();
// Preload contents for billing-admin style modals
$all = array_unique(array_merge($pending, $applied));
foreach ($all as $fname) {
$path = realpath($migrationsDir . '/' . $fname);
if ($path && strpos($path, realpath($migrationsDir)) === 0) {
$content = @file_get_contents($path);
if ($content !== false) {
$migration_contents[$fname] = $content;
}
}
}
} catch (Throwable $e) {
// show error in the page
$migration_error = $e->getMessage();
}
// CSRF token
$csrf_token = $security->generateCsrfToken();
// Load the template
include __DIR__ . '/../templates/admin-tools.php';

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
@ -50,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Apply rate limiting for adding new contacts // Apply rate limiting for adding new contacts
require '../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) {

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) {

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) {

View File

@ -13,7 +13,8 @@ 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,10 +14,17 @@
* - `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'] ?? '';
@ -33,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':
@ -43,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();
@ -60,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();
@ -72,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.');
@ -89,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,
@ -107,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.');
@ -128,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 {

View File

@ -16,7 +16,7 @@ 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) {

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 {

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 = [

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();
} }
@ -62,9 +60,6 @@ try {
// Get any new feedback messages // Get any new feedback messages
include '../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';
exit(); exit();
@ -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
@ -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') {
@ -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,7 +269,7 @@ 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
@ -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: ' . htmlspecialchars($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 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) {

View File

@ -30,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,
@ -48,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 {
@ -89,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");

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 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');

View File

@ -21,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') {
/** /**
@ -31,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) ?? '';
@ -170,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

@ -13,8 +13,8 @@ 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,71 +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 '../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 for the view
$themeData = [];
foreach ($themes as $id => $name) {
$themeData[$id] = [
'name' => $name,
'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,139 +0,0 @@
<?php
/** @var bool $maintenance_enabled */
/** @var string $maintenance_message */
/** @var array $pending */
/** @var array $applied */
/** @var string $csrf_token */
?>
<!-- admin tools page -->
<div class="container-fluid mt-2">
<div class="row mb-4">
<div class="col-12 mb-2">
<h2>Admin tools</h2>
<small class="text-muted">System maintenance and database utilities.</small>
</div>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-3">
<h5 class="card-title mb-0">
<i class="fas fa-tools me-2 text-secondary"></i>
Maintenance mode
</h5>
<span class="badge <?= $maintenance_enabled ? 'bg-danger' : 'bg-success' ?>"><?= $maintenance_enabled ? 'enabled' : 'disabled' ?></span>
</div>
<div class="card-body p-4">
<form method="post" class="mb-3">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_on">
<div class="mb-3">
<label for="maintenance_message" class="form-label mb-1">Message (optional)</label>
<input type="text" id="maintenance_message" name="maintenance_message" class="form-control form-control-sm" value="<?= htmlspecialchars($maintenance_message) ?>" placeholder="Upgrading database">
</div>
<button type="submit" class="btn btn-warning btn-sm" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
</form>
<form method="post" class="mt-2">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_off">
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= $maintenance_enabled ? '' : 'disabled' ?>>Disable maintenance</button>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-3">
<h5 class="card-title mb-0">
<i class="fas fa-database me-2 text-secondary"></i>
Database migrations
</h5>
</div>
<div class="card-body p-4">
<?php if (!empty($migration_error)): ?>
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
<?php endif; ?>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div><strong>Pending</strong></div>
<span class="badge <?= empty($pending) ? 'bg-success' : 'bg-warning text-dark' ?>"><?= count($pending) ?></span>
</div>
<div class="mt-2 small border rounded" style="max-height: 240px; overflow: auto;">
<?php if (empty($pending)): ?>
<div class="p-2"><span class="text-success">none</span></div>
<?php else: ?>
<ul class="list-group list-group-flush">
<?php foreach ($pending as $fname): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-monospace small"><?= htmlspecialchars($fname) ?></span>
<button type="button"
class="btn btn-outline-primary btn-sm"
data-toggle="modal"
data-target="#migrationModal<?= md5($fname) ?>">View
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div><strong>Applied</strong></div>
<span class="badge bg-secondary"><?= count($applied) ?></span>
</div>
<div class="mt-2 small border rounded" style="max-height: 240px; overflow: auto;">
<?php if (empty($applied)): ?>
<div class="p-2"><span class="text-muted">none</span></div>
<?php else: ?>
<ul class="list-group list-group-flush">
<?php foreach ($applied as $fname): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-monospace small"><?= htmlspecialchars($fname) ?></span>
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-toggle="modal"
data-target="#migrationModal<?= md5($fname) ?>">View
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<form method="post" class="mt-3">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up">
<button type="submit" class="btn btn-primary btn-sm" <?= empty($pending) ? 'disabled' : '' ?>>Apply pending migrations</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Migration viewer modals (one per file) -->
<?php if (!empty($migration_contents)):
foreach ($migration_contents as $name => $content):
$modalId = 'migrationModal' . md5($name);
?>
<div class="modal fade" id="<?= $modalId ?>" tabindex="-1" aria-labelledby="<?= $modalId ?>Label" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="<?= $modalId ?>Label"><?= htmlspecialchars($name) ?></h5>
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<pre class="mb-0" style="max-height: 60vh; overflow: auto;"><code class="p-3 d-block"><?= htmlspecialchars($content) ?></code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<?php endforeach;
endif; ?>

View File

@ -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,15 +4,15 @@
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p> <p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login"> <form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login">
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include 'csrf_token.php'; ?>
<div class="form-group mb-3"> <div class="form-group mb-3">
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username" <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 _" pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
required /> required autofocus />
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password" <input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
pattern=".{8,}" title="Eight or more characters" pattern=".{5,}" title="Eight or more characters"
required /> required />
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
@ -21,9 +21,6 @@
remember me remember me
</label> </label>
</div> </div>
<?php if (isset($_GET['redirect'])): ?>
<input type="hidden" name="redirect" value="<?php echo htmlspecialchars($_GET['redirect']); ?>">
<?php endif; ?>
<input type="submit" class="btn btn-primary" value="Login" /> <input type="submit" class="btn btn-primary" value="Login" />
</form> </form>
<div class="mt-3"> <div class="mt-3">

View File

@ -8,7 +8,7 @@
<p>Enter your email address and we will send you<br /> <p>Enter your email address and we will send you<br />
instructions to reset your password.</p> instructions to reset your password.</p>
<form method="post" action="?page=login&action=forgot"> <form method="post" action="?page=login&action=forgot">
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include 'csrf_token.php'; ?>
<div class="form-group"> <div class="form-group">
<label for="email">email address:</label> <label for="email">email address:</label>
<input type="email" <input type="email"

View File

@ -6,7 +6,7 @@
<div class="card-body"> <div class="card-body">
<h3 class="card-title mb-4">Set new password</h3> <h3 class="card-title mb-4">Set new password</h3>
<form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>"> <form method="post" action="?page=login&action=reset&token=<?= htmlspecialchars(urlencode($token)) ?>">
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include 'csrf_token.php'; ?>
<div class="form-group"> <div class="form-group">
<label for="new_password">new password:</label> <label for="new_password">new password:</label>
<input type="password" <input type="password"

View File

@ -4,11 +4,11 @@
<div class="card-body"> <div class="card-body">
<p class="card-text">Enter credentials for registration:</p> <p class="card-text">Enter credentials for registration:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=register"> <form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=register">
<?php include CSRF_TOKEN_INCLUDE; ?> <?php include 'csrf_token.php'; ?>
<div class="form-group mb-3"> <div class="form-group mb-3">
<input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username" <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 _" pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
required /> required autofocus />
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password" <input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
@ -20,17 +20,6 @@
pattern=".{8,}" title="Eight or more characters" pattern=".{8,}" title="Eight or more characters"
required /> required />
</div> </div>
<div class="form-group mb-3">
<div class="form-check">
<label class="form-check-label" for="terms">
<input type="checkbox" class="form-check-input" id="terms" name="terms" required>
I agree to the <a href="<?= htmlspecialchars($app_root) ?>?page=terms" target="_blank">terms & conditions</a> and <a href="<?= htmlspecialchars($app_root) ?>?page=privacy" target="_blank">privacy policy</a>
</label>
</div>
<small class="text-muted mt-2">
We use cookies to improve your experience. See our <a href="<?= htmlspecialchars($app_root) ?>?page=cookies" target="_blank">cookies policy</a>
</small>
</div>
<input type="submit" class="btn btn-primary" value="Register" /> <input type="submit" class="btn btn-primary" value="Register" />
</form> </form>
</div> </div>

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,24 +36,7 @@
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-adapter-moment.min.js"></script> <script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-adapter-moment.min.js"></script>
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script> <script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script>
<?php } ?> <?php } ?>
<?php if ($page === 'admin-tools') { <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 } ?>
<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>

View File

@ -15,10 +15,10 @@
</div> </div>
<li class="font-weight-light text-uppercase" style="font-size: 0.5em; color: whitesmoke; margin-right: 70px; align-content: center;"> <li class="font-weight-light text-uppercase" style="font-size: 0.5em; color: whitesmoke; margin-right: 70px; align-content: center;">
version&nbsp;<?= htmlspecialchars($config['version'] ?? '1.0.0') ?> version&nbsp;<?= htmlspecialchars($config['version']) ?>
</li> </li>
<?php if (Session::isValidSession()) { ?> <?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']);
@ -40,17 +40,13 @@
</ul> </ul>
<ul class="menu-right"> <ul class="menu-right">
<?php if (Session::isValidSession()) { ?> <?php if (isset($_SESSION['username']) && isset($_SESSION['user_id'])) { ?>
<li class="dropdown"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
<i class="fas fa-user"></i> <i class="fas fa-user"></i>
</a> </a>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right">
<h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6> <h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme">
<i class="fas fa-paint-brush"></i>Change theme
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile"> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
<i class="fas fa-id-card"></i>Profile details <i class="fas fa-id-card"></i>Profile details
</a> </a>
@ -63,54 +59,10 @@
</a> </a>
</div> </div>
</li> </li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
<i class="fas fa-cog"></i>
</a>
<div class="dropdown-menu dropdown-menu-right">
<h6 class="dropdown-header">system</h6>
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
<a class="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" 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" href="<?= htmlspecialchars($app_root) ?>?page=security">
<i class="fas fa-shield-alt"></i>Security
</a>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'view app logs')) {?>
<?php do_hook('main_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
<?php } ?>
</div>
</li>
<?php } else { ?> <?php } else { ?>
<li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li> <li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li>
<?php do_hook('main_public_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?> <li><a href="<?= htmlspecialchars($app_root) ?>?page=register">register</a></li>
<?php } ?> <?php } ?>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
<i class="fas fa-info-circle"></i>
</a>
<div class="dropdown-menu dropdown-menu-right">
<h6 class="dropdown-header">resources</h6>
<a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=help">
<i class="fas fa-question-circle"></i>Help
</a>
</div>
</li>
</ul> </ul>
</div> </div>
<!-- /Menu --> <!-- /Menu -->

View File

@ -72,11 +72,45 @@ $timeNow = new DateTime('now', new DateTimeZone($userTimezone));
<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

@ -6,7 +6,6 @@
<div class="card-body"> <div class="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">
<?php include CSRF_TOKEN_INCLUDE; ?>
<div class="row"> <div class="row">
<p class="border rounded bg-light mb-4"><small>edit the profile fields</small></p> <p class="border rounded bg-light mb-4"><small>edit the profile fields</small></p>
<div class="col-md-4 avatar-container"> <div class="col-md-4 avatar-container">
@ -133,7 +132,6 @@
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<form id="remove-avatar-form" data-action="remove-avatar" method="POST" action="<?= htmlspecialchars($app_root) ?>?page=profile&action=remove&item=avatar"> <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> <button type="button" class="btn btn-danger" id="confirm-delete">Delete Avatar</button>
</form> </form>
</div> </div>

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,44 +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
*/
?>
<div class="container mt-4">
<h2>Theme switcher</h2>
<p class="text-muted">Select a theme to change the appearance of the application.</p>
<div class="row mt-4">
<?php foreach ($themes as $themeId => $theme): ?>
<div class="col-md-4 mb-4">
<div class="card h-100 <?= $theme['isActive'] ? 'border-primary' : '' ?>">
<!-- Theme screenshot -->
<div class="theme-screenshot" style="height: 150px; background-size: cover; background-position: center; background-color: #f8f9fa; <?= $theme['screenshotUrl'] ? 'background-image: url(' . htmlspecialchars($theme['screenshotUrl']) . ')' : '' ?>">
<?php if (!$theme['screenshotUrl']): ?>
<div class="h-100 d-flex align-items-center justify-content-center text-muted">No preview available</div>
<?php endif; ?>
</div>
<?php if ($theme['isActive']): ?>
<div class="card-header bg-primary text-white">Current theme</div>
<?php endif; ?>
<div class="card-body d-flex flex-column">
<h5 class="card-title"><?= htmlspecialchars($theme['name']) ?></h5>
<p class="card-text text-muted">Theme ID: <code><?= htmlspecialchars($themeId) ?></code></p>
<div class="mt-auto">
<?php if (!$theme['isActive']): ?>
<a href="?page=theme&switch_to=<?= urlencode($themeId) ?>&csrf_token=<?= $csrf_token ?>" class="btn btn-primary">Switch to this theme</a>
<?php else: ?>
<button class="btn btn-outline-secondary" disabled>Currently active</button>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>

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,
`avatar` varchar(255) DEFAULT NULL,
`bio` text DEFAULT NULL,
`theme` varchar(255) 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 `platforms` (`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 +0,0 @@
-- Add user theme preference column to user_meta, if it doesn't exist
ALTER TABLE user_meta
ADD COLUMN IF NOT EXISTS theme VARCHAR(64) NULL DEFAULT NULL AFTER timezone;

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.

View File

@ -1,33 +0,0 @@
<?php
// Logs plugin bootstrap
// (here we add any plugin autoloader, if needed)
// List here all the controllers in "/controllers/" that we need as pages
$GLOBALS['plugin_controllers']['logs'] = [
'logs'
];
// Logger plugin bootstrap
register_hook('logger.system_init', function(array $context) {
// Load plugin-specific LoggerFactory class
require_once __DIR__ . '/models/LoggerFactory.php';
[$logger, $userIP] = LoggerFactory::create($context['db']);
// Expose to globals for routing logic
$GLOBALS['logObject'] = $logger;
$GLOBALS['user_IP'] = $userIP;
});
// Configuration for top menu injection
define('LOGS_MAIN_MENU_SECTION', 'main'); // section of the top menu
define('LOGS_MAIN_MENU_POSITION', 20); // lower = earlier in menu
register_hook('main_menu', function($ctx) {
$section = defined('LOGS_MAIN_MENU_SECTION') ? LOGS_MAIN_MENU_SECTION : 'main';
$position = defined('LOGS_MAIN_MENU_POSITION') ? LOGS_MAIN_MENU_POSITION : 100;
// We use $section/$position for sorting/insertion logic in the menu template
echo '
<a class="dropdown-item" href="?page=logs">
<i class="fas fa-list"></i>Logs
</a>';
});

View File

@ -1,15 +0,0 @@
<?php
function getLogLevelClass($level) {
switch (strtolower($level)) {
case 'emergency': return 'text-danger fw-bold';
case 'alert': return 'text-danger';
case 'critical': return 'text-warning fw-bold';
case 'error': return 'text-warning';
case 'warning': return 'text-warning';
case 'notice': return 'text-primary';
case 'info': return 'text-info';
case 'debug': return 'text-muted';
default: return 'text-body'; // fallback normal text
}
}

View File

@ -1,14 +0,0 @@
-- --------------------------------------------------------
-- Logger Plugin: Create `log` table if it does not exist
-- --------------------------------------------------------
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,
`level` set('emergency','alert','critical','error','warning','notice','info','debug') NOT NULL DEFAULT 'info',
`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;

View File

@ -1,119 +0,0 @@
<?php
/**
* class Log
*
* Handles logging events into a database and reading log entries.
*/
class Log {
/**
* @var PDO|null $db The database connection instance.
*/
private $db;
/**
* Logs constructor.
* Initializes the database connection.
*
* @param object $database The database object to initialize the connection.
*/
public function __construct($database) {
$this->db = $database->getConnection();
}
/**
* Retrieve log entries from the database.
*
* @param int $userId The ID of the user whose logs are being retrieved.
* @param string $scope The scope of the logs ('user' or 'system').
* @param int $offset The offset for pagination. Default is 0.
* @param int $items_per_page The number of log entries to retrieve per page. Default is no limit.
* @param array $filters Optional array of filters (from_time, until_time, message, id)
*
* @return array An array of log entries.
*/
public function readLog($userId, $scope, $offset = 0, $items_per_page = '', $filters = []) {
$params = [];
$where_clauses = [];
// Base query with user join
$base_sql = 'SELECT l.*, u.username
FROM log l
LEFT JOIN user u ON l.user_id = u.id';
// Add scope condition
if ($scope === 'user') {
$where_clauses[] = 'l.user_id = :user_id';
$params[':user_id'] = $userId;
}
// 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);
}
/**
* PSR-3 style log method - inserts a log event into the database.
*
* @param string $level The log level (emergency, alert, critical, error, warning, notice, info, debug).
* @param string $message The log message to insert.
* @param string $scope The scope of the log event (e.g., 'user', 'system'). Default is 'system'.
*/
public function log(string $level, string $message, array $context = []): void {
$userId = $context['user_id'] ?? null;
$scope = $context['scope'] ?? 'system';
try {
$sql = 'INSERT INTO log
(user_id, level, scope, message)
VALUES
(:user_id, :level, :scope, :message)';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $userId,
':level' => $level,
':scope' => $scope,
':message' => $message,
]);
} catch (Exception $e) {
// swallowing exceptions or here we could log to error log for testing
}
}
}

View File

@ -1,34 +0,0 @@
<?php
/**
* LoggerFactory for Logger Plugin.
*
* Responsible for auto-migration and creating the Log instance.
*/
class LoggerFactory
{
/**
* @param object $db Database connector instance.
* @return array [Log $logger, string $userIP]
*/
public static function create($db): array
{
// Auto-migration: ensure log table exists
$pdo = $db->getConnection();
$migrationFile = __DIR__ . '/../migrations/create_log_table.sql';
if (file_exists($migrationFile)) {
$sql = file_get_contents($migrationFile);
$pdo->exec($sql);
}
// Load models and core IP helper
require_once __DIR__ . '/Log.php';
require_once __DIR__ . '/../../../app/helpers/ip_helper.php';
// Instantiate logger and retrieve user IP
$logger = new \Log($db);
$userIP = getUserIP();
return [$logger, $userIP];
}
}

View File

@ -1,6 +0,0 @@
{
"name": "Logger Plugin",
"version": "1.0.1",
"enabled": true,
"description": "Initializes logging system via LoggerFactory"
}

View File

@ -1,111 +0,0 @@
<!-- 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 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" style="width: 100%;">
<thead class="table-light">
<tr>
<?php if ($scope === 'system') { ?>
<th>Username&nbsp;(id)</th>
<?php } ?>
<th style="white-space: nowrap;">Time</th>
<th style="white-space: nowrap;">Log level</th>
<th>Log message</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs['records'] as $row) { ?>
<tr>
<?php if ($scope === 'system') { ?>
<td style="white-space: nowrap;"><?= $row['userID'] ? '<strong>' . htmlspecialchars($row['username'] . " ({$row['userID']})") . '</strong>' : '<span class="text-muted font-weight-normal small">SYSTEM</span>' ?></td>
<?php } ?>
<td style="white-space: nowrap;"><span class="text-muted"><?= date('d M Y H:i', strtotime($row['time'])) ?></span></td>
<td style="white-space: nowrap;"><span class="<?= getLogLevelClass($row['log level']) ?>"><?= htmlspecialchars($row['log level']) ?></span></td>
<td style="width: 100%; word-break: break-word;"><?= 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,24 +0,0 @@
<?php
// Register plugin bootstrap
// (here we add any plugin autoloader, if needed)
// List here all the controllers in "/controllers/" that we need as pages
$GLOBALS['plugin_controllers']['register'] = [
'register'
];
// Add to publicly accessible pages
register_hook('filter_public_pages', function($pages) {
$pages[] = 'register';
return $pages;
});
// Configuration for main menu injection
define('REGISTRATIONS_MAIN_MENU_SECTION', 'main');
define('REGISTRATIONS_MAIN_MENU_POSITION', 30);
register_hook('main_public_menu', function($ctx) {
$section = defined('REGISTRATIONS_MAIN_MENU_SECTION') ? REGISTRATIONS_MAIN_MENU_SECTION : 'main';
$position = defined('REGISTRATIONS_MAIN_MENU_POSITION') ? REGISTRATIONS_MAIN_MENU_POSITION : 100;
echo '<li><a href="?page=register">register</a></li>';
});

View File

@ -1,92 +0,0 @@
<?php
/**
* class Register
*
* Handles user registration.
*/
class Register {
/**
* @var PDO|null $db The database connection instance.
*/
private $db;
private $rateLimiter;
private $twoFactorAuth;
/**
* Register constructor.
* Initializes the database connection.
*
* @param object $database The database object to initialize the connection.
*/
public function __construct($database) {
if ($database instanceof PDO) {
$this->db = $database;
} else {
$this->db = $database->getConnection();
}
require_once dirname(__FILE__, 4) . '/app/classes/ratelimiter.php';
require_once dirname(__FILE__, 4) . '/app/classes/twoFactorAuth.php';
$this->rateLimiter = new RateLimiter($database);
$this->twoFactorAuth = new TwoFactorAuthentication($database);
}
/**
* Registers a new user.
*
* @param string $username The username of the new user.
* @param string $password The password for the new user.
*
* @return bool|string True if registration is successful, error message otherwise.
*/
public function register($username, $password) {
try {
// we have two inserts, start a transaction
$this->db->beginTransaction();
// hash the password, don't store it plain
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// insert into user table
$sql = 'INSERT
INTO user (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;
}
// insert the last user id into user_meta table
$sql2 = 'INSERT
INTO user_meta (user_id)
VALUES (:user_id)';
$query2 = $this->db->prepare($sql2);
$query2->bindValue(':user_id', $this->db->lastInsertId());
// execute the second query
if (!$query2->execute()) {
// rollback on error
$this->db->rollBack();
return false;
}
// if all is OK, commit the transaction
$this->db->commit();
return true;
} catch (Exception $e) {
// rollback on any error
$this->db->rollBack();
return $e->getMessage();
}
}
}

View File

@ -1,6 +0,0 @@
{
"name": "Registration Plugin",
"version": "1.0.1",
"enabled": true,
"description": "Provides registration functionality as a plugin."
}

View File

@ -11,126 +11,18 @@
* Version: 0.4 * Version: 0.4
*/ */
// error reporting, comment out in production
//ini_set('display_errors', 1);
//ini_set('display_startup_errors', 1);
//error_reporting(E_ALL);
// Prepare config loader
require_once __DIR__ . '/../app/core/ConfigLoader.php';
use App\Core\ConfigLoader;
// Load configuration
$config = ConfigLoader::loadConfig([
__DIR__ . '/../app/config/jilo-web.conf.php',
__DIR__ . '/../jilo-web.conf.php',
'/srv/jilo-web/jilo-web.conf.php',
'/opt/jilo-web/jilo-web.conf.php',
]);
// Make config available globally
$GLOBALS['config'] = $config;
// Expose config file path for pages
$config_file = ConfigLoader::getConfigPath();
$localConfigPath = str_replace(__DIR__ . '/..', '', $config_file);
// Set app root with default
$app_root = $config['folder'] ?? '/';
// Preparing plugins and hooks
// Initialize HookDispatcher and plugin system
require_once __DIR__ . '/../app/core/HookDispatcher.php';
require_once __DIR__ . '/../app/core/PluginManager.php';
use App\Core\HookDispatcher;
use App\Core\PluginManager;
// Global allowed URLs registration
register_hook('filter_allowed_urls', function($urls) {
if (isset($GLOBALS['plugin_controllers']) && is_array($GLOBALS['plugin_controllers'])) {
foreach ($GLOBALS['plugin_controllers'] as $controllers) {
foreach ($controllers as $ctrl) {
$urls[] = $ctrl;
}
}
}
return $urls;
});
// Hook registration and dispatch helpers
function register_hook(string $hook, callable $callback): void {
HookDispatcher::register($hook, $callback);
}
function do_hook(string $hook, array $context = []): void {
HookDispatcher::dispatch($hook, $context);
}
function filter_public_pages(array $pages): array {
return HookDispatcher::applyFilters('filter_public_pages', $pages);
}
function filter_allowed_urls(array $urls): array {
return HookDispatcher::applyFilters('filter_allowed_urls', $urls);
}
// Load enabled plugins
$plugins_dir = dirname(__DIR__) . '/plugins/';
$enabled_plugins = PluginManager::load($plugins_dir);
$GLOBALS['enabled_plugins'] = $enabled_plugins;
// Define CSRF token include path globally
if (!defined('CSRF_TOKEN_INCLUDE')) {
define('CSRF_TOKEN_INCLUDE', dirname(__DIR__) . '/app/includes/csrf_token.php');
}
// Global cnstants
require_once '../app/includes/constants.php';
// we start output buffering and // we start output buffering and
// flush it later only when there is no redirect // flush it later only when there is no redirect
ob_start(); ob_start();
// Start session before any session-dependent code // Apply security headers
require_once '../app/classes/session.php'; require_once '../app/includes/security_headers_middleware.php';
// Initialize themes system after session is started // sanitize all input vars that may end up in URLs or forms
require_once __DIR__ . '/../app/helpers/theme.php'; require '../app/includes/sanitize.php';
use app\Helpers\Theme;
Session::startSession(); session_name('jilo');
session_start();
// Define page variable early via sanitize
require_once __DIR__ . '/../app/includes/sanitize.php';
// Ensure $page is defined to avoid undefined variable
if (!isset($page)) {
$page = 'dashboard';
}
// List of pages that don't require authentication
$public_pages = ['login', 'help', 'about'];
// Let plugins filter/extend public_pages
$public_pages = filter_public_pages($public_pages);
// Middleware pipeline for security, sanitization & CSRF
require_once __DIR__ . '/../app/core/MiddlewarePipeline.php';
$pipeline = new \App\Core\MiddlewarePipeline();
$pipeline->add(function() {
// Apply security headers
require_once __DIR__ . '/../app/includes/security_headers_middleware.php';
return true;
});
// For public pages, we don't need to validate the session
// The Router will handle authentication for protected pages
$validSession = false;
$userId = null;
// Only check session for non-public pages
if (!in_array($page, $public_pages)) {
$validSession = Session::isValidSession(true);
if ($validSession) {
$userId = Session::getUserId();
}
}
// Initialize feedback message system // Initialize feedback message system
require_once '../app/classes/feedback.php'; require_once '../app/classes/feedback.php';
@ -138,149 +30,120 @@ $system_messages = [];
require '../app/includes/errors.php'; require '../app/includes/errors.php';
// error reporting, comment out in production
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// list of available pages // list of available pages
// edit accordingly, add 'pages/PAGE.php' // edit accordingly, add 'pages/PAGE.php'
$allowed_urls = [ $allowed_urls = [
'dashboard', 'dashboard',
'conferences','participants','components',
'graphs','latest','livejs','agents', 'conferences',
'profile','credentials','config','security', 'participants',
'settings','theme','theme-asset', 'components',
'admin-tools',
'graphs',
'latest',
'livejs',
'agents',
'config',
'profile',
'credentials',
'settings',
'security',
'status', 'status',
'help','about', 'logs',
'login','logout', 'help',
'login',
'logout',
'register',
]; ];
// Let plugins filter/extend allowed_urls // cnfig file
$allowed_urls = filter_allowed_urls($allowed_urls); // possible locations, in order of preference
$config_file_locations = [
// Dispatch routing and auth __DIR__ . '/../app/config/jilo-web.conf.php',
require_once __DIR__ . '/../app/core/Router.php'; __DIR__ . '/../jilo-web.conf.php',
use App\Core\Router; '/srv/jilo-web/jilo-web.conf.php',
$currentUser = Router::checkAuth($config, $app_root, $public_pages, $page); '/opt/jilo-web/jilo-web.conf.php'
];
// Connect to DB via DatabaseConnector $config_file = null;
require_once __DIR__ . '/../app/core/DatabaseConnector.php'; // try to find the config file
use App\Core\DatabaseConnector; foreach ($config_file_locations as $location) {
$db = DatabaseConnector::connect($config); if (file_exists($location)) {
$config_file = $location;
// Logging: default to NullLogger, plugin can override break;
require_once __DIR__ . '/../app/core/NullLogger.php'; }
use App\Core\NullLogger;
$logObject = new NullLogger();
// Get the user IP
require_once __DIR__ . '/../app/helpers/ip_helper.php';
$user_IP = '';
// Plugin: initialize logging system plugin if available
do_hook('logger.system_init', ['db' => $db]);
// Override defaults if plugin provided real logger
if (isset($GLOBALS['logObject'])) {
$logObject = $GLOBALS['logObject'];
} }
if (isset($GLOBALS['user_IP'])) { // if found, use it
$user_IP = $GLOBALS['user_IP']; if ($config_file) {
$localConfigPath = str_replace(__DIR__ . '/..', '', $config_file);
$config = require $config_file;
} else {
die('Config file not found');
} }
// Check for pending DB migrations (non-intrusive: warn only) $app_root = $config['folder'];
// check if logged in
unset($currentUser);
if (isset($_COOKIE['username'])) {
if ( !isset($_SESSION['username']) ) {
$_SESSION['username'] = $_COOKIE['username'];
}
$currentUser = htmlspecialchars($_SESSION['username']);
}
// redirect to login
if ( !isset($_COOKIE['username']) && ($page !== 'login' && $page !== 'register') ) {
header('Location: ' . htmlspecialchars($app_root) . '?page=login');
exit();
}
// connect to db of Jilo Web
require '../app/classes/database.php';
require '../app/includes/database.php';
try { try {
$migrationsDir = __DIR__ . '/../doc/database/migrations'; $response = connectDB($config);
if (is_dir($migrationsDir)) { if (!$response['db']) {
require_once __DIR__ . '/../app/core/MigrationRunner.php'; throw new Exception('Could not connect to database: ' . $response['error']);
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
if ($runner->hasPendingMigrations()) {
$pending = $runner->listPendingMigrations();
$msg = 'Database schema is out of date. There are pending migrations. Run "<code>php scripts/migrate.php up</code>" or use the <a href="?page=admin-tools">Admin tools</a>';
// Log and show as a system message
$logObject->log('warning', $msg, ['scope' => 'system']);
Feedback::flash('SYSTEM', 'MIGRATIONS_PENDING', $msg, false, true, false);
}
} }
} catch (\Throwable $e) { $dbWeb = $response['db'];
// Do not break the app; log only } catch (Exception $e) {
error_log('Migration check failed: ' . $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to the database.', $e->getMessage()));
include '../app/templates/page-header.php';
include '../app/helpers/feedback.php';
include '../app/templates/page-footer.php';
exit();
} }
// CSRF middleware and run pipeline // start logging
$pipeline->add(function() { require '../app/classes/log.php';
// Initialize security middleware include '../app/helpers/logs.php';
require_once __DIR__ . '/../app/includes/csrf_middleware.php'; $logObject = new Log($dbWeb);
require_once __DIR__ . '/../app/helpers/security.php'; $user_IP = getUserIP();
$security = SecurityHelper::getInstance();
// Verify CSRF token for POST requests
return applyCsrfMiddleware();
});
$pipeline->add(function() {
// Init rate limiter
global $db, $page, $userId;
require_once __DIR__ . '/../app/includes/rate_limit_middleware.php';
return checkRateLimit($db, $page, $userId);
});
$pipeline->add(function() {
// Init user functions
global $db, $userObject;
require_once __DIR__ . '/../app/classes/user.php';
include __DIR__ . '/../app/helpers/profile.php';
$userObject = new User($db);
return true;
});
if (!$pipeline->run()) {
exit;
}
// Maintenance mode: show maintenance page to non-superusers // Initialize security middleware
try { require_once '../app/includes/csrf_middleware.php';
require_once __DIR__ . '/../app/core/Maintenance.php'; require_once '../app/helpers/security.php';
if (\App\Core\Maintenance::isEnabled()) { $security = SecurityHelper::getInstance();
$isSuperuser = false;
if ($validSession && isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
// user 1 is always superuser per implementation, but also check explicit right
$isSuperuser = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser');
}
if (!$isSuperuser) {
http_response_code(503);
// Advise clients to retry after 10 minutes (600 seconds; configure here)
header('Retry-After: 600');
// Show themed maintenance page
\App\Helpers\Theme::include('page-header');
\App\Helpers\Theme::include('page-menu');
include __DIR__ . '/../app/templates/maintenance.php';
\App\Helpers\Theme::include('page-footer');
ob_end_flush();
exit;
} else {
// Superusers bypass maintenance; show a small banner
$maintMsg = \App\Core\Maintenance::getMessage();
$custom = 'Maintenance mode is enabled.';
if (!empty($maintMsg)) {
$custom .= ' <em>' . htmlspecialchars($maintMsg) . '</em>';
}
$custom .= ' Control it in <a href="' . htmlspecialchars($app_root) . '?page=admin-tools">Admin tools</a>';
// Non-dismissible and small, do not sanitize to allow link and <em>
Feedback::flash('SYSTEM', 'MAINTENANCE_ON', $custom, false, true, false);
}
}
} catch (\Throwable $e) {
// Do not break app if maintenance check fails
}
// Apply per-user theme from DB into session (without persisting) once user is known // Verify CSRF token for POST requests
if ($validSession && isset($userId) && isset($userObject) && is_object($userObject) && method_exists($userObject, 'getUserTheme')) { applyCsrfMiddleware();
try {
$dbTheme = $userObject->getUserTheme((int)$userId); // init rate limiter
if ($dbTheme) { require '../app/classes/ratelimiter.php';
\App\Helpers\Theme::setCurrentTheme($dbTheme, false);
}
} catch (\Throwable $e) {
// Non-fatal if theme load fails
}
}
// get platforms details // get platforms details
require '../app/classes/platform.php'; require '../app/classes/platform.php';
$platformObject = new Platform($db); $platformObject = new Platform($dbWeb);
$platformsAll = $platformObject->getPlatformDetails(); $platformsAll = $platformObject->getPlatformDetails();
// by default we connect ot the first configured platform // by default we connect ot the first configured platform
@ -290,50 +153,54 @@ if ($platform_id == '') {
$platformDetails = $platformObject->getPlatformDetails($platform_id); $platformDetails = $platformObject->getPlatformDetails($platform_id);
// init user functions
require '../app/classes/user.php';
include '../app/helpers/profile.php';
$userObject = new User($dbWeb);
// logout is a special case, as we can't use session vars for notices // logout is a special case, as we can't use session vars for notices
if ($page == 'logout') { if ($page == 'logout') {
// Save config before destroying session // get user info before destroying session
$savedConfig = $config; $user_id = $userObject->getUserId($currentUser)[0]['id'];
// clean up session // clean up session
Session::destroySession(); session_unset();
session_destroy();
// start new session for the login page // start new session for the login page
Session::startSession(); session_start();
// Restore config to global scope
$config = $savedConfig;
$GLOBALS['config'] = $config;
setcookie('username', "", time() - 100, $config['folder'], $config['domain'], isset($_SERVER['HTTPS']), true); setcookie('username', "", time() - 100, $config['folder'], $config['domain'], isset($_SERVER['HTTPS']), true);
// Log successful logout // Log successful logout
$logObject->log('info', "Logout: User \"$currentUser\" logged out. IP: $user_IP", ['user_id' => $userId, 'scope' => 'user']); $logObject->insertLog($user_id, "Logout: User \"$currentUser\" logged out. IP: $user_IP", 'user');
// Set success message // Set success message
Feedback::flash('LOGIN', 'LOGOUT_SUCCESS'); Feedback::flash('LOGIN', 'LOGOUT_SUCCESS');
// Use theme helper to include templates include '../app/templates/page-header.php';
\App\Helpers\Theme::include('page-header'); include '../app/templates/page-menu.php';
\App\Helpers\Theme::include('page-menu');
include '../app/pages/login.php'; include '../app/pages/login.php';
\App\Helpers\Theme::include('page-footer'); include '../app/templates/page-footer.php';
} else { } else {
// if user is logged in, we need user details and rights // if user is logged in, we need user details and rights
if ($validSession) { if (isset($currentUser)) {
// If by error a logged in user requests the login page // If by error a logged in user requests the login page
if ($page === 'login') { if ($page === 'login') {
header('Location: ' . htmlspecialchars($app_root)); header('Location: ' . htmlspecialchars($app_root));
exit(); exit();
} }
$userDetails = $userObject->getUserDetails($userId); $user_id = $userObject->getUserId($currentUser)[0]['id'];
$userRights = $userObject->getUserRights($userId); $userDetails = $userObject->getUserDetails($user_id);
$userRights = $userObject->getUserRights($user_id);
$userTimezone = (!empty($userDetails[0]['timezone'])) ? $userDetails[0]['timezone'] : 'UTC'; // Default to UTC if no timezone is set (or is missing) $userTimezone = (!empty($userDetails[0]['timezone'])) ? $userDetails[0]['timezone'] : 'UTC'; // Default to UTC if no timezone is set (or is missing)
// check if the Jilo Server is running // check if the Jilo Server is running
require '../app/classes/server.php'; require '../app/classes/server.php';
$serverObject = new Server($db); $serverObject = new Server($dbWeb);
$server_host = '127.0.0.1'; $server_host = '127.0.0.1';
$server_port = '8080'; $server_port = '8080';
@ -344,70 +211,28 @@ if ($page == 'logout') {
} }
} }
// --- Plugin loading logic for all enabled plugins --- // List of pages that don't require authentication
// Ensure all enabled plugin bootstraps are loaded before mapping controllers $public_pages = ['login', 'register'];
foreach ($GLOBALS['enabled_plugins'] as $plugin_name => $plugin_info) {
$bootstrap_path = $plugin_info['path'] . '/bootstrap.php'; // Check if the requested page requires authentication
if (file_exists($bootstrap_path)) { if (!in_array($page, $public_pages)) {
require_once $bootstrap_path; require_once '../app/includes/session_middleware.php';
}
}
// Plugin controller mapping logic (we add each controller listed in bootstrap as a page)
$mapped_plugin_controllers = [];
foreach ($GLOBALS['enabled_plugins'] as $plugin_name => $plugin_info) {
if (isset($GLOBALS['plugin_controllers'][$plugin_name])) {
foreach ($GLOBALS['plugin_controllers'][$plugin_name] as $plugin_page) {
$controller_path = $plugin_info['path'] . '/controllers/' . $plugin_page . '.php';
if (file_exists($controller_path)) {
$mapped_plugin_controllers[$plugin_page] = $controller_path;
}
}
}
} }
// page building // page building
if (in_array($page, $allowed_urls)) { include '../app/templates/page-header.php';
// The page is in allowed URLs include '../app/templates/page-menu.php';
if (isset($mapped_plugin_controllers[$page]) && file_exists($mapped_plugin_controllers[$page])) { if (isset($currentUser)) {
// The page is from a plugin controller include '../app/templates/page-sidebar.php';
if (defined('PLUGIN_PAGE_DIRECT_OUTPUT') && PLUGIN_PAGE_DIRECT_OUTPUT === true) {
// Barebone page controller, we don't output anything extra
include $mapped_plugin_controllers[$page];
ob_end_flush();
exit;
} else {
\App\Helpers\Theme::include('page-header');
\App\Helpers\Theme::include('page-menu');
if ($validSession) {
\App\Helpers\Theme::include('page-sidebar');
}
include $mapped_plugin_controllers[$page];
\App\Helpers\Theme::include('page-footer');
}
} else {
// The page is from a core controller
\App\Helpers\Theme::include('page-header');
\App\Helpers\Theme::include('page-menu');
if ($validSession) {
\App\Helpers\Theme::include('page-sidebar');
}
if (file_exists("../app/pages/{$page}.php")) {
include "../app/pages/{$page}.php";
} else {
include '../app/templates/error-notfound.php';
}
\App\Helpers\Theme::include('page-footer');
}
} else {
// The page is not in allowed URLs
\App\Helpers\Theme::include('page-header');
\App\Helpers\Theme::include('page-menu');
if ($validSession) {
\App\Helpers\Theme::include('page-sidebar');
}
include '../app/templates/error-notfound.php';
\App\Helpers\Theme::include('page-footer');
} }
if (in_array($page, $allowed_urls)) {
// all normal pages
include "../app/pages/{$page}.php";
} else {
// the page is not in allowed urls, loading "not found" page
include '../app/templates/error-notfound.php';
}
include '../app/templates/page-footer.php';
} }
// flush the output buffer and show the page // flush the output buffer and show the page

View File

@ -1,328 +0,0 @@
/*!
Highlight.js v11.11.1 (git: 08cb242e7d)
(c) 2006-2025 Josh Goebel <hello@joshgoebel.com> and other contributors
License: BSD-3-Clause
*/
var hljs=function(){"use strict";function e(t){
return t instanceof Map?t.clear=t.delete=t.set=()=>{
throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{
throw Error("set is read-only")
}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{
const i=t[n],s=typeof i;"object"!==s&&"function"!==s||Object.isFrozen(i)||e(i)
})),t}class t{constructor(e){
void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}
ignoreMatch(){this.isMatchIgnored=!0}}function n(e){
return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")
}function i(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope
;class r{constructor(e,t){
this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){
this.buffer+=n(e)}openNode(e){if(!s(e))return;const t=((e,{prefix:t})=>{
if(e.startsWith("language:"))return e.replace("language:","language-")
;if(e.includes(".")){const n=e.split(".")
;return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ")
}return`${t}${e}`})(e.scope,{prefix:this.classPrefix});this.span(t)}
closeNode(e){s(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
this.buffer+=`<span class="${e}">`}}const o=(e={})=>{const t={children:[]}
;return Object.assign(t,e),t};class a{constructor(){
this.rootNode=o(),this.stack=[this.rootNode]}get top(){
return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){
this.top.children.push(e)}openNode(e){const t=o({scope:e})
;this.add(t),this.stack.push(t)}closeNode(){
if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){
for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}
walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){
return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),
t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){
"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{
a._collapse(e)})))}}class c extends a{constructor(e){super(),this.options=e}
addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){
this.closeNode()}__addSublanguage(e,t){const n=e.root
;t&&(n.scope="language:"+t),this.add(n)}toHTML(){
return new r(this,this.options).value()}finalize(){
return this.closeAllNodes(),!0}}function l(e){
return e?"string"==typeof e?e:e.source:null}function g(e){return h("(?=",e,")")}
function u(e){return h("(?:",e,")*")}function d(e){return h("(?:",e,")?")}
function h(...e){return e.map((e=>l(e))).join("")}function f(...e){const t=(e=>{
const t=e[e.length-1]
;return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{}
})(e);return"("+(t.capture?"":"?:")+e.map((e=>l(e))).join("|")+")"}
function p(e){return RegExp(e.toString()+"|").exec("").length-1}
const b=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./
;function m(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n
;let i=l(e),s="";for(;i.length>0;){const e=b.exec(i);if(!e){s+=i;break}
s+=i.substring(0,e.index),
i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0],
"("===e[0]&&n++)}return s})).map((e=>`(${e})`)).join(t)}
const E="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",_="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",O={
begin:"\\\\[\\s\\S]",relevance:0},v={scope:"string",begin:"'",end:"'",
illegal:"\\n",contains:[O]},k={scope:"string",begin:'"',end:'"',illegal:"\\n",
contains:[O]},N=(e,t,n={})=>{const s=i({scope:"comment",begin:e,end:t,
contains:[]},n);s.contains.push({scope:"doctag",
begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",
end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0})
;const r=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/)
;return s.contains.push({begin:h(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s
},S=N("//","$"),M=N("/\\*","\\*/"),R=N("#","$");var j=Object.freeze({
__proto__:null,APOS_STRING_MODE:v,BACKSLASH_ESCAPE:O,BINARY_NUMBER_MODE:{
scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:N,
C_BLOCK_COMMENT_MODE:M,C_LINE_COMMENT_MODE:S,C_NUMBER_MODE:{scope:"number",
begin:_,relevance:0},C_NUMBER_RE:_,END_SAME_AS_BEGIN:e=>Object.assign(e,{
"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{
t.data._beginMatch!==e[1]&&t.ignoreMatch()}}),HASH_COMMENT_MODE:R,IDENT_RE:E,
MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+x,relevance:0},
NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y,
PHRASAL_WORDS_MODE:{
begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/
},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/,
end:/\/[gimuy]*/,contains:[O,{begin:/\[/,end:/\]/,relevance:0,contains:[O]}]},
RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",
SHEBANG:(e={})=>{const t=/^#![ ]*\//
;return e.binary&&(e.begin=h(t,/.*\b/,e.binary,/\b.*/)),i({scope:"meta",begin:t,
end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)},
TITLE_MODE:{scope:"title",begin:E,relevance:0},UNDERSCORE_IDENT_RE:x,
UNDERSCORE_TITLE_MODE:{scope:"title",begin:x,relevance:0}});function A(e,t){
"."===e.input[e.index-1]&&t.ignoreMatch()}function I(e,t){
void 0!==e.className&&(e.scope=e.className,delete e.className)}function T(e,t){
t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",
e.__beforeBegin=A,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,
void 0===e.relevance&&(e.relevance=0))}function L(e,t){
Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function B(e,t){
if(e.match){
if(e.begin||e.end)throw Error("begin & end are not supported with match")
;e.begin=e.match,delete e.match}}function P(e,t){
void 0===e.relevance&&(e.relevance=1)}const D=(e,t)=>{if(!e.beforeMatch)return
;if(e.starts)throw Error("beforeMatch cannot be used with starts")
;const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t]
})),e.keywords=n.keywords,e.begin=h(n.beforeMatch,g(n.begin)),e.starts={
relevance:0,contains:[Object.assign(n,{endsParent:!0})]
},e.relevance=0,delete n.beforeMatch
},H=["of","and","for","in","not","or","if","then","parent","list","value"]
;function C(e,t,n="keyword"){const i=Object.create(null)
;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{
Object.assign(i,C(e[n],t,n))})),i;function s(e,n){
t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|")
;i[n[0]]=[e,$(n[0],n[1])]}))}}function $(e,t){
return t?Number(t):(e=>H.includes(e.toLowerCase()))(e)?0:1}const U={},z=e=>{
console.error(e)},W=(e,...t)=>{console.log("WARN: "+e,...t)},X=(e,t)=>{
U[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),U[`${e}/${t}`]=!0)
},G=Error();function K(e,t,{key:n}){let i=0;const s=e[n],r={},o={}
;for(let e=1;e<=t.length;e++)o[e+i]=s[e],r[e+i]=!0,i+=p(t[e-1])
;e[n]=o,e[n]._emit=r,e[n]._multi=!0}function F(e){(e=>{
e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope,
delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={
_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope
}),(e=>{if(Array.isArray(e.begin)){
if(e.skip||e.excludeBegin||e.returnBegin)throw z("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),
G
;if("object"!=typeof e.beginScope||null===e.beginScope)throw z("beginScope must be object"),
G;K(e,e.begin,{key:"beginScope"}),e.begin=m(e.begin,{joinWith:""})}})(e),(e=>{
if(Array.isArray(e.end)){
if(e.skip||e.excludeEnd||e.returnEnd)throw z("skip, excludeEnd, returnEnd not compatible with endScope: {}"),
G
;if("object"!=typeof e.endScope||null===e.endScope)throw z("endScope must be object"),
G;K(e,e.end,{key:"endScope"}),e.end=m(e.end,{joinWith:""})}})(e)}function Z(e){
function t(t,n){
return RegExp(l(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":""))
}class n{constructor(){
this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}
addRule(e,t){
t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),
this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null)
;const e=this.regexes.map((e=>e[1]));this.matcherRe=t(m(e,{joinWith:"|"
}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex
;const t=this.matcherRe.exec(e);if(!t)return null
;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n]
;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){
this.rules=[],this.multiRegexes=[],
this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){
if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n
;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),
t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){
return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){
this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){
const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex
;let n=t.exec(e)
;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{
const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}
return n&&(this.regexIndex+=n.position+1,
this.regexIndex===this.count&&this.considerAll()),n}}
if(e.compilerExtensions||(e.compilerExtensions=[]),
e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.")
;return e.classNameAliases=i(e.classNameAliases||{}),function n(r,o){const a=r
;if(r.isCompiled)return a
;[I,B,F,D].forEach((e=>e(r,o))),e.compilerExtensions.forEach((e=>e(r,o))),
r.__beforeBegin=null,[T,L,P].forEach((e=>e(r,o))),r.isCompiled=!0;let c=null
;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords),
c=r.keywords.$pattern,
delete r.keywords.$pattern),c=c||/\w+/,r.keywords&&(r.keywords=C(r.keywords,e.case_insensitive)),
a.keywordPatternRe=t(c,!0),
o&&(r.begin||(r.begin=/\B|\b/),a.beginRe=t(a.begin),r.end||r.endsWithParent||(r.end=/\B|\b/),
r.end&&(a.endRe=t(a.end)),
a.terminatorEnd=l(a.end)||"",r.endsWithParent&&o.terminatorEnd&&(a.terminatorEnd+=(r.end?"|":"")+o.terminatorEnd)),
r.illegal&&(a.illegalRe=t(r.illegal)),
r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>i(e,{
variants:null},t)))),e.cachedVariants?e.cachedVariants:V(e)?i(e,{
starts:e.starts?i(e.starts):null
}):Object.isFrozen(e)?i(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{n(e,a)
})),r.starts&&n(r.starts,o),a.matcher=(e=>{const t=new s
;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"
}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end"
}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function V(e){
return!!e&&(e.endsWithParent||V(e.starts))}class q extends Error{
constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}}
const J=n,Y=i,Q=Symbol("nomatch"),ee=n=>{
const i=Object.create(null),s=Object.create(null),r=[];let o=!0
;const a="Could not find the language '{}', did you forget to load/include a language module?",l={
disableAutodetect:!0,name:"Plain text",contains:[]};let p={
ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,
languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",
cssSelector:"pre code",languages:null,__emitter:c};function b(e){
return p.noHighlightRe.test(e)}function m(e,t,n){let i="",s=""
;"object"==typeof t?(i=e,
n=t.ignoreIllegals,s=t.language):(X("10.7.0","highlight(lang, code, ...args) has been deprecated."),
X("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),
s=e,i=t),void 0===n&&(n=!0);const r={code:i,language:s};N("before:highlight",r)
;const o=r.result?r.result:E(r.language,r.code,n)
;return o.code=r.code,N("after:highlight",o),o}function E(e,n,s,r){
const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(R)
;let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(R),n=""
;for(;t;){n+=R.substring(e,t.index)
;const s=w.case_insensitive?t[0].toLowerCase():t[0],r=(i=s,N.keywords[i]);if(r){
const[e,i]=r
;if(M.addText(n),n="",c[s]=(c[s]||0)+1,c[s]<=7&&(j+=i),e.startsWith("_"))n+=t[0];else{
const n=w.classNameAliases[e]||e;u(t[0],n)}}else n+=t[0]
;e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(R)}var i
;n+=R.substring(e),M.addText(n)}function g(){null!=N.subLanguage?(()=>{
if(""===R)return;let e=null;if("string"==typeof N.subLanguage){
if(!i[N.subLanguage])return void M.addText(R)
;e=E(N.subLanguage,R,!0,S[N.subLanguage]),S[N.subLanguage]=e._top
}else e=x(R,N.subLanguage.length?N.subLanguage:null)
;N.relevance>0&&(j+=e.relevance),M.__addSublanguage(e._emitter,e.language)
})():l(),R=""}function u(e,t){
""!==e&&(M.startScope(t),M.addText(e),M.endScope())}function d(e,t){let n=1
;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue}
const i=w.classNameAliases[e[n]]||e[n],s=t[n];i?u(s,i):(R=s,l(),R=""),n++}}
function h(e,t){
return e.scope&&"string"==typeof e.scope&&M.openNode(w.classNameAliases[e.scope]||e.scope),
e.beginScope&&(e.beginScope._wrap?(u(R,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap),
R=""):e.beginScope._multi&&(d(e.beginScope,t),R="")),N=Object.create(e,{parent:{
value:N}}),N}function f(e,n,i){let s=((e,t)=>{const n=e&&e.exec(t)
;return n&&0===n.index})(e.endRe,i);if(s){if(e["on:end"]){const i=new t(e)
;e["on:end"](n,i),i.isMatchIgnored&&(s=!1)}if(s){
for(;e.endsParent&&e.parent;)e=e.parent;return e}}
if(e.endsWithParent)return f(e.parent,n,i)}function b(e){
return 0===N.matcher.regexIndex?(R+=e[0],1):(T=!0,0)}function m(e){
const t=e[0],i=n.substring(e.index),s=f(N,e,i);if(!s)return Q;const r=N
;N.endScope&&N.endScope._wrap?(g(),
u(t,N.endScope._wrap)):N.endScope&&N.endScope._multi?(g(),
d(N.endScope,e)):r.skip?R+=t:(r.returnEnd||r.excludeEnd||(R+=t),
g(),r.excludeEnd&&(R=t));do{
N.scope&&M.closeNode(),N.skip||N.subLanguage||(j+=N.relevance),N=N.parent
}while(N!==s.parent);return s.starts&&h(s.starts,e),r.returnEnd?0:t.length}
let y={};function _(i,r){const a=r&&r[0];if(R+=i,null==a)return g(),0
;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===a){
if(R+=n.slice(r.index,r.index+1),!o){const t=Error(`0 width match regex (${e})`)
;throw t.languageName=e,t.badRule=y.rule,t}return 1}
if(y=r,"begin"===r.type)return(e=>{
const n=e[0],i=e.rule,s=new t(i),r=[i.__beforeBegin,i["on:begin"]]
;for(const t of r)if(t&&(t(e,s),s.isMatchIgnored))return b(n)
;return i.skip?R+=n:(i.excludeBegin&&(R+=n),
g(),i.returnBegin||i.excludeBegin||(R=n)),h(i,e),i.returnBegin?0:n.length})(r)
;if("illegal"===r.type&&!s){
const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"<unnamed>")+'"')
;throw e.mode=N,e}if("end"===r.type){const e=m(r);if(e!==Q)return e}
if("illegal"===r.type&&""===a)return R+="\n",1
;if(I>1e5&&I>3*r.index)throw Error("potential infinite loop, way more iterations than matches")
;return R+=a,a.length}const w=O(e)
;if(!w)throw z(a.replace("{}",e)),Error('Unknown language: "'+e+'"')
;const v=Z(w);let k="",N=r||v;const S={},M=new p.__emitter(p);(()=>{const e=[]
;for(let t=N;t!==w;t=t.parent)t.scope&&e.unshift(t.scope)
;e.forEach((e=>M.openNode(e)))})();let R="",j=0,A=0,I=0,T=!1;try{
if(w.__emitTokens)w.__emitTokens(n,M);else{for(N.matcher.considerAll();;){
I++,T?T=!1:N.matcher.considerAll(),N.matcher.lastIndex=A
;const e=N.matcher.exec(n);if(!e)break;const t=_(n.substring(A,e.index),e)
;A=e.index+t}_(n.substring(A))}return M.finalize(),k=M.toHTML(),{language:e,
value:k,relevance:j,illegal:!1,_emitter:M,_top:N}}catch(t){
if(t.message&&t.message.includes("Illegal"))return{language:e,value:J(n),
illegal:!0,relevance:0,_illegalBy:{message:t.message,index:A,
context:n.slice(A-100,A+100),mode:t.mode,resultSoFar:k},_emitter:M};if(o)return{
language:e,value:J(n),illegal:!1,relevance:0,errorRaised:t,_emitter:M,_top:N}
;throw t}}function x(e,t){t=t||p.languages||Object.keys(i);const n=(e=>{
const t={value:J(e),illegal:!1,relevance:0,_top:l,_emitter:new p.__emitter(p)}
;return t._emitter.addText(e),t})(e),s=t.filter(O).filter(k).map((t=>E(t,e,!1)))
;s.unshift(n);const r=s.sort(((e,t)=>{
if(e.relevance!==t.relevance)return t.relevance-e.relevance
;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1
;if(O(t.language).supersetOf===e.language)return-1}return 0})),[o,a]=r,c=o
;return c.secondBest=a,c}function y(e){let t=null;const n=(e=>{
let t=e.className+" ";t+=e.parentNode?e.parentNode.className:""
;const n=p.languageDetectRe.exec(t);if(n){const t=O(n[1])
;return t||(W(a.replace("{}",n[1])),
W("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"}
return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return
;if(N("before:highlightElement",{el:e,language:n
}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e)
;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),
console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),
console.warn("The element with unescaped HTML:"),
console.warn(e)),p.throwUnescapedHTML))throw new q("One of your code blocks includes unescaped HTML.",e.innerHTML)
;t=e;const i=t.textContent,r=n?m(i,{language:n,ignoreIllegals:!0}):x(i)
;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,t,n)=>{const i=t&&s[t]||n
;e.classList.add("hljs"),e.classList.add("language-"+i)
})(e,n,r.language),e.result={language:r.language,re:r.relevance,
relevance:r.relevance},r.secondBest&&(e.secondBest={
language:r.secondBest.language,relevance:r.secondBest.relevance
}),N("after:highlightElement",{el:e,result:r,text:i})}let _=!1;function w(){
if("loading"===document.readyState)return _||window.addEventListener("DOMContentLoaded",(()=>{
w()}),!1),void(_=!0);document.querySelectorAll(p.cssSelector).forEach(y)}
function O(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]}
function v(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{
s[e.toLowerCase()]=t}))}function k(e){const t=O(e)
;return t&&!t.disableAutodetect}function N(e,t){const n=e;r.forEach((e=>{
e[n]&&e[n](t)}))}Object.assign(n,{highlight:m,highlightAuto:x,highlightAll:w,
highlightElement:y,
highlightBlock:e=>(X("10.7.0","highlightBlock will be removed entirely in v12.0"),
X("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)},
initHighlighting:()=>{
w(),X("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")},
initHighlightingOnLoad:()=>{
w(),X("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")
},registerLanguage:(e,t)=>{let s=null;try{s=t(n)}catch(t){
if(z("Language definition for '{}' could not be registered.".replace("{}",e)),
!o)throw t;z(t),s=l}
s.name||(s.name=e),i[e]=s,s.rawDefinition=t.bind(null,n),s.aliases&&v(s.aliases,{
languageName:e})},unregisterLanguage:e=>{delete i[e]
;for(const t of Object.keys(s))s[t]===e&&delete s[t]},
listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:v,
autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{
e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{
e["before:highlightBlock"](Object.assign({block:t.el},t))
}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{
e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),r.push(e)},
removePlugin:e=>{const t=r.indexOf(e);-1!==t&&r.splice(t,1)}}),n.debugMode=()=>{
o=!1},n.safeMode=()=>{o=!0},n.versionString="11.11.1",n.regex={concat:h,
lookahead:g,either:f,optional:d,anyNumberOfTimes:u}
;for(const t in j)"object"==typeof j[t]&&e(j[t]);return Object.assign(n,j),n
},te=ee({});return te.newInstance=()=>ee({}),te}()
;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);/*! `sql` grammar compiled for Highlight.js 11.11.1 */
(()=>{var e=(()=>{"use strict";return e=>{
const r=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],n=a,s=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),i={
match:r.concat(/\b/,r.either(...n),/\s*\(/),relevance:0,keywords:{built_in:n}}
;function o(e){
return r.concat(/\b/,r.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)}
const c={scope:"keyword",
match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]),
relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{
$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:r,when:t}={})=>{const a=t
;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:a(e)?e+"|0":e))
})(s,{when:e=>e.length<3}),literal:["true","false","unknown"],
type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],
built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"]
},contains:[{scope:"type",
match:o(["double precision","large object","with timezone","without timezone"])
},c,i,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{
begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{
match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator",
match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})()
;hljs.registerLanguage("sql",e)})();

View File

@ -1,21 +0,0 @@
/*! `sql` grammar compiled for Highlight.js 11.11.1 */
(()=>{var e=(()=>{"use strict";return e=>{
const r=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],n=a,s=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),i={
match:r.concat(/\b/,r.either(...n),/\s*\(/),relevance:0,keywords:{built_in:n}}
;function o(e){
return r.concat(/\b/,r.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)}
const c={scope:"keyword",
match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]),
relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{
$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:r,when:t}={})=>{const a=t
;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:a(e)?e+"|0":e))
})(s,{when:e=>e.length<3}),literal:["true","false","unknown"],
type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],
built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"]
},contains:[{scope:"type",
match:o(["double precision","large object","with timezone","without timezone"])
},c,i,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{
begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{
match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator",
match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})()
;hljs.registerLanguage("sql",e)})();

View File

@ -1,10 +0,0 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}

View File

@ -1,49 +0,0 @@
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../app/core/Maintenance.php';
use App\Core\Maintenance;
function usage()
{
echo "\nJilo Web - Maintenance Mode\n";
echo "Usage:\n";
echo " php scripts/maintenance.php on [message] # Enable maintenance mode with optional message\n";
echo " php scripts/maintenance.php off # Disable maintenance mode\n";
echo " php scripts/maintenance.php status # Show maintenance status\n\n";
}
$cmd = $argv[1] ?? 'status';
try {
switch ($cmd) {
case 'on':
$message = $argv[2] ?? '';
if (Maintenance::enable($message)) {
echo "Maintenance mode ENABLED" . ($message ? ": $message" : '') . "\n";
exit(0);
}
fwrite(STDERR, "Failed to enable maintenance mode\n");
exit(1);
case 'off':
if (Maintenance::disable()) {
echo "Maintenance mode DISABLED\n";
exit(0);
}
fwrite(STDERR, "Failed to disable maintenance mode\n");
exit(1);
case 'status':
default:
if (Maintenance::isEnabled()) {
$msg = Maintenance::getMessage();
echo "Maintenance: ON" . ($msg ? " - $msg" : '') . "\n";
} else {
echo "Maintenance: OFF\n";
}
exit(0);
}
} catch (Throwable $e) {
fwrite(STDERR, "Error: " . $e->getMessage() . "\n");
exit(1);
}

View File

@ -1,74 +0,0 @@
#!/usr/bin/env php
<?php
// Simple CLI to run DB migrations
require_once __DIR__ . '/../app/core/ConfigLoader.php';
require_once __DIR__ . '/../app/core/DatabaseConnector.php';
require_once __DIR__ . '/../app/core/MigrationRunner.php';
use App\Core\ConfigLoader;
use App\Core\DatabaseConnector;
use App\Core\MigrationRunner;
function printUsage()
{
echo "\nJilo Web - Database Migrations\n";
echo "Usage:\n";
echo " php scripts/migrate.php status # Show pending and applied migrations\n";
echo " php scripts/migrate.php up # Apply all pending migrations\n";
echo "\n";
}
$action = $argv[1] ?? 'status';
try {
// Load configuration to connect to DB
$config = ConfigLoader::loadConfig([
__DIR__ . '/../app/config/jilo-web.conf.php',
__DIR__ . '/../jilo-web.conf.php',
'/srv/jilo-web/jilo-web.conf.php',
'/opt/jilo-web/jilo-web.conf.php',
]);
$db = DatabaseConnector::connect($config);
$migrationsDir = realpath(__DIR__ . '/../doc/database/migrations');
if ($migrationsDir === false) {
fwrite(STDERR, "Migrations directory not found: doc/database/migrations\n");
exit(1);
}
$runner = new MigrationRunner($db, $migrationsDir);
if ($action === 'status') {
$all = $runner->listAllMigrations();
$applied = $runner->listAppliedMigrations();
$pending = $runner->listPendingMigrations();
echo "All migrations (" . count($all) . "):\n";
foreach ($all as $m) echo " - $m\n";
echo "\nApplied (" . count($applied) . "):\n";
foreach ($applied as $m) echo " - $m\n";
echo "\nPending (" . count($pending) . "):\n";
foreach ($pending as $m) echo " - $m\n";
echo "\n";
exit(0);
} elseif ($action === 'up') {
$pending = $runner->listPendingMigrations();
if (empty($pending)) {
echo "No pending migrations.\n";
exit(0);
}
echo "Applying " . count($pending) . " migration(s):\n";
foreach ($pending as $m) echo " - $m\n";
$applied = $runner->applyPendingMigrations();
echo "\nApplied successfully: " . count($applied) . "\n";
exit(0);
} else {
printUsage();
exit(1);
}
} catch (Throwable $e) {
fwrite(STDERR, "Migration error: " . $e->getMessage() . "\n");
exit(1);
}

View File

@ -1,7 +0,0 @@
<?php
namespace Tests\Feature\Middleware\Mock;
class Feedback {
public static function flash($type, $message) {}
}

View File

@ -1,19 +0,0 @@
<?php
namespace Tests\Feature\Middleware\Mock;
class Session {
public static function startSession() {}
public static function isValidSession() {
return isset($_SESSION["user_id"]) &&
isset($_SESSION["username"]) &&
(!isset($_SESSION["LAST_ACTIVITY"]) ||
$_SESSION["LAST_ACTIVITY"] > time() - 7200 ||
isset($_SESSION["REMEMBER_ME"]));
}
public static function cleanup($config) {
$_SESSION = [];
}
}

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