Compare commits

..

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

167 changed files with 2726 additions and 16446 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -7,84 +7,16 @@ All notable changes to this project will be documented in this file.
## Unreleased ## Unreleased
#### Links #### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4.1...HEAD - upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...HEAD
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4.1...HEAD - codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...HEAD
- github: https://github.com/lindeas/jilo-web/compare/v0.4.1...HEAD - github: https://github.com/lindeas/jilo-web/compare/v0.4...HEAD
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4.1...HEAD - gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...HEAD
### Added ### Added
- CSS for dashboard widgets
- Monthly dashboard statistics redesign
- Tracking of applied database migrations in the database
- Option to run database migrations one by one
- Log Throttler to prevent log flooding
- Logger helper with fallback when no log plugin is available
- Plugin asset page
- Plugin hooks for profile page, account menu, and asset loading
- Email helper and email templates, including password reset email
- Admin page and admin dashboard for all administrative tasks
- Plugin namagement section for the admin dashboard
### Changed ### Changed
- Updated credentials pages and removed unused "credentials.php"
- Redesigned admin tools, themes, profile, credentials/2FA, and authentication pages
- Redesigned sidebar, main elements, menus, and overall CSS
- Updated pagination styling
- Reorganized dashboard layout
- Switched profile edit and action pages to uniform action-card design
- Replaced "error_log" with "app_log" in 2FA
- Updated index bootstrap to use global "APP_PATH"
- Refactored database migration system and Admin Tools functionality
- Removed "admin-tools" page, all functionality is now in "admin" page
### Fixed ### Fixed
- Database migration reliability issues
- Validator rejecting "0" as a valid value
- Collapsing sidebar layout issues
- Profile avatar upload issues
- Public pages incorrectly requiring authentication
- Correct encoding of login redirect URL parameters
---
## 0.4.1 - 2025-11-13
#### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...v0.4.1
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...v0.4.1
- github: https://github.com/lindeas/jilo-web/compare/v0.4...v0.4.1
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...v0.4.1
### Added
- Added the ability to have non-sanitized feedback messages
- Added a notice for maintenance mode for superusers
- Added initial support for maintenance mode
- Added initial support for database upgrades and migrations
- Added CSRF protection to the theme switcher functionality
- Added a helper to manage all static assets of a theme
- Added theme data to be passed correctly to the views
- Added "modern" and "retro" theme screenshots
- Added "alternative retro" theme
- Added CSS and JS to the default theme
- Added change theme menu entry
### Changed
- Moved away from SQLite to MariaDB/MySQL for the main Jilo website
- Integrated Highlight.js library into the SQL view modal for better code highlighting
- Moved the migration flag to the database with a fallback to a file
- Made the theme setting configuration per-user instead of global
- Refactored the session class and added a random session name generator if not configured
- Moved session variables to the configuration file
### Fixed
- Fixed flash messages to show up only once per page
- Fixed database migration functionality and associated feedback notices
- Fixed theme folder structure, helpers, and display logic to work correctly with new asset management
- Fixed index routing to work with the latest session and config changes
- Fixed the router class and several bugs within the session class and theme switcher functionality
### Removed
- Removed getScreenshotUrl function, using the generic getAssetUrl for all assets instead
--- ---
@ -244,6 +176,8 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Changed the layout with bootstrap CSS classes - Changed the layout with bootstrap CSS classes
### Fixed
--- ---
## 0.1 - 2024-07-08 ## 0.1 - 2024-07-08

View File

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

View File

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

View File

@ -39,9 +39,6 @@ class ApiResponse {
* @param int $status HTTP status code * @param int $status HTTP status code
*/ */
private static function send($data, $status) { private static function send($data, $status) {
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code($status); http_response_code($status);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($data); echo json_encode($data);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,16 @@
<?php <?php
use App\App;
use App\Core\NullLogger;
class RateLimiter { class RateLimiter {
public $db; public $db;
/** @var mixed NullLogger (or PSR-3 logger) or plugin Log */ private $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,24 +22,9 @@ class RateLimiter {
'config' => 10 'config' => 10
]; ];
/** public function __construct($database) {
* @param mixed $logger Optional NullLogger (or PSR-3 logger) or plugin Log $this->db = $database->getConnection();
*/ $this->log = new Log($database);
public function __construct($logger = null) {
$db = App::db();
// Extract PDO connection from Database object
$this->db = ($db instanceof PDO) ? $db : $db->getConnection();
// Initialize logger (plugin Log if present or NullLogger otherwise)
if ($logger !== null) {
$this->logger = $logger;
} else {
global $logObject;
$this->logger = isset($logObject) && is_object($logObject) && method_exists($logObject, 'info')
? $logObject
: new NullLogger();
}
// Initialize database tables
$this->createTablesIfNotExist(); $this->createTablesIfNotExist();
} }
@ -51,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);
@ -105,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) {
@ -121,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')");
@ -136,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']);
@ -233,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]);
@ -257,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;
@ -292,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;
@ -312,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;
@ -320,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]);
@ -340,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;
@ -374,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;
@ -405,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;
} }
@ -444,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,
@ -456,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);
@ -482,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([
@ -518,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([
@ -531,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([
@ -573,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([
@ -604,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();
@ -616,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'];
} }
} }
@ -655,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' => 0,
'cookie_samesite' => 'Lax',
'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' => 'Lax'
]);
}
// Align session start options dynamically with current transport
self::$sessionOptions['cookie_secure'] = $isSecure ? 1 : 0;
self::$sessionOptions['cookie_samesite'] = 'Lax';
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' => 'Lax'
]);
}
// 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' => 'Lax'
]
);
// Set username cookie
setcookie('username', $username, [
'expires' => $cookieLifetime,
'path' => $config['folder'] ?? '/',
'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax'
]);
}
if ($rememberMe) {
self::setRememberMe(true);
}
}
/**
* Store 2FA pending information in session
*/
public static function store2FAPending($userId, $username, $rememberMe = false) {
$_SESSION['2fa_pending_user_id'] = $userId;
$_SESSION['2fa_pending_username'] = $username;
if ($rememberMe) {
$_SESSION['2fa_pending_remember'] = true;
}
}
/**
* Clear 2FA pending information from session
*/
public static function clear2FAPending() {
unset($_SESSION['2fa_pending_user_id']);
unset($_SESSION['2fa_pending_username']);
unset($_SESSION['2fa_pending_remember']);
}
/**
* Get 2FA pending information
*/
public static function get2FAPending() {
if (!isset($_SESSION['2fa_pending_user_id']) || !isset($_SESSION['2fa_pending_username'])) {
return null;
}
return [
'user_id' => $_SESSION['2fa_pending_user_id'],
'username' => $_SESSION['2fa_pending_username'],
'remember_me' => isset($_SESSION['2fa_pending_remember'])
];
}
}

View File

@ -1,11 +1,5 @@
<?php <?php
use App\App;
// Already required in index.php, but we require it here,
// because this class could be used standalone
require_once __DIR__ . '/../helpers/logger_loader.php';
/** /**
* Class TwoFactorAuthentication * Class TwoFactorAuthentication
* *
@ -18,8 +12,7 @@ class TwoFactorAuthentication {
private $period = 30; // Time step in seconds (T0) private $period = 30; // Time step in seconds (T0)
private $digits = 6; // Number of digits in TOTP code private $digits = 6; // Number of digits in TOTP code
private $algorithm = 'sha1'; // HMAC algorithm private $algorithm = 'sha1'; // HMAC algorithm
// Branding: populated from config so authenticator apps show the configured site name. private $issuer = 'TotalMeet';
private $issuer = 'Website';
private $window = 1; // Time window of 1 step before/after private $window = 1; // Time window of 1 step before/after
/** /**
@ -33,9 +26,6 @@ class TwoFactorAuthentication {
} else { } else {
$this->db = $database->getConnection(); $this->db = $database->getConnection();
} }
$config = App::config();
$this->issuer = (string)$config['site_name'];
} }
/** /**
@ -108,10 +98,7 @@ class TwoFactorAuthentication {
if ($code !== null) { if ($code !== null) {
// Verify the setup code // Verify the setup code
if (!$this->verify($userId, $code)) { if (!$this->verify($userId, $code)) {
app_log('warning', '2FA setup code verification failed', [ error_log("Code verification failed");
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
@ -130,10 +117,7 @@ class TwoFactorAuthentication {
if ($this->db->inTransaction()) { if ($this->db->inTransaction()) {
$this->db->rollBack(); $this->db->rollBack();
} }
app_log('error', '2FA enable error: ' . $e->getMessage(), [ error_log('2FA enable error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -173,10 +157,7 @@ class TwoFactorAuthentication {
return false; return false;
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', '2FA verification error: ' . $e->getMessage(), [ error_log('2FA verification error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -370,10 +351,7 @@ class TwoFactorAuthentication {
return false; return false;
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', 'Backup code verification error: ' . $e->getMessage(), [ error_log('Backup code verification error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -400,10 +378,7 @@ class TwoFactorAuthentication {
return $stmt->execute([$userId]); return $stmt->execute([$userId]);
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', '2FA disable error: ' . $e->getMessage(), [ error_log('2FA disable error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -422,10 +397,7 @@ class TwoFactorAuthentication {
return $result && $result['enabled']; return $result && $result['enabled'];
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', '2FA status check error: ' . $e->getMessage(), [ error_log('2FA status check error: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return false; return false;
} }
} }
@ -441,10 +413,7 @@ class TwoFactorAuthentication {
return $stmt->fetch(PDO::FETCH_ASSOC); return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (Exception $e) { } catch (Exception $e) {
app_log('error', 'Failed to get user 2FA settings: ' . $e->getMessage(), [ error_log('Failed to get user 2FA settings: ' . $e->getMessage());
'scope' => 'security',
'user_id' => $userId,
]);
return null; return null;
} }
} }

View File

@ -1,11 +1,9 @@
<?php <?php
use App\App;
/** /**
* 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 {
/** /**
@ -14,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.
@ -35,80 +28,64 @@ class User {
require_once __DIR__ . '/ratelimiter.php'; require_once __DIR__ . '/ratelimiter.php';
require_once __DIR__ . '/twoFactorAuth.php'; require_once __DIR__ . '/twoFactorAuth.php';
$this->rateLimiter = new RateLimiter(); $this->rateLimiter = new RateLimiter($database);
$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();
} }
} }
@ -124,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();
@ -133,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();
@ -172,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."
];
} }
@ -188,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);
@ -202,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);
@ -230,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,
]); ]);
} }
@ -251,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,
]); ]);
} }
@ -279,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();
@ -292,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);
@ -321,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,
@ -331,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,
@ -355,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;
} }
@ -384,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.
@ -393,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,
@ -403,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'],
@ -422,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
@ -455,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) {
@ -475,77 +453,37 @@ class User {
$newFileName = md5(time() . $fileName) . '.' . $fileExtension; $newFileName = md5(time() . $fileName) . '.' . $fileExtension;
$dest_path = $avatars_path . $newFileName; $dest_path = $avatars_path . $newFileName;
// ensure avatars directory exists
if (!is_dir($avatars_path)) {
if (!mkdir($avatars_path, 0755, true)) {
$_SESSION['error'] .= 'Unable to create avatars directory. ';
return false;
}
}
// check if directory is writable
if (!is_writable($avatars_path)) {
$_SESSION['error'] .= 'Avatars directory is not writable. ';
return false;
}
// move the file to avatars folder // move the file to avatars folder
if (move_uploaded_file($fileTmpPath, $dest_path)) { if (move_uploaded_file($fileTmpPath, $dest_path)) {
try { try {
// update user's avatar path in DB // update user's avatar path in DB
$sql = 'UPDATE user_meta SET $sql = 'UPDATE users_meta SET
avatar = :avatar avatar = :avatar
WHERE user_id = :user_id'; WHERE user_id = :user_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':avatar' => $newFileName, ':avatar' => $newFileName,
':user_id' => $userId ':user_id' => $user_id
]); ]);
// all went OK // all went OK
$_SESSION['notice'] = 'Avatar updated successfully. '; $_SESSION['notice'] .= 'Avatar updated successfully. ';
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
$_SESSION['error'] .= 'Database error updating avatar. ';
return $e->getMessage(); return $e->getMessage();
} }
} else { } else {
$_SESSION['error'] = 'Error moving the uploaded file. Please check directory permissions. '; $_SESSION['error'] .= 'Error moving the uploaded file. ';
} }
} else { } else {
$_SESSION['error'] = 'Invalid avatar file type. Only JPG, PNG, and JPEG are allowed. '; $_SESSION['error'] .= 'Invalid avatar file type. ';
} }
} else { } else {
// Handle different upload errors $_SESSION['error'] .= 'Error uploading the avatar file. ';
switch ($avatar_file['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$_SESSION['error'] = 'Avatar file is too large. Maximum size is 500KB. ';
break;
case UPLOAD_ERR_PARTIAL:
$_SESSION['error'] = 'Avatar file was only partially uploaded. ';
break;
case UPLOAD_ERR_NO_FILE:
$_SESSION['error'] = 'No avatar file was uploaded. ';
break;
case UPLOAD_ERR_NO_TMP_DIR:
$_SESSION['error'] = 'Missing temporary folder for file upload. ';
break;
case UPLOAD_ERR_CANT_WRITE:
$_SESSION['error'] = 'Failed to write avatar file to disk. ';
break;
case UPLOAD_ERR_EXTENSION:
$_SESSION['error'] = 'File upload stopped by extension. ';
break;
default:
$_SESSION['error'] = 'Unknown upload error occurred. ';
break;
}
} }
} catch (Exception $e) { } catch (Exception $e) {
$_SESSION['error'] = 'An error occurred while processing the avatar: ' . $e->getMessage();
return $e->getMessage(); return $e->getMessage();
} }
return false;
} }
/** /**
@ -555,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);
@ -567,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) {
@ -589,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) {
@ -610,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);
@ -631,7 +569,7 @@ class User {
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT); $hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
// Update the password // Update the password
$sql = "UPDATE user SET password = :password WHERE id = :user_id"; $sql = "UPDATE users SET password = :password WHERE id = :user_id";
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
return $query->execute([ return $query->execute([
':password' => $hashedPassword, ':password' => $hashedPassword,

View File

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

View File

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

View File

@ -1,45 +0,0 @@
<?php
global $config;
$siteName = (string)$config['site_name'];
/**
* 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' => sprintf('A %s theme', $siteName),
'version' => '1.0.0',
'author' => 'Lindeas Inc.',
'screenshot' => 'screenshot.png',
'options' => []
]
];

View File

@ -1,110 +0,0 @@
<?php
namespace App;
/**
* Minimal application API
* we use it expose and access core services from plugins (and legacy code).
*/
final class App
{
/** @var array<string, mixed> */
private static array $services = [];
/**
* Register or override a service value.
*/
public static function set(string $key, $value): void
{
self::$services[$key] = $value;
}
/**
* Clear one or all registered services.
*
* Primarily used by unit tests to avoid cross-test pollution.
*/
public static function reset(?string $key = null): void
{
if ($key === null) {
self::$services = [];
return;
}
unset(self::$services[$key]);
}
/**
* Determine whether a value is registered.
*/
public static function has(string $key): bool
{
if (array_key_exists($key, self::$services)) {
return true;
}
return self::fallback($key) !== null;
}
/**
* Retrieve a registered value.
* Falls back to legacy globals when no explicit service was registered.
*/
public static function get(string $key, $default = null)
{
if (array_key_exists($key, self::$services)) {
return self::$services[$key];
}
$fallback = self::fallback($key);
return $fallback !== null ? $fallback : $default;
}
/**
* Convenience accessor for the database connection.
*/
public static function db()
{
return self::get('db');
}
/**
* Convenience accessor for the configuration array.
*/
public static function config(): array
{
$config = self::get('config', []);
return is_array($config) ? $config : [];
}
/**
* Convenience accessor for the authenticated user object, if any.
*/
public static function user()
{
return self::get('user');
}
/**
* Basic fallback bridge for legacy globals.
*/
private static function fallback(string $key)
{
switch ($key) {
case 'config':
return $GLOBALS['config'] ?? null;
case 'config_path':
return $GLOBALS['config_file'] ?? null;
case 'db':
return $GLOBALS['db'] ?? null;
case 'user':
case 'user_object':
return $GLOBALS['userObject'] ?? null;
case 'user_id':
return $GLOBALS['userId'] ?? null;
case 'logger':
return $GLOBALS['logObject'] ?? null;
case 'app_root':
return $GLOBALS['app_root'] ?? null;
default:
return null;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,95 +0,0 @@
<?php
namespace App\Core;
use App\App;
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('APP_MAINTENANCE') === '1') {
return true;
}
// Prefer DB settings if available in the current request
$db = App::db();
if ($db) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($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
{
$db = App::db();
if ($db) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($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
{
$db = App::db();
if ($db) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($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('APP_MAINTENANCE_MESSAGE');
if ($envMsg) {
return trim($envMsg);
}
$db = App::db();
if ($db) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($db);
return (string)$settings->get('maintenance_message', '');
} catch (\Throwable $e) {
// ignore and fall back to file flag
}
}
$msg = @file_get_contents(self::FLAG_PATH);
return is_string($msg) ? trim($msg) : '';
}
}

View File

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

View File

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

View File

@ -1,356 +0,0 @@
<?php
namespace App\Core;
require_once __DIR__ . '/NullLogger.php';
require_once __DIR__ . '/MigrationException.php';
use PDO;
use Exception;
class MigrationRunner
{
private PDO $pdo;
private string $migrationsDir;
private string $driver;
private bool $isSqlite = false;
private $logger;
private array $lastResults = [];
/**
* @param mixed $db Either a PDO instance or the application's Database wrapper
* @param string $migrationsDir Directory containing .sql migrations
*/
public function __construct($db, string $migrationsDir)
{
// Normalize to PDO
if ($db instanceof PDO) {
$this->pdo = $db;
} elseif (is_object($db) && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
if (!$pdo instanceof PDO) {
throw new Exception('Database wrapper did not return a PDO instance');
}
$this->pdo = $pdo;
} else {
$type = is_object($db) ? get_class($db) : gettype($db);
throw new Exception("Unsupported database type: {$type}");
}
$this->migrationsDir = rtrim($migrationsDir, '/');
if (!is_dir($this->migrationsDir)) {
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
}
$this->driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$this->isSqlite = ($this->driver === 'sqlite');
$this->ensureMigrationsTable();
$this->ensureMigrationColumns();
$this->initializeLogger();
}
private function ensureMigrationsTable(): void
{
if ($this->isSqlite) {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL,
content_hash TEXT NULL,
content TEXT NULL
)";
} else {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL,
content_hash CHAR(64) NULL,
content LONGTEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$this->pdo->exec($sql);
}
private function ensureMigrationColumns(): void
{
$this->ensureColumnExists(
'content_hash',
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content_hash TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content_hash CHAR(64) NULL DEFAULT NULL AFTER applied_at"
);
$this->ensureColumnExists(
'content',
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content LONGTEXT NULL DEFAULT NULL AFTER content_hash"
);
$this->ensureColumnExists(
'result',
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN result TEXT NULL" : "ALTER TABLE migrations ADD COLUMN result LONGTEXT NULL DEFAULT NULL AFTER content"
);
}
private function ensureColumnExists(string $column, string $alterSql): void
{
if ($this->columnExists('migrations', $column)) {
return;
}
$this->pdo->exec($alterSql);
}
private function columnExists(string $table, string $column): bool
{
if ($this->isSqlite) {
$stmt = $this->pdo->query("PRAGMA table_info({$table})");
$columns = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
foreach ($columns as $col) {
if (($col['name'] ?? '') === $column) {
return true;
}
}
return false;
}
$stmt = $this->pdo->prepare("SHOW COLUMNS FROM {$table} LIKE :column");
$stmt->execute([':column' => $column]);
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
}
public function listAllMigrations(): array
{
$files = glob($this->migrationsDir . '/*.sql');
sort($files, SORT_NATURAL);
return array_map('basename', $files);
}
public function listAppliedMigrations(): array
{
$stmt = $this->pdo->query('SELECT migration FROM migrations ORDER BY migration ASC');
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
public function listPendingMigrations(): array
{
$all = $this->listAllMigrations();
$applied = $this->listAppliedMigrations();
$pending = array_values(array_diff($all, $applied));
return $this->sortMigrations($pending);
}
public function hasPendingMigrations(): bool
{
return count($this->listPendingMigrations()) > 0;
}
public function applyPendingMigrations(): array
{
return $this->runMigrations($this->listPendingMigrations());
}
public function applyNextMigration(): array
{
$pending = $this->listPendingMigrations();
if (empty($pending)) {
return [];
}
return $this->runMigrations([reset($pending)]);
}
public function applyMigrationByName(string $migration): array
{
$pending = $this->listPendingMigrations();
if (!in_array($migration, $pending, true)) {
return [];
}
return $this->runMigrations([$migration]);
}
private function runMigrations(array $migrations): array
{
$appliedNow = [];
if (empty($migrations)) {
return $appliedNow;
}
$this->lastResults = [];
try {
$this->pdo->beginTransaction();
foreach ($migrations as $migration) {
try {
$path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path);
if ($sql === false) {
throw new Exception("Unable to read migration file: {$migration}");
}
$trimmedSql = trim($sql);
$hash = hash('sha256', $trimmedSql);
if ($this->contentHashExists($hash)) {
$this->recordMigration($migration, $trimmedSql, $hash);
$appliedNow[] = $migration;
continue;
}
$statements = $this->splitStatements($trimmedSql);
foreach ($statements as $stmtSql) {
if ($stmtSql === '') {
continue;
}
$this->pdo->exec($stmtSql);
}
$statementCount = count($statements);
$resultMessage = sprintf('Migration "%s" applied successfully (%d statement%s).', $migration, $statementCount, $statementCount === 1 ? '' : 's');
$this->lastResults[$migration] = [
'content' => $trimmedSql,
'message' => $resultMessage,
'is_test' => $this->isTestMigration($migration)
];
if ($this->isTestMigration($migration)) {
$appliedNow[] = $migration;
$this->logger->log('info', $resultMessage . ' (test migration)', ['scope' => 'system', 'migration' => $migration]);
$this->cleanupTestMigrationFile($migration);
} else {
$this->recordMigration($migration, $trimmedSql, $hash, $resultMessage);
$appliedNow[] = $migration;
$this->logger->log('info', $resultMessage, ['scope' => 'system', 'migration' => $migration]);
}
} catch (Exception $migrationException) {
throw new MigrationException($migration, $migrationException->getMessage(), $migrationException);
}
}
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} catch (MigrationException $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->logger->log('error', sprintf('Migration "%s" failed: %s', $e->getMigration(), $e->getMessage()), ['scope' => 'system', 'migration' => $e->getMigration()]);
throw $e;
} catch (Exception $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->logger->log('error', 'Migration run failed: ' . $e->getMessage(), ['scope' => 'system']);
throw $e;
}
return $appliedNow;
}
private function splitStatements(string $sql): array
{
if ($sql === '') {
return [];
}
return array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
}
private function contentHashExists(string $hash): bool
{
if ($hash === '') {
return false;
}
$stmt = $this->pdo->prepare('SELECT 1 FROM migrations WHERE content_hash = :hash LIMIT 1');
$stmt->execute([':hash' => $hash]);
return (bool)$stmt->fetchColumn();
}
private function recordMigration(string $name, string $content, string $hash, ?string $result = null): void
{
$timestampExpr = $this->isSqlite ? "datetime('now')" : 'NOW()';
$sql = "INSERT INTO migrations (migration, applied_at, content_hash, content, result) VALUES (:migration, {$timestampExpr}, :hash, :content, :result)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':migration' => $name,
':hash' => $hash,
':content' => $content === '' ? null : $content,
':result' => $result,
]);
}
private function sortMigrations(array $items): array
{
usort($items, static function ($a, $b) {
$aTest = strpos($a, '_test_migration') !== false;
$bTest = strpos($b, '_test_migration') !== false;
if ($aTest === $bTest) {
return strcmp($a, $b);
}
return $aTest ? -1 : 1;
});
return $items;
}
private function isTestMigration(string $migration): bool
{
return strpos($migration, '_test_migration') !== false;
}
private function cleanupTestMigrationFile(string $migration): void
{
$path = $this->migrationsDir . '/' . $migration;
if (is_file($path)) {
@unlink($path);
}
$stmt = $this->pdo->prepare('DELETE FROM migrations WHERE migration = :migration');
$stmt->execute([':migration' => $migration]);
}
public function markMigrationApplied(string $migration, ?string $note = null): bool
{
$path = $this->migrationsDir . '/' . $migration;
$content = '';
if (is_file($path)) {
$fileContent = file_get_contents($path);
if ($fileContent !== false) {
$content = trim($fileContent);
}
}
$hash = $content === '' ? '' : hash('sha256', $content);
if ($hash !== '' && $this->contentHashExists($hash)) {
return true;
}
$result = $note ?? 'Marked as applied manually.';
$this->recordMigration($migration, $content, $hash, $result);
return true;
}
public function skipMigration(string $migration): bool
{
$source = $this->migrationsDir . '/' . $migration;
if (!is_file($source)) {
return false;
}
$skippedDir = $this->migrationsDir . '/skipped';
if (!is_dir($skippedDir)) {
if (!mkdir($skippedDir, 0775, true) && !is_dir($skippedDir)) {
throw new Exception('Unable to create skipped migrations directory.');
}
}
$destination = $skippedDir . '/' . $migration;
if (rename($source, $destination)) {
return true;
}
return false;
}
private function initializeLogger(): void
{
$logger = $GLOBALS['logObject'] ?? null;
if (is_object($logger) && method_exists($logger, 'log')) {
$this->logger = $logger;
} else {
$this->logger = new NullLogger();
}
}
public function getMigrationRecord(string $migration): ?array
{
$stmt = $this->pdo->prepare('SELECT migration, applied_at, content, result FROM migrations WHERE migration = :migration LIMIT 1');
$stmt->execute([':migration' => $migration]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function getLastResults(): array
{
return $this->lastResults;
}
}

View File

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

View File

@ -1,360 +0,0 @@
<?php
namespace App\Core;
class PluginManager
{
/** @var array<string, array{path: string, meta: array}> */
private static array $catalog = [];
/** @var array<string, array{path: string, meta: array}>> */
private static array $loaded = [];
/** @var array<string, array<int, string>> */
private static array $dependencyErrors = [];
/**
* Loads all enabled plugins from the given directory.
* Enforces declared dependencies before bootstrapping each plugin.
*
* @param string $pluginsDir
* @return array<string, array{path: string, meta: array}>
*/
public static function load(string $pluginsDir): array
{
self::$catalog = self::scanCatalog($pluginsDir);
self::$loaded = [];
self::$dependencyErrors = [];
foreach (self::$catalog as $name => $info) {
if (!self::isEnabled($name)) {
continue;
}
self::resolve($name);
}
$GLOBALS['plugin_dependency_errors'] = self::$dependencyErrors;
return self::$loaded;
}
/**
* @param string $pluginsDir
* @return array<string, array{path: string, meta: array}>
*/
private static function scanCatalog(string $pluginsDir): array
{
$catalog = [];
foreach (glob(rtrim($pluginsDir, '/'). '/*', GLOB_ONLYDIR) as $pluginPath) {
$manifest = $pluginPath . '/plugin.json';
if (!file_exists($manifest)) {
continue;
}
$meta = json_decode(file_get_contents($manifest), true);
if (!is_array($meta)) {
$meta = [];
}
$name = basename($pluginPath);
$catalog[$name] = [
'path' => $pluginPath,
'meta' => $meta,
];
}
return $catalog;
}
/**
* Recursively resolves a plugin and its dependencies.
*/
private static function resolve(string $plugin, array $stack = []): bool
{
if (isset(self::$loaded[$plugin])) {
return true;
}
if (!isset(self::$catalog[$plugin])) {
return false;
}
if (in_array($plugin, $stack, true)) {
self::$dependencyErrors[$plugin][] = 'Circular dependency detected: ' . implode(' -> ', array_merge($stack, [$plugin]));
return false;
}
$meta = self::$catalog[$plugin]['meta'];
if (!self::isEnabled($plugin)) {
return false;
}
$dependencies = $meta['dependencies'] ?? [];
if (!is_array($dependencies)) {
$dependencies = [$dependencies];
}
$stack[] = $plugin;
foreach ($dependencies as $dependency) {
$dependency = trim((string)$dependency);
if ($dependency === '') {
continue;
}
if (!isset(self::$catalog[$dependency])) {
self::$dependencyErrors[$plugin][] = sprintf('Missing dependency "%s"', $dependency);
continue;
}
if (!self::isEnabled($dependency)) {
self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" is disabled', $dependency);
continue;
}
if (!self::resolve($dependency, $stack)) {
self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" failed to load', $dependency);
}
}
array_pop($stack);
if (!empty(self::$dependencyErrors[$plugin])) {
return false;
}
$bootstrap = self::$catalog[$plugin]['path'] . '/bootstrap.php';
if (file_exists($bootstrap)) {
include_once $bootstrap;
}
self::$loaded[$plugin] = self::$catalog[$plugin];
return true;
}
/**
* Returns the scanned plugin catalog (enabled and disabled).
*
* @return array<string, array{path: string, meta: array}>
*/
public static function getCatalog(): array
{
return self::$catalog;
}
/**
* Returns all plugins that successfully loaded (dependencies satisfied).
*
* @return array<string, array{path: string, meta: array}>
*/
public static function getLoaded(): array
{
return self::$loaded;
}
/**
* Returns dependency validation errors collected during load.
*
* @return array<string, array<int, string>>
*/
public static function getDependencyErrors(): array
{
return self::$dependencyErrors;
}
/**
* Persists a plugin's enabled flag to the database settings table.
* Note: This method no longer requires write access to plugin.json files.
*/
public static function setEnabled(string $plugin, bool $enabled): bool
{
if (!isset(self::$catalog[$plugin])) {
app_log('error', 'PluginManager::setEnabled: Plugin ' . $plugin . ' not found in catalog', ['scope' => 'plugin']);
return false;
}
// Use App API to get database connection
$db = \App\App::db();
$pdo = ($db instanceof \PDO) ? $db : $db->getConnection();
try {
// Update or insert plugin setting in database
$stmt = $pdo->prepare(
'INSERT INTO settings (`key`, `value`, updated_at)
VALUES (:key, :value, NOW())
ON DUPLICATE KEY UPDATE `value` = :value, updated_at = NOW()'
);
$key = 'plugin_enabled_' . $plugin;
$value = $enabled ? '1' : '0';
app_log('info', 'PluginManager::setEnabled: Setting ' . $key . ' to ' . $value, ['scope' => 'plugin']);
$result = $stmt->execute([':key' => $key, ':value' => $value]);
if (!$result) {
app_log('error', 'PluginManager::setEnabled: Failed to execute query for ' . $plugin, ['scope' => 'plugin']);
return false;
}
// Clear loaded cache if disabling
if (!$enabled && isset(self::$loaded[$plugin])) {
unset(self::$loaded[$plugin]);
}
app_log('info', 'PluginManager::setEnabled: Successfully set ' . $plugin . ' to ' . ($enabled ? 'enabled' : 'disabled'), ['scope' => 'plugin']);
return true;
} catch (\PDOException $e) {
// Log the actual error for debugging
app_log('error', 'PluginManager::setEnabled failed for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
return false;
}
}
/**
* Check if a plugin is enabled from database settings.
*/
public static function isEnabled(string $plugin): bool
{
if (!isset(self::$catalog[$plugin])) {
return false;
}
// Use App API to get database connection
$db = \App\App::db();
// If database unavailable, fallback to manifest
if (!$db) {
return self::$catalog[$plugin]['meta']['enabled'] ?? false;
}
$pdo = ($db instanceof \PDO) ? $db : $db->getConnection();
try {
$stmt = $pdo->prepare('SELECT `value` FROM settings WHERE `key` = :key LIMIT 1');
$key = 'plugin_enabled_' . $plugin;
$stmt->execute([':key' => $key]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return $result && $result['value'] === '1';
} catch (\PDOException $e) {
app_log('error', 'PluginManager::isEnabled failed for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
// Fallback to manifest on database error
return self::$catalog[$plugin]['meta']['enabled'] ?? false;
}
}
/**
* Install plugin by running its migrations.
*/
public static function install(string $plugin): bool
{
if (!isset(self::$catalog[$plugin])) {
return false;
}
$pluginPath = self::$catalog[$plugin]['path'];
$bootstrapPath = $pluginPath . '/bootstrap.php';
if (!file_exists($bootstrapPath)) {
return false;
}
try {
// Include bootstrap to run migrations
include_once $bootstrapPath;
// Look for migration function
$migrationFunction = str_replace('-', '_', $plugin) . '_ensure_tables';
if (function_exists($migrationFunction)) {
$migrationFunction();
app_log('info', 'PluginManager::install: Successfully ran migrations for ' . $plugin, ['scope' => 'plugin']);
return true;
}
// If no migration function exists, that's okay for plugins that don't need tables
app_log('info', 'PluginManager::install: No migrations needed for ' . $plugin, ['scope' => 'plugin']);
return true;
} catch (Throwable $e) {
app_log('error', 'PluginManager::install failed for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
return false;
}
}
/**
* Purge plugin by dropping its migration-defined tables and removing settings.
*/
public static function purge(string $plugin): bool
{
if (!isset(self::$catalog[$plugin])) {
return false;
}
$db = \App\App::db();
if (!$db) {
app_log('error', 'PluginManager::purge: Database connection not available', ['scope' => 'plugin']);
return false;
}
$pdo = ($db instanceof \PDO) ? $db : $db->getConnection();
$foreignKeyChecksDisabled = false;
try {
// First disable the plugin
self::setEnabled($plugin, false);
// Remove plugin settings
$stmt = $pdo->prepare('DELETE FROM settings WHERE `key` LIKE :pattern');
$stmt->execute([':pattern' => 'plugin_enabled_' . $plugin]);
$migrationDir = self::$catalog[$plugin]['path'] . '/migrations';
$migrationFiles = glob($migrationDir . '/*.sql') ?: [];
$tables = [];
foreach ($migrationFiles as $migrationFile) {
if (!is_string($migrationFile) || !file_exists($migrationFile)) {
continue;
}
$migrationContent = file_get_contents($migrationFile);
if (!is_string($migrationContent) || $migrationContent === '') {
continue;
}
// Derive table ownership from CREATE TABLE statements in plugin migrations.
preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([a-zA-Z0-9_]+)`?/i', $migrationContent, $matches);
$discoveredTables = $matches[1] ?? [];
foreach ($discoveredTables as $tableName) {
if (!is_string($tableName) || $tableName === '') {
continue;
}
$tables[] = $tableName;
}
}
$tables = array_values(array_unique($tables));
// Disable foreign key checks temporarily to allow table drops
$pdo->exec('SET FOREIGN_KEY_CHECKS=0');
$foreignKeyChecksDisabled = true;
foreach ($tables as $table) {
// Defensive validation: only allow plain SQL identifiers for drop targets.
if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
app_log('warning', 'PluginManager::purge: Skipped unsafe table identifier "' . (string)$table . '" for plugin ' . $plugin, ['scope' => 'plugin']);
continue;
}
$pdo->exec("DROP TABLE IF EXISTS `$table`");
app_log('info', 'PluginManager::purge: Dropped table ' . $table . ' for plugin ' . $plugin, ['scope' => 'plugin']);
}
app_log('info', 'PluginManager::purge: Successfully purged plugin ' . $plugin, ['scope' => 'plugin']);
return true;
} catch (Throwable $e) {
app_log('error', 'PluginManager::purge failed for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
return false;
} finally {
if ($foreignKeyChecksDisabled) {
try {
$pdo->exec('SET FOREIGN_KEY_CHECKS=1');
} catch (Throwable $e) {
app_log('error', 'PluginManager::purge: Failed to restore FOREIGN_KEY_CHECKS for ' . $plugin . ': ' . $e->getMessage(), ['scope' => 'plugin']);
}
}
}
}
}

View File

@ -1,129 +0,0 @@
<?php
namespace App\Core;
/**
* Registry for plugin route prefixes/dispatchers. Allows plugins to handle
* sub-actions without registering dozens of standalone controllers.
*/
final class PluginRouteRegistry
{
/** @var array<string, array{dispatcher: mixed, access?: string, defaults?: array, plugin?: string}> */
private static array $prefixes = [];
/**
* Register a route prefix for a plugin.
*
* @param string $prefix Query parameter value for "page" (e.g. "calls").
* @param array $definition dispatcher callable/class plus optional metadata.
*/
public static function registerPrefix(string $prefix, array $definition): void
{
$key = strtolower(trim($prefix));
if ($key === '') {
return;
}
$dispatcher = $definition['dispatcher'] ?? null;
if (!is_callable($dispatcher) && !(is_string($dispatcher) && $dispatcher !== '')) {
return;
}
$meta = [
'dispatcher' => $dispatcher,
'access' => strtolower((string)($definition['access'] ?? 'private')),
'defaults' => is_array($definition['defaults'] ?? null) ? $definition['defaults'] : [],
'plugin' => $definition['plugin'] ?? null,
];
self::$prefixes[$key] = $meta;
if (!isset($GLOBALS['plugin_route_prefixes']) || !is_array($GLOBALS['plugin_route_prefixes'])) {
$GLOBALS['plugin_route_prefixes'] = [];
}
$GLOBALS['plugin_route_prefixes'][$key] = $meta;
}
/**
* Return a registered route definition, if any.
*/
public static function match(string $prefix): ?array
{
$key = strtolower(trim($prefix));
return self::$prefixes[$key] ?? null;
}
/**
* Append registered prefixes to the allowed pages list.
*/
public static function injectAllowedPages(array $allowed): array
{
return array_values(array_unique(array_merge($allowed, array_keys(self::$prefixes))));
}
/**
* Append any public prefixes to the public pages list.
*/
public static function injectPublicPages(array $public): array
{
foreach (self::$prefixes as $prefix => $meta) {
if (($meta['access'] ?? 'private') === 'public') {
$public[] = $prefix;
}
}
return array_values(array_unique($public));
}
/**
* Dispatch the provided prefix using its registered handler.
*
* The dispatcher can be:
* - A callable accepting ($action, array $context)
* - A class name with a handle($action, array $context): bool method
*
* Returning `false` allows core routing to continue. Any other return value
* (including null) is treated as handled.
*/
public static function dispatch(string $prefix, array $context = []): bool
{
$route = self::match($prefix);
if (!$route) {
return false;
}
$action = $context['action']
?? ($context['request']['action'] ?? null)
?? ($route['defaults']['action'] ?? 'index');
$context['action'] = $action;
$dispatcher = $route['dispatcher'];
$handled = null;
if (is_string($dispatcher) && class_exists($dispatcher)) {
$instance = new $dispatcher();
if (method_exists($instance, 'handle')) {
$handled = $instance->handle($action, $context);
}
} elseif (is_callable($dispatcher)) {
$handled = call_user_func($dispatcher, $action, $context);
}
return $handled !== false;
}
/**
* Expose current registry (useful for debugging or admin UIs).
*/
public static function all(): array
{
return self::$prefixes;
}
/**
* Reset registry (primarily for unit tests).
*/
public static function reset(): void
{
self::$prefixes = [];
$GLOBALS['plugin_route_prefixes'] = [];
}
}

View File

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

View File

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

View File

@ -1,24 +0,0 @@
<?php
/**
* Datetime helper utilities.
*
* Centralized formatting for UTC timestamps into a specified timezone.
*/
if (!function_exists('app_format_local_datetime')) {
function app_format_local_datetime(?string $value, string $format, string $timezone): string
{
if (empty($value) || $value === '0000-00-00 00:00:00') {
return '';
}
try {
$utc = new DateTimeZone('UTC');
$tz = new DateTimeZone($timezone ?: 'UTC');
$date = new DateTimeImmutable($value, $utc);
return $date->setTimezone($tz)->format($format);
} catch (Throwable $e) {
return '';
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,37 +0,0 @@
<?php
/**
* Returns a logger instance: plugin Log if available, otherwise NullLogger.
*
* @param mixed $database Database or DatabaseConnector instance.
* @return mixed Logger instance with PSR-3 log() compatible method.
*/
function getLoggerInstance($database) {
if (class_exists('Log')) {
return new Log($database);
}
require_once __DIR__ . '/../core/NullLogger.php';
return new \App\Core\NullLogger();
}
if (!function_exists('app_log')) {
/**
* Lightweight logging helper that prefers the plugin logger but falls back to NullLogger.
*/
function app_log(string $level, string $message, array $context = []): void {
global $logObject;
if (isset($logObject) && is_object($logObject) && method_exists($logObject, 'log')) {
$logObject->log($level, $message, $context);
return;
}
static $fallbackLogger = null;
if ($fallbackLogger === null) {
require_once __DIR__ . '/../core/NullLogger.php';
$fallbackLogger = new \App\Core\NullLogger();
}
$fallbackLogger->log($level, $message, $context);
}
}

View File

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

View File

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

View File

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

View File

@ -1,505 +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 App\App;
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
global $config;
$siteName = (string)$config['site_name'];
/**
* 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' => sprintf('A %s theme', $siteName),
'version' => '1.0.0',
'author' => 'Lindeas Inc.',
'screenshot' => 'screenshot.png',
'options' => []
]
];
EOT;
file_put_contents($configFile, $configContent);
}
// Load the configuration
self::$config = require $configFile;
return self::$config;
}
/**
* @var string Current theme name
*/
private static $currentTheme;
/**
* Initialize the theme system
*/
public static function init()
{
// Only load config if not already loaded
if (self::$config === null) {
try {
self::getConfig(); // This will create default config if needed
} catch (Exception $e) {
error_log('Failed to load theme configuration: ' . $e->getMessage());
// Fallback to default configuration
self::$config = [
'active_theme' => 'modern',
'available_themes' => [
'modern' => ['name' => 'Modern'],
'retro' => ['name' => 'Retro']
]
];
}
}
self::$currentTheme = self::getCurrentThemeName();
}
/**
* Get the current theme name
*
* @return string
*/
public static function getCurrentThemeName()
{
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
Session::startSession();
}
// Check if already determined
if (self::$currentTheme !== null) {
return self::$currentTheme;
}
// Try to get from session first
$sessionTheme = isset($_SESSION['theme']) ? $_SESSION['theme'] : null;
if ($sessionTheme && isset(self::$config['available_themes'][$sessionTheme])) {
self::$currentTheme = $sessionTheme;
} else {
// Attempt to load per-user theme from DB if user is logged in and userObject is available
if (Session::isValidSession() && isset($_SESSION['user_id']) && isset($GLOBALS['userObject']) && is_object($GLOBALS['userObject']) && method_exists($GLOBALS['userObject'], 'getUserTheme')) {
try {
$dbTheme = $GLOBALS['userObject']->getUserTheme((int)$_SESSION['user_id']);
if ($dbTheme && isset(self::$config['available_themes'][$dbTheme]) && self::themeExists($dbTheme)) {
// Set session and current theme to the user's stored preference
$_SESSION['theme'] = $dbTheme;
self::$currentTheme = $dbTheme;
}
} catch (\Throwable $e) {
// Ignore and continue to default fallback
}
}
// Fall back to default theme if still not determined
if (self::$currentTheme === null) {
self::$currentTheme = self::$config['active_theme'];
}
}
return self::$currentTheme;
}
/**
* Get the URL for a theme asset
*
* @param string $themeId Theme ID
* @param string $assetPath Path to the asset relative to theme directory (e.g., 'css/style.css')
* @return string|null URL to the asset or null if not found
*/
public static function getAssetUrl($themeId, $assetPath = '')
{
// Clean and validate the asset path
$assetPath = ltrim($assetPath, '/');
if (empty($assetPath)) {
return null;
}
// Only allow alphanumeric, hyphen, underscore, dot, and forward slash
if (!preg_match('/^[a-zA-Z0-9_\-\.\/]+$/', $assetPath)) {
return null;
}
// Prevent directory traversal
if (strpos($assetPath, '..') !== false) {
return null;
}
$fullPath = __DIR__ . "/../../themes/$themeId/$assetPath";
if (!file_exists($fullPath) || !is_readable($fullPath)) {
return null;
}
// Generate URL that goes through index.php
global $app_root;
// Remove any trailing slash from app_root to avoid double slashes
$baseUrl = rtrim($app_root, '/');
return "$baseUrl/?page=theme-asset&theme=" . urlencode($themeId) . "&path=" . urlencode($assetPath);
}
/**
* Set the current theme for the session
*
* @param string $themeName
* @return bool
*/
public static function setCurrentTheme(string $themeName, bool $persist = true): bool
{
if (!self::themeExists($themeName)) {
return false;
}
// Update session
if (Session::isValidSession()) {
$_SESSION['theme'] = $themeName;
} else {
return false;
}
// Clear the current theme cache
self::$currentTheme = null;
// Persist per-user preference in DB when available and requested
if ($persist && Session::isValidSession() && isset($_SESSION['user_id'])) {
// Try to use existing user object if available
if (isset($GLOBALS['userObject']) && is_object($GLOBALS['userObject']) && method_exists($GLOBALS['userObject'], 'setUserTheme')) {
try {
$GLOBALS['userObject']->setUserTheme((int)$_SESSION['user_id'], $themeName);
} catch (\Throwable $e) {
// Non-fatal: keep session theme even if DB save fails
error_log('Failed to persist user theme: ' . $e->getMessage());
}
}
}
self::$currentTheme = $themeName;
return true;
}
/**
* Check if a theme exists
*
* @param string $themeName
* @return bool
*/
public static function themeExists(string $themeName): bool
{
// Default theme always exists as it uses core templates
if ($themeName === 'default') {
return true;
}
$themePath = self::getThemePath($themeName);
return is_dir($themePath) && file_exists("$themePath/config.php");
}
/**
* Get the path to a theme
*
* @param string|null $themeName
* @return string
*/
public static function getThemePath(?string $themeName = null): string
{
$themeName = $themeName ?? self::getCurrentThemeName();
$config = self::getConfig();
return rtrim($config['paths']['themes'], '/') . "/$themeName";
}
/**
* Get descriptive metadata for a theme.
*
* @param string $themeId
* @return array{name:string,description:string,version:string,author:string,tags:array}
*/
public static function getThemeMetadata(string $themeId): array
{
static $cache = [];
if (isset($cache[$themeId])) {
return $cache[$themeId];
}
$themeConfig = self::getConfig();
$defaults = $themeConfig['default_config'] ?? [];
$availableEntry = $themeConfig['available_themes'][$themeId] ?? null;
$metadata = [
'name' => is_array($availableEntry) ? ($availableEntry['name'] ?? ucfirst($themeId)) : ($availableEntry ?? ucfirst($themeId)),
'description' => $defaults['description'] ?? '',
'version' => $defaults['version'] ?? '',
'author' => $defaults['author'] ?? '',
'tags' => [],
'type' => $themeId === 'default' ? 'Core built-in' : 'Custom',
'path' => $themeId === 'default' ? 'app/templates' : ('themes/' . $themeId),
'last_modified' => null,
'file_count' => null
];
if (is_array($availableEntry)) {
$metadata = array_merge($metadata, array_intersect_key($availableEntry, array_flip(['name', 'description', 'version', 'author', 'tags'])));
}
if ($themeId !== 'default') {
$themesDir = rtrim($themeConfig['paths']['themes'] ?? (__DIR__ . '/../../themes'), '/');
$themeConfigPath = $themesDir . '/' . $themeId . '/config.php';
if (file_exists($themeConfigPath)) {
$themeConfig = require $themeConfigPath;
if (is_array($themeConfig)) {
$metadata = array_merge($metadata, array_intersect_key($themeConfig, array_flip(['name', 'description', 'version', 'author', 'tags'])));
}
}
}
$appConfig = App::config();
$siteName = (string)$appConfig['site_name'];
if (empty($metadata['description'])) {
$metadata['description'] = $defaults['description'] ?? ('A ' . $siteName . ' theme');
}
if (empty($metadata['version'])) {
$metadata['version'] = $defaults['version'] ?? '1.0.0';
}
if (empty($metadata['author'])) {
$metadata['author'] = $defaults['author'] ?? $siteName;
}
if (empty($metadata['tags']) || !is_array($metadata['tags'])) {
$metadata['tags'] = [];
}
$paths = $themeConfig['paths'] ?? [];
if ($themeId === 'default') {
$absolutePath = realpath($paths['templates'] ?? (__DIR__ . '/../templates')) ?: null;
} else {
$absolutePath = self::getThemePath($themeId);
}
if ($absolutePath && is_dir($absolutePath)) {
[$lastModified, $fileCount] = self::getDirectoryStats($absolutePath);
if ($lastModified !== null) {
$metadata['last_modified'] = $lastModified;
}
if ($fileCount > 0) {
$metadata['file_count'] = $fileCount;
}
}
return $cache[$themeId] = $metadata;
}
/**
* Calculate directory statistics for a theme folder.
*/
private static function getDirectoryStats(string $path): array
{
$latest = null;
$count = 0;
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
$count++;
$mtime = $fileInfo->getMTime();
if ($latest === null || $mtime > $latest) {
$latest = $mtime;
}
}
} catch (\Throwable $e) {
return [null, 0];
}
return [$latest, $count];
}
/**
* Get the URL for a theme asset
*
* @param string $path
* @param bool $includeVersion
* @return string
*/
public static function asset($path, $includeVersion = false)
{
$themeName = self::getCurrentThemeName();
$config = self::getConfig();
$baseUrl = rtrim($GLOBALS['app_root'] ?? '', '/');
// For non-default themes, use theme assets
if ($themeName !== 'default') {
$assetPath = "/themes/{$themeName}/assets/" . ltrim($path, '/');
// Add version query string for cache busting
if ($includeVersion) {
$version = self::getThemeVersion($themeName);
$assetPath .= (strpos($assetPath, '?') !== false ? '&' : '?') . 'v=' . $version;
}
} else {
// For default theme, use public_html directly
$assetPath = '/' . ltrim($path, '/');
}
return $baseUrl . $assetPath;
}
/**
* Include a theme template file
*
* @param string $template Template name without .php extension
* @return void
*/
public static function include($template)
{
global $config;
$config = $config ?? [];
$themeConfig = self::getConfig();
$themeName = self::getCurrentThemeName();
// We need this here, otherwise because this helper
// between index and the views breaks the session vars
extract($GLOBALS, EXTR_SKIP | EXTR_REFS);
// Ensure config is always available in templates
$config = array_merge($config, $themeConfig);
// For non-default themes, look in the theme directory first
if ($themeName !== 'default') {
$themePath = $config['paths']['themes'] . '/' . $themeName . '/views/' . $template . '.php';
if (file_exists($themePath)) {
include $themePath;
return;
}
}
// Fallback to default template location
$defaultPath = $config['paths']['templates'] . '/' . $template . '.php';
if (file_exists($defaultPath)) {
include $defaultPath;
return;
}
// Log error if template not found
error_log("Template not found: {$template} in theme: {$themeName}");
}
/**
* Get all available themes
*
* @return array
*/
public static function getAvailableThemes(): array
{
$config = self::getConfig();
$availableThemes = $config['available_themes'] ?? [];
$themes = [];
// Add default theme if not already present
if (!isset($availableThemes['default'])) {
$availableThemes['default'] = 'Default built-in theme';
}
// Verify each theme exists and has a config file
$themesDir = $config['paths']['themes'] ?? (__DIR__ . '/../../themes');
foreach ($availableThemes as $id => $name) {
if ($id === 'default' || (is_dir("$themesDir/$id") && file_exists("$themesDir/$id/config.php"))) {
$themes[$id] = $name;
}
}
return $themes;
}
}
// Initialize the theme system
Theme::init();

View File

@ -1,201 +0,0 @@
<?php
/**
* Uploads helper
*
* This helper handles the file upload functionality.
* Can be used to upload files to specified folders under public_html
*/
use App\App;
if (!function_exists('core_public_root')) {
/**
* Resolve the absolute path to the public_html folder regardless of context (web/CLI/tests).
*/
function core_public_root(): string
{
if (defined('APP_ROOT')) {
$projectRoot = rtrim(APP_ROOT, DIRECTORY_SEPARATOR);
} else {
$projectRoot = dirname(__DIR__, 2);
}
return $projectRoot . DIRECTORY_SEPARATOR . 'public_html';
}
}
if (!function_exists('core_public_path')) {
/**
* Build an absolute path inside public_html for filesystem operations.
*/
function core_public_path(string $relative = ''): string
{
$root = rtrim(core_public_root(), DIRECTORY_SEPARATOR);
$relativePath = trim($relative);
if ($relativePath === '') {
return $root;
}
return $root . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath), DIRECTORY_SEPARATOR);
}
}
if (!function_exists('core_upload_relative_dir')) {
/**
* Resolve a config-driven upload subdirectory (always returns normalized relative path).
*/
function core_upload_relative_dir(string $configKey, string $default): string
{
$config = App::config();
$relative = trim((string)($config[$configKey] ?? $default));
if ($relative === '') {
$relative = $default;
}
return rtrim(str_replace('\\', '/', $relative), '/') . '/';
}
}
if (!function_exists('core_upload_absolute_dir')) {
/**
* Convert the configured upload subdirectory into an absolute filesystem path.
*/
function core_upload_absolute_dir(string $configKey, string $default): string
{
$relative = core_upload_relative_dir($configKey, $default);
if (preg_match('#^([A-Za-z]:\\\\|/)#', $relative)) {
return rtrim($relative, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
}
return rtrim(core_public_path($relative), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
}
}
if (!function_exists('core_normalize_site_url')) {
/**
* Build a fully-qualified URL pointing to a resource relative to the site root.
*/
function core_normalize_site_url(string $relativePath): string
{
$config = App::config();
$folder = $config['folder'] ?? '/';
$domain = $config['domain'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
$base = rtrim($scheme . $domain, '/') . '/' . ltrim($folder, '/');
$base = rtrim($base, '/');
$relative = ltrim($relativePath, '/');
return $base . '/' . $relative;
}
}
if (!function_exists('core_normalize_upload_files_array')) {
/**
* Flatten the $_FILES payload regardless of single or multiple upload inputs.
*
* @param array|null $fileInput
* @return array<int,array<string,mixed>>
*/
function core_normalize_upload_files_array(?array $fileInput): array
{
if (empty($fileInput)) {
return [];
}
if (isset($fileInput['name']) && is_array($fileInput['name'])) {
$normalized = [];
foreach ($fileInput['name'] as $idx => $name) {
$normalized[] = [
'name' => $name,
'type' => $fileInput['type'][$idx] ?? null,
'tmp_name' => $fileInput['tmp_name'][$idx] ?? null,
'error' => $fileInput['error'][$idx] ?? UPLOAD_ERR_NO_FILE,
'size' => $fileInput['size'][$idx] ?? 0,
];
}
return $normalized;
}
return [$fileInput];
}
}
if (!function_exists('core_store_upload_files')) {
/**
* Validate and persist uploaded files according to provided options.
*
* @param array $fileInput Raw $_FILES entry (single or multiple)
* @param array $options Behavior overrides: limit, config key, validation, naming, etc.
*
* @return array<int,string> Relative paths of stored files
*/
function core_store_upload_files(array $fileInput, array $options): array
{
$defaults = [
'limit' => 1,
'user_id' => 0,
'config_key' => 'uploads_path',
'default_subdir' => 'uploads/',
'allowed_extensions' => ['jpg', 'jpeg', 'png'],
'allowed_mime' => ['image/jpeg', 'image/png'],
'max_size' => 2 * 1024 * 1024,
'name_prefix' => 'upload-',
];
$options = array_merge($defaults, $options);
$stored = [];
$normalizedFiles = core_normalize_upload_files_array($fileInput);
if (empty($normalizedFiles)) {
return $stored;
}
// Resolve filesystem + relative directories once to avoid repeated IO operations.
$relativeDir = core_upload_relative_dir($options['config_key'], $options['default_subdir']);
$absoluteDir = core_upload_absolute_dir($options['config_key'], $options['default_subdir']);
if (!is_dir($absoluteDir) && !@mkdir($absoluteDir, 0755, true) && !is_dir($absoluteDir)) {
return $stored;
}
if (!is_writable($absoluteDir)) {
return $stored;
}
$finfo = class_exists('finfo') ? new finfo(FILEINFO_MIME_TYPE) : null;
foreach ($normalizedFiles as $file) {
if (count($stored) >= (int)$options['limit']) {
break;
}
$error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error !== UPLOAD_ERR_OK) {
continue;
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
continue;
}
$size = (int)($file['size'] ?? 0);
if ($size <= 0 || $size > (int)$options['max_size']) {
continue;
}
$extension = strtolower((string)pathinfo((string)($file['name'] ?? ''), PATHINFO_EXTENSION));
if (!in_array($extension, $options['allowed_extensions'], true)) {
continue;
}
$mime = $finfo && $tmpName ? $finfo->file($tmpName) : null;
if ($mime && !in_array($mime, $options['allowed_mime'], true)) {
continue;
}
$unique = $options['name_prefix'] . $options['user_id'] . '-' . bin2hex(random_bytes(4)) . '-' . time();
$fileName = $unique . '.' . $extension;
$destPath = $absoluteDir . $fileName;
if (!move_uploaded_file($tmpName, $destPath)) {
continue;
}
$stored[] = $relativeDir . $fileName;
}
return $stored;
}
}

View File

@ -1,252 +0,0 @@
<?php
/**
* Shared URL canonicalization utilities.
*
* Provides query normalization/compare helpers plus a policy-based builder so
* controllers can define canonical query contracts declaratively.
*/
/**
* Normalize query payload recursively for stable comparisons.
*
* - Sort associative keys.
* - Re-index list arrays.
* - Drop null values.
* - Convert scalar values to strings so GET payloads and canonical payloads
* compare by value rather than PHP type.
*
* @param array<string,mixed> $query
* @return array<string,mixed>
*/
function app_url_normalize_query(array $query): array
{
$normalized = [];
foreach ($query as $key => $value) {
if ($value === null) {
continue;
}
if (is_array($value)) {
$child = app_url_normalize_query($value);
$normalized[$key] = array_is_list($value)
? array_values($child)
: $child;
continue;
}
if (is_bool($value)) {
$normalized[$key] = $value ? '1' : '0';
continue;
}
$normalized[$key] = (string)$value;
}
if (!array_is_list($normalized)) {
ksort($normalized);
}
return $normalized;
}
/**
* Compare two query arrays after canonical normalization.
*
* @param array<string,mixed> $left
* @param array<string,mixed> $right
*/
function app_url_queries_match(array $left, array $right): bool
{
return app_url_normalize_query($left) === app_url_normalize_query($right);
}
/**
* Build an internal app URL from a canonical query payload.
*
* @param array<string,mixed> $query
*/
function app_url_build_internal(string $appRoot, array $query): string
{
return rtrim($appRoot !== '' ? $appRoot : '/', '/?&') . '/?' . http_build_query($query);
}
/**
* Redirect to canonical query URL when current and canonical payloads differ.
*
* @param array<string,mixed> $currentQuery
* @param array<string,mixed> $canonicalQuery
*/
function app_url_redirect_to_canonical_query(string $appRoot, array $currentQuery, array $canonicalQuery): void
{
if (!app_url_queries_match($currentQuery, $canonicalQuery)) {
header('Location: ' . app_url_build_internal($appRoot, $canonicalQuery));
exit;
}
}
/**
* Resolve one canonical value from a policy rule.
*
* Supported rule types:
* - literal: fixed `value`
* - string: trimmed scalar string
* - int: validated integer with optional `min` / `max`
* - enum: trimmed scalar string constrained by `allowed`
* - bool_flag: includes `value_true` only when request value is truthy
* - string_list: trims and filters array items
*
* @param string $targetKey
* @param array<string,mixed> $rule
* @param array<string,mixed> $sourceQuery
* @return mixed|null
*/
function app_url_policy_value(string $targetKey, array $rule, array $sourceQuery)
{
$type = (string)($rule['type'] ?? 'string');
$sourceKey = (string)($rule['source'] ?? $targetKey);
if ($type === 'literal') {
return $rule['value'] ?? null;
}
$hasSourceValue = array_key_exists($sourceKey, $sourceQuery);
$rawValue = $hasSourceValue ? $sourceQuery[$sourceKey] : null;
if (!$hasSourceValue) {
return $rule['default'] ?? null;
}
if ($type === 'string') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$value = (string)$rawValue;
if (($rule['trim'] ?? true) === true) {
$value = trim($value);
}
if ($value === '' && !($rule['allow_empty'] ?? false)) {
return $rule['default'] ?? null;
}
return $value;
}
if ($type === 'int') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$candidate = trim((string)$rawValue);
if ($candidate === '' || filter_var($candidate, FILTER_VALIDATE_INT) === false) {
return $rule['default'] ?? null;
}
$value = (int)$candidate;
if (isset($rule['min']) && $value < (int)$rule['min']) {
return $rule['default'] ?? null;
}
if (isset($rule['max']) && $value > (int)$rule['max']) {
return $rule['default'] ?? null;
}
return $value;
}
if ($type === 'enum') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$value = trim((string)$rawValue);
if ($value === '') {
return $rule['default'] ?? null;
}
$allowed = is_array($rule['allowed'] ?? null) ? $rule['allowed'] : [];
if (!in_array($value, $allowed, true)) {
return $rule['default'] ?? null;
}
return $value;
}
if ($type === 'bool_flag') {
if (is_array($rawValue)) {
return $rule['default'] ?? null;
}
$truthyValues = is_array($rule['truthy_values'] ?? null)
? $rule['truthy_values']
: ['1', 'true', 'yes', 'on'];
$candidate = strtolower(trim((string)$rawValue));
if (in_array($candidate, $truthyValues, true)) {
return $rule['value_true'] ?? '1';
}
return $rule['default'] ?? null;
}
if ($type === 'string_list') {
if (!is_array($rawValue)) {
return $rule['default'] ?? null;
}
// Normalize list payloads into deterministic arrays for canonical URLs.
$items = array_map(static function ($item): string {
return trim((string)$item);
}, $rawValue);
$items = array_values(array_filter($items, static function ($item): bool {
return $item !== '';
}));
if (!empty($rule['unique'])) {
$items = array_values(array_unique($items));
}
if (empty($items) && !($rule['allow_empty'] ?? false)) {
return $rule['default'] ?? null;
}
return $items;
}
return $rule['default'] ?? null;
}
/**
* Build canonical query payload from declarative policy rules.
*
* @param array<string,mixed> $sourceQuery
* @param array<string,array<string,mixed>> $policy
* @return array<string,mixed>
*/
function app_url_build_query_from_policy(array $sourceQuery, array $policy): array
{
$canonical = [];
foreach ($policy as $targetKey => $rule) {
if (!is_array($rule)) {
continue;
}
if (isset($rule['include_if']) && is_callable($rule['include_if']) && !$rule['include_if']($sourceQuery, $canonical)) {
continue;
}
$value = app_url_policy_value((string)$targetKey, $rule, $sourceQuery);
if ($value === null) {
continue;
}
if (isset($rule['transform']) && is_callable($rule['transform'])) {
$value = $rule['transform']($value, $sourceQuery, $canonical);
if ($value === null) {
continue;
}
}
if (isset($rule['validator']) && is_callable($rule['validator']) && !$rule['validator']($value, $sourceQuery, $canonical)) {
continue;
}
if (array_key_exists('omit_if', $rule) && $value === $rule['omit_if']) {
continue;
}
$canonical[(string)$targetKey] = $value;
}
return $canonical;
}

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,728 +0,0 @@
<?php
/*
* Admin control center
*
* Provides maintenance/migration tooling and exposes hook placeholders
* so plugins can contribute additional sections, actions, and metrics.
*/
require_once APP_PATH . 'core/Maintenance.php';
require_once APP_PATH . 'core/MigrationRunner.php';
require_once APP_PATH . 'core/PluginManager.php';
require_once APP_PATH . 'helpers/feedback.php';
require_once APP_PATH . 'helpers/security.php';
$security = SecurityHelper::getInstance();
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Check if the user has admin permissions
$canAdmin = false;
if (isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
$canAdmin = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser');
}
if (!$canAdmin) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
header('Location: ' . $app_root);
exit;
}
$postAction = $_POST['action'] ?? '';
$queryAction = $_GET['action'] ?? '';
$action = $postAction ?: $queryAction;
$targetId = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : null;
$sectionRegistry = [
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
'maintenance' => ['label' => 'Maintenance', 'position' => 200, 'hook' => null, 'type' => 'core'],
'migrations' => ['label' => 'Migrations', 'position' => 300, 'hook' => null, 'type' => 'core'],
'plugins' => ['label' => 'Plugins', 'position' => 400, 'hook' => null, 'type' => 'core'],
];
// Register sections for plugins
$registerSection = static function(array $section) use (&$sectionRegistry): void {
$key = strtolower(trim($section['key'] ?? ''));
$label = trim((string)($section['label'] ?? ''));
if ($key === '' || $label === '') {
return;
}
$position = (int)($section['position'] ?? 900);
$sectionRegistry[$key] = [
'label' => $label,
'position' => $position,
'hook' => $section['hook'] ?? ('admin.' . $key . '.render'),
'type' => $section['type'] ?? 'plugin',
];
};
// Hooks sections for plugins
do_hook('admin.sections.register', [
'register' => $registerSection,
'app_root' => $app_root,
'sections' => &$sectionRegistry,
]);
uasort($sectionRegistry, static function(array $a, array $b): int {
if ($a['position'] === $b['position']) {
return strcmp($a['label'], $b['label']);
}
return $a['position'] <=> $b['position'];
});
if (empty($sectionRegistry)) {
$sectionRegistry = [
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
];
}
$validSections = array_keys($sectionRegistry);
$buildAdminUrl = static function(string $section = 'overview') use ($app_root, &$sectionRegistry): string {
if (!isset($sectionRegistry[$section])) {
$section = array_key_first($sectionRegistry) ?? 'overview';
}
$suffix = $section !== 'overview' ? ('&section=' . urlencode($section)) : '';
return $app_root . '?page=admin' . $suffix;
};
$sectionUrls = [];
foreach (array_keys($sectionRegistry) as $sectionKey) {
$sectionUrls[$sectionKey] = $buildAdminUrl($sectionKey);
}
$requestedSection = strtolower(trim($_GET['section'] ?? 'overview'));
if (!isset($sectionRegistry[$requestedSection])) {
$requestedSection = array_key_first($sectionRegistry) ?? 'overview';
}
$activeSection = $requestedSection;
$adminTabs = [];
foreach ($sectionRegistry as $key => $meta) {
$adminTabs[$key] = [
'label' => $meta['label'],
'url' => $sectionUrls[$key],
'hook' => $meta['hook'],
'type' => $meta['type'],
'position' => $meta['position'],
];
}
$sectionStatePayload = \App\Core\HookDispatcher::applyFilters('admin.sections.state', [
'sections' => $sectionRegistry,
'state' => [],
'db' => $db ?? null,
'user_id' => $userId,
'app_root' => $app_root,
]);
$sectionState = [];
if (is_array($sectionStatePayload)) {
$sectionState = $sectionStatePayload['state'] ?? (is_array($sectionStatePayload) ? $sectionStatePayload : []);
}
// Get plugin catalog and list of loaded plugins
// with their dependencies
$pluginCatalog = \App\Core\PluginManager::getCatalog();
$pluginLoadedMap = \App\Core\PluginManager::getLoaded();
$pluginDependencyErrors = \App\Core\PluginManager::getDependencyErrors();
$normalizeDependencies = static function ($meta): array {
$deps = $meta['dependencies'] ?? [];
if (!is_array($deps)) {
$deps = $deps === null || $deps === '' ? [] : [$deps];
}
$deps = array_map('trim', $deps);
$deps = array_filter($deps, static function($dep) {
return $dep !== '';
});
return array_values(array_unique($deps));
};
$pluginDependentsIndex = [];
foreach ($pluginCatalog as $slug => $info) {
$deps = $normalizeDependencies($info['meta'] ?? []);
foreach ($deps as $dep) {
$pluginDependentsIndex[$dep][] = $slug;
}
}
// Build plugin admin map with details, state and dependencies
$pluginAdminMap = [];
foreach ($pluginCatalog as $slug => $info) {
$meta = $info['meta'] ?? [];
$name = trim((string)($meta['name'] ?? $slug));
$enabled = \App\Core\PluginManager::isEnabled($slug); // Use database setting
$dependencies = $normalizeDependencies($meta);
$dependents = array_values($pluginDependentsIndex[$slug] ?? []);
$enabledDependents = array_values(array_filter($dependents, static function($depSlug) {
return \App\Core\PluginManager::isEnabled($depSlug); // Use database setting
}));
$missingDependencies = array_values(array_filter($dependencies, static function($depSlug) use ($pluginCatalog) {
return !isset($pluginCatalog[$depSlug]) || !\App\Core\PluginManager::isEnabled($depSlug); // Use database setting
}));
// Check for migration files and existing tables
$migrationFiles = glob($info['path'] . '/migrations/*.sql');
$hasMigration = !empty($migrationFiles);
$existingTables = [];
if ($hasMigration) {
$db = \App\App::db();
if ($db instanceof PDO) {
$stmt = $db->query("SHOW TABLES");
$allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
foreach ($migrationFiles as $migrationFile) {
$migrationContent = file_get_contents($migrationFile);
foreach ($allTables as $table) {
if (strpos($migrationContent, $table) !== false) {
$existingTables[] = $table;
}
}
}
$existingTables = array_unique($existingTables);
}
}
$pluginAdminMap[$slug] = [
'slug' => $slug,
'name' => $name,
'version' => (string)($meta['version'] ?? ''),
'description' => (string)($meta['description'] ?? ''),
'path' => $info['path'],
'enabled' => $enabled,
'loaded' => isset($pluginLoadedMap[$slug]),
'dependencies' => $dependencies,
'dependents' => $dependents,
'enabled_dependents' => $enabledDependents,
'missing_dependencies' => $missingDependencies,
'dependency_errors' => $pluginDependencyErrors[$slug] ?? [],
'can_enable' => !$enabled && empty($missingDependencies),
'can_disable' => $enabled && empty($enabledDependents),
'has_migration' => $hasMigration,
'existing_tables' => $existingTables,
'can_install' => $hasMigration && empty($existingTables),
];
}
$pluginAdminList = array_values($pluginAdminMap);
usort($pluginAdminList, static function(array $a, array $b): int {
return strcmp(strtolower($a['name']), strtolower($b['name']));
});
$sectionState['plugins'] = [
'plugins' => $pluginAdminList,
'dependency_errors' => $pluginDependencyErrors,
'plugin_index' => $pluginAdminMap,
];
// Prepare the DB migrations details
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
if ($postAction === 'read_migration') {
header('Content-Type: application/json');
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$csrfToken = $_POST['csrf_token'] ?? $csrfHeader;
if (!$security->verifyCsrfToken($csrfToken)) {
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
if (!$canAdmin) {
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
$filename = basename($_POST['filename'] ?? '');
if ($filename === '' || !preg_match('/^[A-Za-z0-9_\-]+\.sql$/', $filename)) {
echo json_encode(['success' => false, 'error' => 'Invalid filename']);
exit;
}
$path = realpath($migrationsDir . '/' . $filename);
if ($path === false || strpos($path, realpath($migrationsDir)) !== 0) {
echo json_encode(['success' => false, 'error' => 'File not found']);
exit;
}
$content = @file_get_contents($path);
if ($content === false) {
echo json_encode(['success' => false, 'error' => 'Could not read file']);
exit;
}
echo json_encode(['success' => true, 'name' => $filename, 'content' => $content]);
exit;
}
// Hooks actions for plugins
if ($action !== '' && $action !== 'read_migration') {
$customActionPayload = \App\Core\HookDispatcher::applyFilters('admin.actions.handle', [
'handled' => false,
'action' => $action,
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'request' => $_REQUEST,
'security' => $security,
'app_root' => $app_root,
'build_admin_url' => $buildAdminUrl,
'user_id' => $userId,
'db' => $db ?? null,
'target_id' => $targetId,
'section_state' => $sectionState,
]);
if (!empty($customActionPayload['handled'])) {
return;
}
}
if ($postAction !== '' && $postAction !== 'read_migration') {
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('SECURITY', 'CSRF_INVALID');
header('Location: ' . $buildAdminUrl($activeSection));
exit;
}
$postSection = strtolower(trim($_POST['section'] ?? $activeSection));
if (!in_array($postSection, $validSections, true)) {
$postSection = 'overview';
}
try {
// Maintenance actions
if ($postAction === 'maintenance_on') {
$msg = trim($_POST['maintenance_message'] ?? '');
\App\Core\Maintenance::enable($msg);
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode enabled.', true);
} elseif ($postAction === 'maintenance_off') {
\App\Core\Maintenance::disable();
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode disabled.', true);
// DB migrations actions
} elseif ($postAction === 'migrate_up') {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$applied = $runner->applyPendingMigrations();
Feedback::flash('NOTICE', 'DEFAULT', empty($applied) ? 'No pending migrations.' : 'Applied migrations: ' . implode(', ', $applied), true);
} elseif ($postAction === 'migrate_apply_one') {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$migrationName = trim($_POST['migration_name'] ?? '');
$applied = $migrationName !== '' ? $runner->applyMigrationByName($migrationName) : [];
if (empty($applied)) {
Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
$_SESSION['migration_modal_result'] = [
'name' => $migrationName ?: null,
'status' => 'info',
'message' => 'No pending migrations to apply.'
];
if (!empty($migrationName)) {
$_SESSION['migration_modal_open'] = $migrationName;
}
} else {
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migration: ' . implode(', ', $applied), true);
$_SESSION['migration_modal_result'] = [
'name' => $applied[0],
'status' => 'success',
'message' => 'Migration ' . $applied[0] . ' applied successfully.'
];
$_SESSION['migration_modal_open'] = $applied[0];
}
// Plugin actions
} elseif ($postAction === 'plugin_enable' || $postAction === 'plugin_disable') {
$slug = strtolower(trim($_POST['plugin'] ?? ''));
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
} else {
$pluginMeta = $pluginAdminMap[$slug];
if ($postAction === 'plugin_enable') {
if (!$pluginMeta['can_enable']) {
$reason = 'Resolve missing dependencies before enabling this plugin.';
if (!empty($pluginMeta['missing_dependencies'])) {
$reason = 'Enable required plugins first: ' . implode(', ', $pluginMeta['missing_dependencies']);
}
Feedback::flash('ERROR', 'DEFAULT', $reason, false);
} elseif (!\App\Core\PluginManager::setEnabled($slug, true)) {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to enable plugin. Check database connection and error logs.', false);
} else {
// Automatically install plugin tables when enabling
$installResult = \App\Core\PluginManager::install($slug);
if ($installResult) {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled and installed successfully.', $pluginMeta['name']), true);
} else {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled but installation failed. Check migration files.', $pluginMeta['name']), true);
}
}
} else {
if (!$pluginMeta['can_disable']) {
$reason = 'Disable dependent plugins first: ' . implode(', ', $pluginMeta['enabled_dependents']);
Feedback::flash('ERROR', 'DEFAULT', $reason, false);
} elseif (!\App\Core\PluginManager::setEnabled($slug, false)) {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable plugin. Check database connection and error logs.', false);
} else {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" disabled.', $pluginMeta['name']), true);
}
}
}
// Plugin install action
} elseif ($postAction === 'plugin_install') {
$slug = strtolower(trim($_POST['plugin'] ?? ''));
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
} else {
if (\App\Core\PluginManager::install($slug)) {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" installed successfully.', $pluginAdminMap[$slug]['name']), true);
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Plugin installation failed. Check migration files.', false);
}
}
// Plugin purge action
} elseif ($postAction === 'plugin_purge') {
$slug = strtolower(trim($_POST['plugin'] ?? ''));
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
} else {
if (\App\Core\PluginManager::purge($slug)) {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" purged successfully. All data and tables removed.', $pluginAdminMap[$slug]['name']), true);
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Plugin purge failed. Check database permissions.', false);
}
}
// Plugin check action
} elseif ($postAction === 'plugin_check') {
$slug = strtolower(trim($_POST['plugin'] ?? ''));
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
} else {
// Redirect to plugin check page
header('Location: ' . $app_root . '?page=admin&section=plugins&action=plugin_check_page&plugin=' . urlencode($slug));
exit;
}
// Plugin migration test actions
} elseif ($postAction === 'test_plugin_migrations') {
$slug = strtolower(trim($_POST['plugin'] ?? ''));
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
} else {
try {
$pluginPath = $pluginAdminMap[$slug]['path'];
$bootstrapPath = $pluginPath . '/bootstrap.php';
if (!file_exists($bootstrapPath)) {
Feedback::flash('ERROR', 'DEFAULT', 'Plugin has no bootstrap file.', false);
} else {
// Load plugin bootstrap in isolation to test migrations
$migrationFunctions = [];
$bootstrapContent = file_get_contents($bootstrapPath);
// Check for migration functions
if (strpos($bootstrapContent, '_ensure_tables') !== false) {
// Temporarily include bootstrap to test migrations
include_once $bootstrapPath;
$migrationFunctionName = str_replace('-', '_', $slug) . '_ensure_tables';
if (function_exists($migrationFunctionName)) {
$migrationFunctionName();
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" migrations executed successfully.', $pluginAdminMap[$slug]['name']), true);
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Plugin migration function not found.', false);
}
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Plugin has no migration function.', false);
}
}
} catch (Throwable $e) {
Feedback::flash('ERROR', 'DEFAULT', 'Migration test failed: ' . $e->getMessage(), false);
}
}
// Test migrations actions
} elseif ($postAction === 'create_test_migration') {
$timestamp = date('Ymd_His');
$filename = $timestamp . '_test_migration.sql';
$filepath = $migrationsDir . '/' . $filename;
$testMigration = "-- Test migration for testing purposes\n";
$testMigration .= "-- This migration adds a test setting to settings table\n";
$testMigration .= "INSERT INTO settings (`key`, `value`, updated_at) VALUES ('test_migration_flag', '1', NOW())\n";
$testMigration .= "ON DUPLICATE KEY UPDATE `value` = '1', updated_at = NOW();\n";
if (file_put_contents($filepath, $testMigration)) {
Feedback::flash('NOTICE', 'DEFAULT', 'Test migration created: ' . $filename, true);
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to create test migration file', false);
}
} elseif ($postAction === 'clear_test_migrations') {
$testFiles = glob($migrationsDir . '/*_test_migration.sql') ?: [];
$removedCount = 0;
foreach ($testFiles as $file) {
$filename = basename($file);
if (file_exists($file)) {
unlink($file);
$removedCount++;
}
$stmt = $db->getConnection()->prepare('DELETE FROM migrations WHERE migration = :migration');
$stmt->execute([':migration' => $filename]);
}
Feedback::flash('NOTICE', 'DEFAULT', $removedCount > 0 ? ('Cleared ' . $removedCount . ' test migration(s)') : 'No test migrations to clear', true);
}
} catch (Throwable $e) {
Feedback::flash('ERROR', 'DEFAULT', 'Action failed: ' . $e->getMessage(), false);
}
header('Location: ' . $buildAdminUrl($postSection));
exit;
}
$maintenance_enabled = \App\Core\Maintenance::isEnabled();
$maintenance_message = \App\Core\Maintenance::getMessage();
$pending = [];
$applied = [];
$next_pending = null;
$migration_contents = [];
$test_migrations_exist = false;
$migration_records = [];
$migration_error = null;
$migration_modal_result = $_SESSION['migration_modal_result'] ?? null;
if (isset($_SESSION['migration_modal_result'])) {
unset($_SESSION['migration_modal_result']);
}
$modal_to_open = $_SESSION['migration_modal_open'] ?? null;
if (isset($_SESSION['migration_modal_open'])) {
unset($_SESSION['migration_modal_open']);
}
try {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$pending = $runner->listPendingMigrations();
$applied = $runner->listAppliedMigrations();
$sortTestFirst = static function (array $items): array {
usort($items, static function ($a, $b) {
$aTest = strpos($a, '_test_migration') !== false;
$bTest = strpos($b, '_test_migration') !== false;
if ($aTest === $bTest) {
return strcmp($a, $b);
}
return $aTest ? -1 : 1;
});
return $items;
};
$pending = $sortTestFirst($pending);
$applied = $sortTestFirst($applied);
$next_pending = $pending[0] ?? null;
$test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql'));
$all = array_unique(array_merge($pending, $applied));
foreach ($all as $fname) {
$path = realpath($migrationsDir . '/' . $fname);
$content = false;
if ($path && strpos($path, realpath($migrationsDir)) === 0) {
$content = @file_get_contents($path);
}
$record = $runner->getMigrationRecord($fname);
if ($record) {
$migration_records[$fname] = $record;
}
if ($content !== false && $content !== null) {
$migration_contents[$fname] = $content;
} elseif (!empty($record['content'])) {
$migration_contents[$fname] = $record['content'];
}
}
} catch (Throwable $e) {
$migration_error = $e->getMessage();
}
// Generate CSRF token early for all templates
$csrf_token = $security->generateCsrfToken();
// Handle plugin check page
if ($queryAction === 'plugin_check_page' && isset($_GET['plugin'])) {
// Simple test for JSON response
if (isset($_GET['test'])) {
header('Content-Type: application/json');
echo json_encode(['test' => 'working', 'timestamp' => time()]);
exit;
}
// Debug: Log request details
error_log('Plugin check request: ' . print_r([
'action' => $queryAction,
'plugin' => $_GET['plugin'],
'ajax' => isset($_SERVER['HTTP_X_REQUESTED_WITH']),
'ajax_header' => $_SERVER['HTTP_X_REQUESTED_WITH'] ?? 'not set',
'content_type' => $_SERVER['CONTENT_TYPE'] ?? 'not set',
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'not set'
], true));
// Start output buffering to catch any unwanted output
ob_start();
// Disable error display for JSON responses
$originalErrorReporting = error_reporting();
$originalDisplayErrors = ini_get('display_errors');
$isAjax = (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
isset($_GET['ajax']);
if ($isAjax) {
error_reporting(0);
ini_set('display_errors', 0);
}
$pluginSlug = strtolower(trim($_GET['plugin']));
if (!isset($pluginAdminMap[$pluginSlug])) {
if ($isAjax) {
ob_end_clean(); // Clear and end output buffer
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Unknown plugin specified.']);
exit;
}
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
header('Location: ' . $app_root . '?page=admin&section=plugins');
exit;
}
$pluginInfo = $pluginAdminMap[$pluginSlug];
$checkResults = [];
try {
// Check plugin files exist
$migrationFiles = glob($pluginInfo['path'] . '/migrations/*.sql');
$hasMigration = !empty($migrationFiles);
$checkResults['files'] = [
'manifest' => file_exists($pluginInfo['path'] . '/plugin.json'),
'bootstrap' => file_exists($pluginInfo['path'] . '/bootstrap.php'),
'migration' => $hasMigration,
];
// Check database tables
$db = \App\App::db();
$pluginOwnedTables = [];
$pluginReferencedTables = [];
if ($db && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
$stmt = $pdo->query("SHOW TABLES");
$allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
if ($hasMigration) {
foreach ($migrationFiles as $migrationFile) {
$migrationContent = file_get_contents($migrationFile);
// Extract tables created by this migration (plugin-owned)
if (preg_match_all('/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([a-zA-Z0-9_]+)`?/i', $migrationContent, $matches)) {
foreach ($matches[1] as $tableName) {
if (in_array($tableName, $allTables)) {
$pluginOwnedTables[] = $tableName;
}
}
}
// Find all referenced tables (dependencies)
foreach ($allTables as $table) {
if (strpos($migrationContent, $table) !== false && !in_array($table, $pluginOwnedTables)) {
$pluginReferencedTables[] = $table;
}
}
}
$pluginOwnedTables = array_unique($pluginOwnedTables);
$pluginReferencedTables = array_unique($pluginReferencedTables);
}
}
$checkResults['tables'] = [
'owned' => $pluginOwnedTables,
'referenced' => $pluginReferencedTables,
];
// Check plugin functions
$bootstrapPath = $pluginInfo['path'] . '/bootstrap.php';
if (file_exists($bootstrapPath)) {
include_once $bootstrapPath;
$migrationFunction = str_replace('-', '_', $pluginSlug) . '_ensure_tables';
$checkResults['functions'] = [
'migration' => function_exists($migrationFunction),
];
}
} catch (Throwable $e) {
$checkResults['error'] = $e->getMessage();
}
// Handle AJAX request
if ($isAjax) {
// Restore error reporting
error_reporting($originalErrorReporting);
ini_set('display_errors', $originalDisplayErrors);
ob_end_clean(); // Clear and end output buffer
header('Content-Type: application/json');
header('Cache-Control: no-cache, must-revalidate');
$jsonData = json_encode([
'success' => true,
'pluginInfo' => $pluginInfo,
'checkResults' => $checkResults,
'csrf_token' => $csrf_token,
'app_root' => $app_root
]);
error_log('JSON response: ' . $jsonData);
echo $jsonData;
exit;
}
// Restore error reporting for non-AJAX requests
error_reporting($originalErrorReporting);
ini_set('display_errors', $originalDisplayErrors);
// Include check page template for non-AJAX requests
include '../app/templates/admin_plugin_check.php';
exit;
}
$overviewPillsPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.pills', [
'pills' => [],
'sections' => $sectionRegistry,
'section_state' => $sectionState,
'app_root' => $app_root,
'user_id' => $userId,
]);
$adminOverviewPills = [];
if (is_array($overviewPillsPayload)) {
$adminOverviewPills = $overviewPillsPayload['pills'] ?? (is_array($overviewPillsPayload) ? $overviewPillsPayload : []);
}
$overviewStatusesPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.statuses', [
'statuses' => [],
'sections' => $sectionRegistry,
'section_state' => $sectionState,
'app_root' => $app_root,
'user_id' => $userId,
]);
$adminOverviewStatuses = [];
if (is_array($overviewStatusesPayload)) {
$adminOverviewStatuses = $overviewStatusesPayload['statuses'] ?? (is_array($overviewStatusesPayload) ? $overviewStatusesPayload : []);
}
$adminTabDotsPayload = \App\Core\HookDispatcher::applyFilters('admin.tabs.dot_indicators', [
'dots' => [],
'sections' => $sectionRegistry,
'section_state' => $sectionState,
'app_root' => $app_root,
'user_id' => $userId,
]);
$adminTabDots = [];
if (is_array($adminTabDotsPayload)) {
$adminTabDots = $adminTabDotsPayload['dots'] ?? (is_array($adminTabDotsPayload) ? $adminTabDotsPayload : []);
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the view
include '../app/templates/admin.php';

View File

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

View File

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

View File

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

View File

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

View File

@ -14,40 +14,24 @@
* - `password`: Change password * - `password`: Change password
*/ */
require_once '../app/helpers/url_canonicalizer.php'; // Check if user is logged in
if (!isset($_SESSION['user_id'])) {
header("Location: $app_root?page=login");
exit();
}
$user_id = $_SESSION['user_id'];
// Initialize user object // Initialize user object
$userObject = new User($db); $userObject = new User($dbWeb);
// Get action and item from request
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? ''; $item = $_REQUEST['item'] ?? '';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'credentials',
],
'action' => [
'type' => 'enum',
'allowed' => ['setup', 'verify'],
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Restrict credentials URLs to valid setup/verify screen states.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// if a form is submitted // if a form is submitted
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Ensure security helper is available
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Validate CSRF token // Validate CSRF token
$security->verifyCsrfToken($_POST['csrf_token'] ?? '');
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) { if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.'); Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.');
header("Location: $app_root?page=credentials"); header("Location: $app_root?page=credentials");
@ -56,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':
@ -66,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();
@ -83,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();
@ -95,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.');
@ -112,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,
@ -130,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.');
@ -151,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 {
@ -166,7 +151,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
} }
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the 2FA setup template // Load the 2FA setup template
include '../app/templates/credentials-2fa-setup.php'; include '../app/templates/credentials-2fa-setup.php';
@ -174,7 +159,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
case 'verify': case 'verify':
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the 2FA verification template // Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php'; include '../app/templates/credentials-2fa-verify.php';
@ -182,7 +167,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
default: default:
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the combined management template // Load the combined management template
include '../app/templates/credentials-manage.php'; include '../app/templates/credentials-manage.php';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,75 +17,37 @@
// clear the global error var before login // clear the global error var before login
unset($error); unset($error);
require_once '../app/helpers/url_canonicalizer.php';
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(); $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'] ?? '';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'login',
],
'action' => [
'type' => 'enum',
'allowed' => ['verify', 'forgot', 'reset'],
],
'token' => [
'type' => 'string',
'validator' => static function ($value): bool {
return $value !== '';
},
'include_if' => static function (array $sourceQuery): bool {
return (($sourceQuery['action'] ?? '') === 'reset');
},
],
'redirect' => [
'type' => 'string',
'validator' => static function ($value): bool {
return (strpos($value, '/') === 0 || strpos($value, '?') === 0);
},
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep login URLs constrained to supported route states and safe redirect inputs.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
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();
} }
@ -96,10 +58,7 @@ try {
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Make userId available to template
$userId = $pending2FA['user_id'];
// Load the 2FA verification template // Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php'; include '../app/templates/credentials-2fa-verify.php';
@ -133,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
@ -151,7 +110,7 @@ try {
$security->generateCsrfToken(); $security->generateCsrfToken();
// Load the forgot password form // Load the forgot password form
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
include '../app/templates/form-password-forgot.php'; include '../app/templates/form-password-forgot.php';
exit(); exit();
@ -159,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') {
@ -211,7 +170,7 @@ try {
} }
// Show reset password form // Show reset password form
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
include '../app/templates/form-password-reset.php'; include '../app/templates/form-password-reset.php';
exit(); exit();
@ -237,7 +196,8 @@ try {
], ],
'password' => [ 'password' => [
'type' => 'string', 'type' => 'string',
'required' => true 'required' => true,
'min' => 5
] ]
]; ];
@ -260,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
@ -269,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');
@ -279,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:
@ -292,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);
} }
} }
} }
@ -304,11 +269,11 @@ try {
// Show configured login message if any // Show configured login message if any
if (!empty($config['login_message'])) { if (!empty($config['login_message'])) {
echo Feedback::render('NOTICE', 'DEFAULT', $config['login_message'], false, false, false); echo Feedback::render('NOTICE', 'DEFAULT', $config['login_message'], false);
} }
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/form-login.php'; include '../app/templates/form-login.php';
@ -316,26 +281,42 @@ include '../app/templates/form-login.php';
/** /**
* Handle successful login by setting up session and cookies * Handle successful login by setting up session and cookies
*/ */
function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $app_root, $logObject, $userIP) { function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $logObject, $userIP) {
// Create authenticated session if ($rememberMe) {
Session::createAuthSession($userId, $username, $rememberMe, $config); // 30*24*60*60 = 30 days
$cookie_lifetime = 30 * 24 * 60 * 60;
$setcookie_lifetime = time() + 30 * 24 * 60 * 60;
} else {
// 0 - session end on browser close
$cookie_lifetime = 0;
$setcookie_lifetime = 0;
}
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
// set session lifetime and cookies
setcookie('username', $username, [
'expires' => $setcookie_lifetime,
'path' => $config['folder'],
'domain' => $config['domain'],
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
// Set session variables
$_SESSION['user_id'] = $userId;
$_SESSION['USERNAME'] = $username;
$_SESSION['LAST_ACTIVITY'] = time();
if ($rememberMe) {
$_SESSION['REMEMBER_ME'] = true;
}
// Log successful login // Log successful login
$logObject->log('info', "Login: User \"$username\" logged in. IP: $userIP", ['user_id' => $userId, 'scope' => 'user']); $logObject->insertLog($userId, "Login: User \"$username\" logged in. IP: $userIP", 'user');
// Set success message // Set success message and redirect
Feedback::flash('LOGIN', 'LOGIN_SUCCESS'); Feedback::flash('LOGIN', 'LOGIN_SUCCESS');
header('Location: ' . htmlspecialchars($app_root));
// After successful login, redirect to original page if provided in URL param or POST
$redirect = $app_root;
$candidate = $_POST['redirect'] ?? $_GET['redirect'] ?? '';
$trimmed = trim($candidate, '/?');
if (
(strpos($candidate, '/') === 0 || strpos($candidate, '?') === 0)
&& !in_array($trimmed, INVALID_REDIRECT_PAGES, true)
) {
$redirect = $candidate;
}
header('Location: ' . $redirect);
exit();
} }

109
app/pages/logs.php 100644
View File

@ -0,0 +1,109 @@
<?php
/**
* Logs listings
*
* This page ("logs") retrieves and displays logs within a time range
* either for a specified user or for all users.
* It supports pagination and filtering.
*/
// Get any new feedback messages
include '../app/helpers/feedback.php';
// Check for rights; user or system
$has_system_access = ($userObject->hasRight($user_id, 'superuser') ||
$userObject->hasRight($user_id, 'view app logs'));
// Get current page for pagination
$currentPage = $_REQUEST['page_num'] ?? 1;
$currentPage = (int)$currentPage;
// Get selected tab
$selected_tab = $_REQUEST['tab'] ?? 'user';
if ($selected_tab === 'system' && !$has_system_access) {
$selected_tab = 'user';
}
// Set scope based on selected tab
$scope = ($selected_tab === 'system') ? 'system' : 'user';
// specify time range
include '../app/helpers/time_range.php';
// Prepare search filters
$filters = [];
if (isset($_REQUEST['from_time']) && !empty($_REQUEST['from_time'])) {
$filters['from_time'] = $_REQUEST['from_time'];
}
if (isset($_REQUEST['until_time']) && !empty($_REQUEST['until_time'])) {
$filters['until_time'] = $_REQUEST['until_time'];
}
if (isset($_REQUEST['message']) && !empty($_REQUEST['message'])) {
$filters['message'] = $_REQUEST['message'];
}
if ($scope === 'system' && isset($_REQUEST['id']) && !empty($_REQUEST['id'])) {
$filters['id'] = $_REQUEST['id'];
}
// pagination variables
$items_per_page = 15;
$offset = ($currentPage - 1) * $items_per_page;
// Build params for pagination
$params = '';
if (!empty($_REQUEST['from_time'])) {
$params .= '&from_time=' . urlencode($_REQUEST['from_time']);
}
if (!empty($_REQUEST['until_time'])) {
$params .= '&until_time=' . urlencode($_REQUEST['until_time']);
}
if (!empty($_REQUEST['message'])) {
$params .= '&message=' . urlencode($_REQUEST['message']);
}
if (!empty($_REQUEST['id'])) {
$params .= '&id=' . urlencode($_REQUEST['id']);
}
if (isset($_REQUEST['tab'])) {
$params .= '&tab=' . urlencode($_REQUEST['tab']);
}
// prepare the result
$search = $logObject->readLog($user_id, $scope, $offset, $items_per_page, $filters);
$search_all = $logObject->readLog($user_id, $scope, 0, 0, $filters);
if (!empty($search)) {
// we get total items and number of pages
$item_count = count($search_all);
$totalPages = ceil($item_count / $items_per_page);
$logs = array();
$logs['records'] = array();
foreach ($search as $item) {
// when we show only user's logs, omit user_id column
if ($scope === 'user') {
$log_record = array(
// assign title to the field in the array record
'time' => $item['time'],
'log message' => $item['message']
);
} else {
$log_record = array(
// assign title to the field in the array record
'userID' => $item['user_id'],
'username' => $item['username'],
'time' => $item['time'],
'log message' => $item['message']
);
}
// populate the result array
array_push($logs['records'], $log_record);
}
}
$username = $userObject->getUserDetails($user_id)[0]['username'];
// Load the template
include '../app/templates/logs.php';

View File

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

View File

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

View File

@ -12,44 +12,9 @@
* - `edit`: Edit user profile details, rights, or avatar. * - `edit`: Edit user profile details, rights, or avatar.
*/ */
require_once '../app/helpers/url_canonicalizer.php';
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? ''; $item = $_REQUEST['item'] ?? '';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'profile',
],
'action' => [
'type' => 'enum',
'allowed' => ['edit'],
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep profile URLs constrained to supported view states only.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// pass the user details to the profile hooks
$profileHooksContext = [
'userId' => $userId ?? null,
'db' => $db ?? null,
'app_root' => $app_root ?? '/',
'user' => $userDetails[0] ?? null,
];
if (class_exists('\\App\\Core\\HookDispatcher')) {
$profileHooksContext = \App\Core\HookDispatcher::applyFilters('profile.context', $profileHooksContext);
}
// plugins can add additional panels to the profile page
$profilePanelsContext = $profileHooksContext;
// if a form is submitted, it's from the edit page // if a form is submitted, it's from the edit page
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Validate CSRF token // Validate CSRF token
@ -65,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,
@ -83,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 {
@ -124,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");
@ -195,7 +156,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isTimezoneSet = !empty($userDetails[0]['timezone']); $isTimezoneSet = !empty($userDetails[0]['timezone']);
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/profile-edit.php'; include '../app/templates/profile-edit.php';
@ -203,7 +164,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
default: default:
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/profile.php'; include '../app/templates/profile.php';

View File

@ -0,0 +1,98 @@
<?php
/**
* User registration
*
* This page ("register") handles user registration if the feature is enabled in the configuration.
* It accepts a POST request with a username and password, attempts to register the user,
* and redirects to the login page on success or displays an error message on failure.
*/
// registration is allowed, go on
if ($config['registration_enabled'] == true) {
try {
global $dbWeb, $logObject, $userObject;
if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) {
// Apply rate limiting
require '../app/includes/rate_limit_middleware.php';
checkRateLimit($dbWeb, 'register');
require_once '../app/classes/validator.php';
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Sanitize input
$formData = $security->sanitizeArray($_POST, ['username', 'password', 'confirm_password', 'csrf_token']);
// Validate CSRF token
if (!$security->verifyCsrfToken($formData['csrf_token'] ?? '')) {
throw new Exception(Feedback::get('ERROR', 'CSRF_INVALID')['message']);
}
$validator = new Validator($formData);
$rules = [
'username' => [
'required' => true,
'min' => 3,
'max' => 20
],
'password' => [
'required' => true,
'min' => 8,
'max' => 100
],
'confirm_password' => [
'required' => true,
'matches' => 'password'
]
];
$username = $formData['username'] ?? 'unknown';
if ($validator->validate($rules)) {
$password = $formData['password'];
// registering
$result = $userObject->register($username, $password);
// redirect to login
if ($result === true) {
// Get the new user's ID for logging
$user_id = $userObject->getUserId($username)[0]['id'];
$logObject->insertLog($user_id, "Registration: New user \"$username\" registered successfully. IP: $user_IP", 'user');
Feedback::flash('NOTICE', 'DEFAULT', "Registration successful. You can log in now.");
header('Location: ' . htmlspecialchars($app_root));
exit();
// registration fail, redirect to login
} else {
$logObject->insertLog(0, "Registration: Failed registration attempt for user \"$username\". IP: $user_IP. Reason: $result", 'system');
Feedback::flash('ERROR', 'DEFAULT', "Registration failed. $result");
header('Location: ' . htmlspecialchars($app_root));
exit();
}
} else {
$error = $validator->getFirstError();
$logObject->insertLog(0, "Registration: Failed validation for user \"" . ($username ?? 'unknown') . "\". IP: $user_IP. Reason: $error", 'system');
Feedback::flash('ERROR', 'DEFAULT', $error);
header('Location: ' . htmlspecialchars($app_root . '?page=register'));
exit();
}
}
} catch (Exception $e) {
$logObject->insertLog(0, "Registration: System error. IP: $user_IP. Error: " . $e->getMessage(), 'system');
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
}
// Get any new feedback messages
include '../app/helpers/feedback.php';
// Load the template
include '../app/templates/form-register.php';
// registration disabled
} else {
echo Feedback::render('NOTICE', 'DEFAULT', 'Registration is disabled', false);
}

View File

@ -1,45 +1,25 @@
<?php <?php
require_once APP_PATH . 'helpers/url_canonicalizer.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;
} }
// Get current section // Get current section
$section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section']) ? $_GET['section'] : 'whitelist'); $section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section']) ? $_GET['section'] : 'whitelist');
$allowedSections = ['whitelist', 'blacklist', 'ratelimit'];
if (!in_array($section, $allowedSections, true)) {
$section = 'whitelist';
}
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'security',
],
'section' => [
'type' => 'literal',
'value' => $section,
'omit_if' => 'whitelist',
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep security page URLs stable by removing unknown GET parameters.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// 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'])) {
@ -47,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);
@ -55,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;
} }
@ -74,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');
@ -85,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;
} }
@ -99,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');
@ -110,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;
} }
@ -136,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');
@ -147,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;
} }
@ -161,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');
@ -178,20 +158,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
Feedback::flash('ERROR', $e->getMessage()); Feedback::flash('ERROR', $e->getMessage());
} }
// Redirect back to the appropriate section using canonical query formatting. // Redirect back to the appropriate section
$redirectPolicy = [ header("Location: $app_root?page=security&section=" . urlencode($section));
'page' => [
'type' => 'literal',
'value' => 'security',
],
'section' => [
'type' => 'literal',
'value' => $section,
'omit_if' => 'whitelist',
],
];
$redirectQuery = app_url_build_query_from_policy([], $redirectPolicy);
header('Location: ' . app_url_build_internal((string)$app_root, $redirectQuery));
exit; exit;
} }
@ -205,7 +173,7 @@ $whitelisted = $rateLimiter->getWhitelistedIps();
$blacklisted = $rateLimiter->getBlacklistedIps(); $blacklisted = $rateLimiter->getBlacklistedIps();
// Get any new feedback messages // Get any new feedback messages
include_once '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
// Load the template // Load the template
include '../app/templates/security.php'; include '../app/templates/security.php';

View File

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

View File

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

View File

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

View File

@ -1,104 +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';
require_once '../app/helpers/url_canonicalizer.php';
$security = SecurityHelper::getInstance();
// Only allow access to logged-in users
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Get any old feedback messages
include_once '../app/helpers/feedback.php';
$isGetRequest = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET';
if ($isGetRequest) {
$canonicalPolicy = [
'page' => [
'type' => 'literal',
'value' => 'theme',
],
'switch_to' => [
'type' => 'string',
],
'csrf_token' => [
'type' => 'string',
'include_if' => static function (array $sourceQuery): bool {
return trim((string)($sourceQuery['switch_to'] ?? '')) !== '';
},
],
];
$canonicalQuery = app_url_build_query_from_policy($_GET, $canonicalPolicy);
// Keep theme page URLs deterministic while preserving switch action inputs.
app_url_redirect_to_canonical_query((string)$app_root, $_GET, $canonicalQuery);
}
// Handle theme switching
if (isset($_GET['switch_to'])) {
$themeName = $_GET['switch_to'];
// Validate CSRF token for state-changing operations
if (!$security->verifyCsrfToken($_GET['csrf_token'] ?? '')) {
Feedback::flash('SECURITY', 'CSRF_INVALID');
header("Location: $app_root?page=theme");
exit();
}
if (\App\Helpers\Theme::setCurrentTheme($themeName)) {
// Set success message
Feedback::flash('THEME', 'THEME_CHANGED');
} else {
// Set error message
Feedback::flash('THEME', 'THEME_CHANGE_FAILED');
}
// Redirect back to prevent form resubmission
$redirect = $app_root . '?page=theme';
header("Location: $redirect");
exit;
}
// Get available themes and current theme for the view
$themes = \App\Helpers\Theme::getAvailableThemes();
$currentTheme = \App\Helpers\Theme::getCurrentThemeName();
// Prepare theme data with screenshot URLs and metadata for the view
$themeData = [];
foreach ($themes as $id => $name) {
$meta = \App\Helpers\Theme::getThemeMetadata($id);
$themeData[$id] = [
'name' => $meta['name'] ?? $name,
'description' => $meta['description'] ?? '',
'version' => $meta['version'] ?? '',
'author' => $meta['author'] ?? '',
'tags' => $meta['tags'] ?? [],
'type' => $meta['type'] ?? '',
'path' => $meta['path'] ?? '',
'last_modified' => $meta['last_modified'] ?? null,
'file_count' => $meta['file_count'] ?? null,
'screenshotUrl' => \App\Helpers\Theme::getAssetUrl($id, 'screenshot.png'),
'isActive' => $id === $currentTheme
];
}
// Make theme data available to the view
$themes = $themeData;
// Generate CSRF token for the form
$csrf_token = $security->generateCsrfToken();
// Load the template
include '../app/templates/theme.php';

View File

@ -1,921 +0,0 @@
<?php
/** @var bool $maintenance_enabled */
/** @var string $maintenance_message */
/** @var array $pending */
/** @var array $applied */
/** @var string $csrf_token */
/** @var string|null $next_pending */
/** @var array $migration_contents */
/** @var array $migration_records */
/** @var bool $test_migrations_exist */
/** @var array|null $migration_modal_result */
/** @var string|null $modal_to_open */
/** @var string|null $migration_error */
/** @var array $adminOverviewPills */
/** @var array $adminOverviewStatuses */
/** @var array $sectionState */
?>
<?php
$preselectModalId = null;
if (!empty($modal_to_open)) {
$preselectModalId = 'migrationModal' . md5($modal_to_open);
}
?>
<style>
.tooltip {
font-size: 0.75rem;
}
.tooltip-inner {
font-size: 0.75rem;
max-width: 300px;
}
.tm-admin-tab-label,
.tm-admin-subnav-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.tm-admin-tab-label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
</style>
<?php
$tabs = $adminTabs ?? [];
if (empty($tabs)) {
$tabs = [
'overview' => [
'label' => 'Overview',
'url' => $sectionUrls['overview'] ?? ($app_root . '?page=admin'),
'type' => 'core',
'hook' => null,
'position' => 100,
],
];
}
$heroPills = [
[
'label' => 'Maintenance',
'value' => $maintenance_enabled ? 'enabled' : 'off',
'icon' => 'fas fa-power-off',
'tone' => $maintenance_enabled ? 'danger' : 'success',
],
[
'label' => 'Migrations',
'value' => count($pending) . ' pending',
'icon' => 'fas fa-database',
'tone' => empty($pending) ? 'neutral' : 'warning',
],
];
if (!empty($adminOverviewPills) && is_array($adminOverviewPills)) {
foreach ($adminOverviewPills as $pill) {
if (!is_array($pill)) {
continue;
}
$heroPills[] = [
'label' => (string)($pill['label'] ?? 'Status'),
'value' => (string)($pill['value'] ?? ''),
'icon' => (string)($pill['icon'] ?? 'fas fa-info-circle'),
'tone' => (string)($pill['tone'] ?? 'info'),
];
}
}
$statusItems = [
[
'label' => 'Maintenance mode',
'description' => $maintenance_enabled ? 'Live site shows downtime banner.' : 'Visitors see the normal experience.',
'value' => $maintenance_enabled ? 'ON' : 'OFF',
'tone' => $maintenance_enabled ? 'warning' : 'success',
],
[
'label' => 'Schema migrations',
'description' => empty($pending) ? 'Database matches code.' : 'Pending updates detected.',
'value' => count($pending) . ' pending',
'tone' => empty($pending) ? 'success' : 'warning',
],
];
if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) {
foreach ($adminOverviewStatuses as $status) {
if (!is_array($status)) {
continue;
}
$statusItems[] = [
'label' => (string)($status['label'] ?? 'Status'),
'description' => (string)($status['description'] ?? ''),
'value' => (string)($status['value'] ?? ''),
'tone' => (string)($status['tone'] ?? 'info'),
];
}
}
?>
<section class="tm-hero">
<div class="tm-hero-card tm-hero-card--admin">
<div class="tm-hero-body">
<div class="tm-hero-heading">
<h1 class="tm-hero-title">Admin control center</h1>
<p class="tm-hero-subtitle">
Centralized administration dashboard for system-wide management.
</p>
</div>
<div class="tm-hero-meta tm-hero-meta--stacked">
<?php foreach ($heroPills as $pill):
$toneClass = 'pill-' . preg_replace('/[^a-z0-9_-]/i', '', $pill['tone'] ?? 'info');
?>
<div class="tm-hero-pill <?= htmlspecialchars($toneClass) ?>">
<i class="<?= htmlspecialchars($pill['icon']) ?>"></i>
<?= htmlspecialchars($pill['label']) ?> <?= htmlspecialchars($pill['value']) ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="tm-hero-actions">
<a class="btn btn-primary tm-directory-cta" href="<?= htmlspecialchars($app_root) ?>?page=dashboard">
<i class="fas fa-arrow-left"></i> Back to dashboard
</a>
</div>
</div>
</section>
<section class="tm-admin tm-admin--dashboard">
<div class="tm-admin-tabs" role="tablist">
<?php foreach ($tabs as $sectionKey => $tabMeta):
$isActive = $activeSection === $sectionKey;
$tabUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin&section=' . urlencode($sectionKey)));
?>
<a class="tm-admin-tab-button <?= $isActive ? 'active' : '' ?>"
href="<?= htmlspecialchars($tabUrl) ?>"
role="tab"
aria-selected="<?= $isActive ? 'true' : 'false' ?>"
aria-controls="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>">
<span class="tm-admin-tab-label">
<?= htmlspecialchars($tabMeta['label'] ?? ucfirst($sectionKey)) ?>
<?php if (!empty($adminTabDots[$sectionKey])): ?>
<span class="tm-admin-tab-dot" aria-hidden="true"></span>
<?php endif; ?>
</span>
</a>
<?php endforeach; ?>
</div>
<?php foreach ($tabs as $sectionKey => $tabMeta):
$panelUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin&section=' . urlencode($sectionKey)));
$isActive = $activeSection === $sectionKey;
?>
<div class="tm-admin-tab-panel <?= $isActive ? 'active' : '' ?>" id="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>" role="tabpanel">
<?php if (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'overview'): ?>
<div class="tm-admin-grid tm-admin-grid--three">
<article class="tm-admin-card">
<header>
<h2 class="tm-admin-card-title">Current status</h2>
<p class="tm-admin-card-subtitle">High-level signals that require your attention.</p>
</header>
<ul class="tm-admin-status-list">
<?php foreach ($statusItems as $status):
$statusTone = 'status-' . preg_replace('/[^a-z0-9_-]/i', '', $status['tone'] ?? 'info');
?>
<li class="<?= htmlspecialchars($statusTone) ?>">
<div>
<strong><?= htmlspecialchars($status['label']) ?></strong>
<?php if (!empty($status['description'])): ?>
<p><?= htmlspecialchars($status['description']) ?></p>
<?php endif; ?>
</div>
<span class="tm-admin-status-value <?= htmlspecialchars($statusTone) ?>">
<?= htmlspecialchars($status['value']) ?>
</span>
</li>
<?php endforeach; ?>
</ul>
</article>
<article class="tm-admin-card">
<header>
<h2 class="tm-admin-card-title">Maintenance</h2>
<p class="tm-admin-card-subtitle">Toggle maintenance or update visitor message.</p>
</header>
<form method="post" class="tm-admin-controls">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_on">
<input type="hidden" name="section" value="overview">
<label for="maintenance_message_overview" class="form-label">Maintenance message</label>
<input type="text"
id="maintenance_message_overview"
name="maintenance_message"
class="form-control tm-admin-message-input"
value="<?= htmlspecialchars($maintenance_message) ?>"
placeholder="Custom message. Default is 'Please try again later.'">
<div class="tm-admin-inline-actions">
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable</button>
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?>
onclick="document.getElementById('maintenance-disable-form-overview').submit();">Disable</button>
</div>
</form>
<form method="post" id="maintenance-disable-form-overview" class="d-none">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_off">
<input type="hidden" name="section" value="overview">
</form>
</article>
<article class="tm-admin-card">
<header>
<h2 class="tm-admin-card-title">Next migration</h2>
<p class="tm-admin-card-subtitle">Peek at what will run when you apply updates.</p>
</header>
<?php if ($next_pending): ?>
<p class="text-muted mb-2">Next: <strong><?= htmlspecialchars($next_pending) ?></strong></p>
<button class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#migrationModal<?= md5($next_pending) ?>">
View SQL
</button>
<?php else: ?>
<p class="tm-admin-empty">No migrations queued.</p>
<?php endif; ?>
<hr>
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up">
<input type="hidden" name="section" value="overview">
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'maintenance'): ?>
<div class="tm-admin-grid">
<article class="tm-admin-card">
<header>
<div>
<h2 class="tm-admin-card-title">Maintenance mode</h2>
<p class="tm-admin-card-subtitle">Let your users know when maintenance is in progress.</p>
</div>
<span class="tm-hero-pill <?= $maintenance_enabled ? 'pill-danger' : 'pill-neutral' ?>">
<?= $maintenance_enabled ? 'ENABLED' : 'DISABLED' ?>
</span>
</header>
<div class="tm-admin-section">
<p class="tm-admin-section-title">Message</p>
<form method="post" class="tm-admin-controls">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_on">
<textarea id="maintenance_message"
name="maintenance_message"
class="form-control tm-admin-message-input"
rows="3"
placeholder="Custom message. Default is 'Please try again later.'"><?= htmlspecialchars($maintenance_message) ?></textarea>
<div class="tm-admin-inline-actions">
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?> onclick="document.getElementById('maintenance-disable-form').submit();">Disable maintenance</button>
</div>
</form>
</div>
<form method="post" id="maintenance-disable-form" class="d-none">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_off">
<input type="hidden" name="section" value="maintenance">
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'migrations'): ?>
<div class="tm-admin-grid">
<article class="tm-admin-card tm-admin-card--migrations">
<header>
<div>
<h2 class="tm-admin-card-title">Database migrations</h2>
<p class="tm-admin-card-subtitle">Review pending SQL and apply with confidence.</p>
</div>
</header>
<?php if (!empty($migration_error)): ?>
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
<?php endif; ?>
<div class="tm-admin-test-tools">
<p><strong>Test migration tools</strong></p>
<div class="tm-admin-inline-actions">
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="create_test_migration">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-outline-primary btn-sm" <?= !empty($test_migrations_exist) ? 'disabled' : '' ?>>Create test migration</button>
</form>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="clear_test_migrations">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= empty($test_migrations_exist) ? 'disabled' : '' ?>>Clear test migrations</button>
</form>
</div>
</div>
<div class="tm-admin-section">
<div class="d-flex justify-content-between align-items-center">
<p class="tm-admin-section-title mb-0">Pending migrations</p>
<?php if (!empty($next_pending)): ?>
<span class="badge bg-info text-dark">Next: <?= htmlspecialchars($next_pending) ?></span>
<?php endif; ?>
</div>
<ul class="tm-admin-list">
<?php if (empty($pending)): ?>
<li class="tm-admin-empty">No pending migrations</li>
<?php else: ?>
<?php foreach ($pending as $fname): ?>
<li>
<div class="tm-admin-list-actions">
<button type="button" class="btn btn-sm btn-outline-primary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
</div>
<span><?= htmlspecialchars($fname) ?></span>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<div class="tm-admin-section">
<div class="d-flex justify-content-between align-items-center">
<p class="tm-admin-section-title mb-0">Applied migrations</p>
<span class="badge bg-secondary"><?= count($applied) ?></span>
</div>
<ul class="tm-admin-list">
<?php if (empty($applied)): ?>
<li class="tm-admin-empty">No applied migrations yet</li>
<?php else: ?>
<?php foreach ($applied as $fname):
if (strpos($fname, '_test_migration') !== false) {
continue;
}
?>
<li>
<div class="tm-admin-list-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
</div>
<span><?= htmlspecialchars($fname) ?></span>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'plugins'): ?>
<?php
$pluginsState = $sectionState['plugins'] ?? [];
$pluginsList = $pluginsState['plugins'] ?? [];
$dependencyErrors = $pluginsState['dependency_errors'] ?? [];
$totalPlugins = count($pluginsList);
$enabledPlugins = count(array_filter($pluginsList, static function($plugin) {
return !empty($plugin['enabled']);
}));
$issuesPlugins = count(array_filter($pluginsList, static function($plugin) {
return !empty($plugin['dependency_errors']) || !$plugin['loaded'];
}));
?>
<div class="tm-admin-grid">
<article class="tm-admin-card">
<header>
<div>
<h2 class="tm-admin-card-title">Plugin overview</h2>
<p class="tm-admin-card-subtitle">Enable or disable functionality and review dependency health.</p>
</div>
<div class="tm-hero-pill pill-primary">
<?= htmlspecialchars($enabledPlugins) ?> / <?= htmlspecialchars($totalPlugins) ?> enabled
</div>
</header>
<?php if (!empty($dependencyErrors)): ?>
<div class="alert alert-warning">
<strong>Dependency issues detected.</strong> Resolve the following before enabling affected plugins:
<ul class="mb-0 mt-2">
<?php foreach ($dependencyErrors as $slug => $errors):
if (empty($errors)) {
continue;
}
?>
<li><strong><?= htmlspecialchars($slug) ?>:</strong> <?= htmlspecialchars(implode('; ', $errors)) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (empty($pluginsList)): ?>
<p class="tm-admin-empty mb-0">No plugins detected in the plugins directory.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover tm-admin-table">
<thead>
<tr>
<th>Plugin</th>
<th>Status</th>
<th>Depends on</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<?php
$pluginIndex = $pluginsState['plugin_index'] ?? [];
foreach ($pluginsList as $plugin):
$missingDeps = $plugin['missing_dependencies'] ?? [];
$depErrors = $plugin['dependency_errors'] ?? [];
$dependents = $plugin['dependents'] ?? [];
$enabledDependents = $plugin['enabled_dependents'] ?? [];
$statusBadges = [];
$statusBadges[] = $plugin['enabled']
? '<span class="badge text-uppercase" style="background-color:#198754;color:#fff;">Enabled</span>'
: '<span class="badge text-uppercase" style="background-color:#c6c6c6;color:#fff;">Disabled</span>';
if ($plugin['enabled'] && empty($depErrors) && $plugin['loaded']) {
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#0dcaf0;color:#052c65;">Loaded</span>';
}
if (!empty($missingDeps) || !empty($depErrors)) {
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#ffc107;color:#212529;">Issues</span>';
}
?>
<tr>
<td>
<strong><?= htmlspecialchars($plugin['name']) ?></strong>
<?php if (!empty($plugin['version'])): ?>
<span class="text-muted">v<?= htmlspecialchars($plugin['version']) ?></span>
<?php endif; ?>
<?php if (!empty($plugin['description'])): ?>
<p class="tm-admin-muted mb-0"><?= htmlspecialchars($plugin['description']) ?></p>
<?php endif; ?>
</td>
<td>
<?= implode(' ', $statusBadges) ?>
<?php if (!empty($depErrors)): ?>
<p class="tm-admin-muted text-warning mb-0"><?= htmlspecialchars(implode(' ', $depErrors)) ?></p>
<?php endif; ?>
</td>
<td>
<?php if (!empty($plugin['dependencies'])): ?>
<ul class="tm-admin-inline-list">
<?php foreach ($plugin['dependencies'] as $dep):
$depMeta = $pluginIndex[$dep] ?? null;
$depStatusBadge = '';
if ($depMeta) {
$depStatusBadge = $depMeta['enabled']
? '<span class="badge" style="background-color:#198754;color:#fff;">OK</span>'
: '<span class="badge" style="background-color:#ffc107;color:#212529;">Off</span>';
if (!empty($depMeta['dependency_errors'])) {
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Error</span>';
}
} elseif (in_array($dep, $missingDeps, true)) {
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Missing</span>';
}
?>
<li>
<?= htmlspecialchars($dep) ?>
<?php if ($depStatusBadge !== ''): ?>
<span class="tm-admin-dep-status"><?= $depStatusBadge ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-right">
<div class="btn-group" role="group">
<?php if ($plugin['enabled']): ?>
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="section" value="plugins">
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
<input type="hidden" name="action" value="plugin_disable">
<?php if ($plugin['can_disable']): ?>
<span data-toggle="tooltip" data-placement="top" title="Disable this plugin">
<button type="submit" class="btn btn-sm btn-outline-danger">Disable</button>
</span>
<?php else: ?>
<span data-toggle="tooltip" data-placement="top"
title="<?= htmlspecialchars('Cannot disable: ' . (count($plugin['enabled_dependents']) > 0 ? 'Required by: ' . implode(', ', $plugin['enabled_dependents']) : 'Plugin has active dependents')) ?>">
<button type="button" class="btn btn-sm btn-outline-danger" disabled>Disable</button>
</span>
<?php endif; ?>
</form>
<?php else: ?>
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="section" value="plugins">
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
<input type="hidden" name="action" value="plugin_enable">
<?php if ($plugin['can_enable']): ?>
<span data-toggle="tooltip" data-placement="top" title="Enable this plugin">
<button type="submit" class="btn btn-sm btn-outline-success">Enable</button>
</span>
<?php else: ?>
<span data-toggle="tooltip" data-placement="top"
title="<?= htmlspecialchars('Cannot enable: ' . (count($plugin['missing_dependencies']) > 0 ? 'Missing dependencies: ' . implode(', ', $plugin['missing_dependencies']) : 'Plugin has dependency issues')) ?>">
<button type="button" class="btn btn-sm btn-outline-success" disabled>Enable</button>
</span>
<?php endif; ?>
</form>
<?php endif; ?>
<?php if (file_exists($pluginAdminMap[$plugin['slug']]['path'] . '/bootstrap.php')): ?>
<span data-toggle="tooltip" data-placement="top" title="Check plugin health and status" style="margin-left: 0.5rem;">
<button type="button" class="btn btn-sm btn-outline-secondary" data-toggle="modal" data-target="#pluginCheckModal<?= htmlspecialchars($plugin['slug']) ?>">Check</button>
</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</article>
</div>
<?php elseif (!empty($tabMeta['hook'])): ?>
<?php
do_hook($tabMeta['hook'], [
'section' => $sectionKey,
'active_section' => $activeSection,
'app_root' => $app_root,
'section_url' => $panelUrl,
'section_urls' => $sectionUrls ?? [],
'csrf_token' => $csrf_token,
'state' => $sectionState[$sectionKey] ?? [],
'section_state' => $sectionState,
'db' => $db ?? null,
]);
?>
<?php else: ?>
<article class="tm-admin-card">
<p class="tm-admin-empty mb-0">No renderer available for this section.</p>
</article>
<?php endif; ?>
</div>
<?php endforeach; ?>
</section>
<?php if (!empty($migration_contents)):
foreach ($migration_contents as $name => $content):
$modalId = 'migrationModal' . md5($name);
$record = $migration_records[$name] ?? null;
$appliedAtRaw = $record['applied_at'] ?? null;
$appliedAtFormatted = null;
if (!empty($appliedAtRaw)) {
$appliedAtFormatted = app_format_local_datetime($appliedAtRaw, 'M d, Y H:i', $userTimezone) ?: $appliedAtRaw;
}
$isModalNext = (!empty($next_pending) && $next_pending === $name);
$modalResult = (!empty($migration_modal_result) && ($migration_modal_result['name'] ?? '') === $name) ? $migration_modal_result : null;
?>
<div class="modal fade" id="<?= $modalId ?>" tabindex="-1" aria-labelledby="<?= $modalId ?>Label" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="<?= $modalId ?>Label"><?= htmlspecialchars($name) ?></h5>
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<pre class="tm-admin-modal-code"><code style="border-radius: 0.5rem;"><?= htmlspecialchars($content) ?></code></pre>
</div>
<div class="modal-footer">
<?php if ($isModalNext): ?>
<form method="post" class="me-auto tm-confirm" data-confirm="Apply migration <?= htmlspecialchars($name) ?>?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_apply_one">
<input type="hidden" name="migration_name" value="<?= htmlspecialchars($name) ?>">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-danger">Apply migration</button>
</form>
<?php endif; ?>
<?php if ($modalResult): ?>
<div class="alert alert-<?= $modalResult['status'] === 'success' ? 'success' : 'info' ?> mb-0 small">
<?= htmlspecialchars($modalResult['message']) ?>
</div>
<?php endif; ?>
<?php if ($appliedAtFormatted): ?>
<div class="tm-admin-modal-meta">
<span class="tm-admin-pill pill-success">
<i class="far fa-clock"></i>
Applied <?= htmlspecialchars($appliedAtFormatted) ?>
</span>
</div>
<?php endif; ?>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<?php
endforeach;
endif; ?>
<form method="post" id="tm-admin-hidden-read-migration" class="d-none">
<input type="hidden" name="action" value="read_migration">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="filename" value="">
</form>
<!-- Plugin Check Modals -->
<?php foreach ($pluginAdminList as $plugin): ?>
<?php if (file_exists($plugin['path'] . '/bootstrap.php')): ?>
<?php
$modalId = 'pluginCheckModal' . htmlspecialchars($plugin['slug']);
$checkResults = [];
try {
// Check plugin files exist
$migrationFiles = glob($plugin['path'] . '/migrations/*.sql');
$hasMigration = !empty($migrationFiles);
$checkResults['files'] = [
'manifest' => file_exists($plugin['path'] . '/plugin.json'),
'bootstrap' => file_exists($plugin['path'] . '/bootstrap.php'),
'helpers' => file_exists($plugin['path'] . '/helpers.php'),
'controllers' => is_dir($plugin['path'] . '/controllers') && count(glob($plugin['path'] . '/controllers/*.php')) > 0,
'migration' => $hasMigration,
];
// Check database tables
$db = \App\App::db();
$pluginOwnedTables = [];
$pluginReferencedTables = [];
if ($db && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
$stmt = $pdo->query("SHOW TABLES");
$allTables = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
if ($hasMigration) {
foreach ($migrationFiles as $migrationFile) {
$migrationContent = file_get_contents($migrationFile);
// Extract tables created by this migration (plugin-owned)
if (preg_match_all('/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([a-zA-Z0-9_]+)`?/i', $migrationContent, $matches)) {
foreach ($matches[1] as $tableName) {
if (in_array($tableName, $allTables)) {
$pluginOwnedTables[] = $tableName;
}
}
}
// Find all referenced tables (dependencies)
foreach ($allTables as $table) {
if (strpos($migrationContent, $table) !== false && !in_array($table, $pluginOwnedTables)) {
$pluginReferencedTables[] = $table;
}
}
}
$pluginOwnedTables = array_unique($pluginOwnedTables);
$pluginReferencedTables = array_unique($pluginReferencedTables);
}
}
$checkResults['tables'] = [
'owned' => $pluginOwnedTables,
'referenced' => $pluginReferencedTables,
];
// Check plugin functions and integrations
$bootstrapPath = $plugin['path'] . '/bootstrap.php';
if (file_exists($bootstrapPath)) {
include_once $bootstrapPath;
$migrationFunction = str_replace('-', '_', $plugin['slug']) . '_ensure_tables';
$migrationTestResult = null;
// Test migration function if it exists
if (function_exists($migrationFunction)) {
try {
// Check if plugin tables already exist
$tablesExist = !empty($pluginTables);
if ($tablesExist) {
$migrationTestResult = 'already installed';
} else {
// For plugins without tables, the function exists and is ready
$migrationTestResult = 'function ready (tables not installed)';
}
} catch (Exception $e) {
$migrationTestResult = 'error: ' . $e->getMessage();
}
} else {
$migrationTestResult = 'not applicable';
}
// Check route and hook registrations
$routePrefix = $plugin['slug'];
$hasRouteRegistration = isset($GLOBALS['plugin_route_prefixes']) && isset($GLOBALS['plugin_route_prefixes'][$routePrefix]);
$checkResults['functions'] = [
'migration' => function_exists($migrationFunction),
'migration_test' => $migrationTestResult ?: 'not applicable',
'route_registration' => $hasRouteRegistration,
'hook_registration' => true, // If bootstrap loaded, assume hooks are registered
];
}
} catch (Throwable $e) {
$checkResults['error'] = $e->getMessage();
}
?>
<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">Plugin Health Check: <?= htmlspecialchars($plugin['name']) ?></h5>
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">Plugin Information</h6>
</div>
<div class="card-body">
<div class="small">
<div class="mb-1"><strong>Name:</strong> <?= htmlspecialchars($plugin['name']) ?></div>
<div class="mb-1"><strong>Version:</strong> <?= htmlspecialchars($plugin['version'] ?? 'N/A') ?></div>
<div class="mb-1"><strong>Enabled:</strong> <span class="badge bg-<?= $plugin['enabled'] ? 'success' : 'secondary' ?>"><?= $plugin['enabled'] ? 'Yes' : 'No' ?></span></div>
<div class="mb-1"><strong>Dependencies:</strong> <?= !empty($plugin['dependencies']) ? htmlspecialchars(implode(', ', $plugin['dependencies'])) : 'None' ?></div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">Functions Check</h6>
</div>
<div class="card-body">
<?php foreach ($checkResults['functions'] ?? [] as $func => $value): ?>
<?php if ($func === 'migration_test'): ?>
<?php if ($value !== 'not applicable'): ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Migration Test</span>
<?php if ($value === 'already installed'): ?>
<span class="badge bg-info">Already Installed</span>
<?php elseif (strpos($value, 'error') === false): ?>
<span class="badge bg-success">Passed</span>
<?php else: ?>
<span class="badge bg-danger">Failed</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($value === 'already installed'): ?>
<div class="text-muted small mb-2">Plugin tables already exist - migration not needed</div>
<?php elseif (strpos($value, 'error') !== false): ?>
<div class="text-muted small mb-2"><?= htmlspecialchars($value) ?></div>
<?php endif; ?>
<?php elseif ($func === 'route_registration'): ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Route Registration</span>
<span class="badge bg-<?= $value ? 'success' : 'danger' ?>">
<?= $value ? 'Registered' : 'Not Registered' ?>
</span>
</div>
<?php elseif ($func === 'hook_registration'): ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Hook Registration</span>
<span class="badge bg-<?= $value ? 'success' : 'danger' ?>">
<?= $value ? 'Active' : 'Inactive' ?>
</span>
</div>
<?php elseif ($func === 'migration'): ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<span><?= htmlspecialchars(ucfirst($func)) ?> Function</span>
<span class="badge bg-<?= $value ? 'success' : 'danger' ?>">
<?= $value ? 'Available' : 'Missing' ?>
</span>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">Database Tables</h6>
</div>
<div class="card-body">
<?php if (!empty($checkResults['tables']['owned']) || !empty($checkResults['tables']['referenced'])): ?>
<?php if (!empty($checkResults['tables']['owned'])): ?>
<div class="mb-3">
<strong class="text-danger">Plugin Tables (removed on purge):</strong>
<?php foreach ($checkResults['tables']['owned'] as $table): ?>
<div class="d-flex justify-content-between align-items-center mb-2 mt-2">
<span><i class="fas fa-database text-danger"></i> <?= htmlspecialchars($table) ?></span>
<span class="badge bg-danger">Owned</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($checkResults['tables']['referenced'])): ?>
<div>
<strong class="text-muted">Referenced Tables (dependencies):</strong>
<?php foreach ($checkResults['tables']['referenced'] as $table): ?>
<div class="d-flex justify-content-between align-items-center mb-2 mt-2">
<span><i class="fas fa-link text-muted"></i> <?= htmlspecialchars($table) ?></span>
<span class="badge bg-secondary">Referenced</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php else: ?>
<p class="text-muted mb-0">
<?php if ($checkResults['files']['migration']): ?>
Plugin has migration files but tables are not installed yet.
<?php else: ?>
No plugin tables found.
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">File System Check</h6>
</div>
<div class="card-body">
<?php foreach ($checkResults['files'] ?? [] as $file => $exists): ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<span><?= htmlspecialchars(ucfirst($file)) ?></span>
<span class="badge bg-<?= $exists ? 'success' : 'danger' ?>">
<?= $exists ? 'Exists' : 'Missing' ?>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php if (isset($checkResults['error'])): ?>
<div class="alert alert-danger mt-3">
<strong>Error:</strong> <?= htmlspecialchars($checkResults['error']) ?>
</div>
<?php endif; ?>
</div>
<div class="modal-footer">
<?php if ($plugin['has_migration']): ?>
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="section" value="plugins">
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
<input type="hidden" name="action" value="plugin_install">
<button type="submit" class="btn btn-primary">Install plugin DB tables</button>
</form>
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="section" value="plugins">
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
<input type="hidden" name="action" value="plugin_purge">
<button type="submit" class="btn btn-warning" onclick="return confirm('Are you sure? This will permanently delete all plugin data and tables!')">Purge all plugin data</button>
</form>
<?php endif; ?>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
<form method="post" id="tm-admin-hidden-read-migration" class="d-none">
<input type="hidden" name="action" value="read_migration">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="filename" value="">
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Initialize tooltips
if (typeof $ !== 'undefined' && $.fn.tooltip) {
$('[data-toggle="tooltip"]').tooltip();
}
document.querySelectorAll('form.tm-confirm').forEach((form) => {
form.addEventListener('submit', (event) => {
const message = form.getAttribute('data-confirm') || 'Are you sure?';
if (!confirm(message)) {
event.preventDefault();
}
});
});
const preselectModal = <?= $preselectModalId ? '"#' . htmlspecialchars($preselectModalId) . '"' : 'null' ?>;
if (preselectModal) {
const el = document.querySelector(preselectModal);
if (el && window.$) {
window.$(el).modal('show');
}
}
});
</script>

View File

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

View File

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

View File

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

View File

@ -5,86 +5,87 @@
*/ */
?> ?>
<div class="action-card"> <div class="container mt-4">
<div class="action-card-header"> <div class="row justify-content-center">
<p class="action-eyebrow">Security</p> <div class="col-md-8">
<h2 class="action-title">Manage credentials</h2> <!-- Password Management -->
<p class="action-subtitle">Update your password and keep two-factor authentication status in one place.</p> <div class="card mb-4">
</div> <div class="card-header">
<div class="action-card-body"> <h3>change password</h3>
<div class="tm-cred-grid">
<section class="tm-cred-panel">
<div class="tm-cred-panel-head">
<div>
<h3>Change password</h3>
<p>Choose a strong password to keep your account safe.</p>
</div>
<span class="badge bg-light text-dark">Required</span>
</div> </div>
<form method="post" action="?page=credentials&item=password" class="action-form" novalidate> <div class="card-body">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>"> <form method="post" action="?page=credentials&item=password">
<div class="action-form-group">
<label for="current_password" class="action-form-label">Current password</label>
<input type="password" class="form-control action-form-control" id="current_password" name="current_password" required>
</div>
<div class="action-form-group">
<label for="new_password" class="action-form-label">New password</label>
<input type="password" class="form-control action-form-control" id="new_password" name="new_password" pattern=".{8,}" title="Password must be at least 8 characters long" required>
<small class="form-text text-muted">Minimum 8 characters</small>
</div>
<div class="action-form-group">
<label for="confirm_password" class="action-form-label">Confirm new password</label>
<input type="password" class="form-control action-form-control" id="confirm_password" name="confirm_password" pattern=".{8,}" required>
</div>
<div class="action-actions">
<button type="submit" class="btn btn-primary">Save new password</button>
</div>
</form>
</section>
<section class="tm-cred-panel">
<div class="tm-cred-panel-head">
<div>
<h3>Two-factor authentication</h3>
<p>Strengthen security with a verification code from your authenticator app.</p>
</div>
<span class="badge <?= $has2fa ? 'bg-success' : 'bg-warning text-dark' ?>">
<?= $has2fa ? 'Enabled' : 'Disabled' ?>
</span>
</div>
<?php if ($has2fa): ?>
<div class="alert alert-success d-flex align-items-center gap-2">
<i class="fas fa-shield-check"></i>
<span>Two-factor authentication is currently enabled.</span>
</div>
<form method="post" action="?page=credentials&item=2fa&action=disable" class="action-form">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>"> <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<div class="action-actions">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Disable two-factor authentication? This will make your account less secure.')"> <div class="form-group">
Disable 2FA <label for="current_password">current password</label>
</button> <input type="password"
class="form-control"
id="current_password"
name="current_password"
required>
</div>
<div class="form-group mt-3">
<label for="new_password">new password</label>
<input type="password"
class="form-control"
id="new_password"
name="new_password"
pattern=".{8,}"
title="Password must be at least 8 characters long"
required>
<small class="form-text text-muted">minimum 8 characters</small>
</div>
<div class="form-group mt-3">
<label for="confirm_password">confirm new password</label>
<input type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
pattern=".{8,}"
required>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">change password</button>
</div> </div>
</form> </form>
<?php else: ?> </div>
<div class="alert alert-warning d-flex align-items-center gap-2"> </div>
<i class="fas fa-lock"></i>
<span>Two-factor authentication is not enabled yet.</span> <!-- 2FA Management -->
</div> <div class="card">
<form method="post" action="?page=credentials&item=2fa&action=setup" class="action-form"> <div class="card-header">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>"> <h3>two-factor authentication</h3>
<div class="action-actions"> </div>
<button type="submit" class="btn btn-outline-primary"> <div class="card-body">
Set up 2FA <p class="mb-4">Two-factor authentication adds an extra layer of security to your account. Once enabled, you'll need to enter both your password and a code from your authenticator app when signing in.</p>
</button>
<?php if ($has2fa): ?>
<div class="alert alert-success">
<i class="fas fa-check-circle"></i> two-factor authentication is enabled
</div> </div>
</form> <form method="post" action="?page=credentials&item=2fa&action=disable">
<?php endif; ?> <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
</section> <button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.')">
disable two-factor authentication
</button>
</form>
<?php else: ?>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i> two-factor authentication is not enabled
</div>
<form method="post" action="?page=credentials&item=2fa&action=setup">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<button type="submit" class="btn btn-primary">
set up two-factor authentication
</button>
</form>
<?php endif; ?>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -96,5 +97,4 @@ document.getElementById('confirm_password').addEventListener('input', function()
} else { } else {
this.setCustomValidity(''); this.setCustomValidity('');
} }
}); });</script>
</script>

View File

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

View File

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

View File

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

View File

@ -1,54 +1,31 @@
<!-- login form --> <!-- login form -->
<div class="action-card"> <div class="card text-center w-50 mx-auto">
<div class="action-card-header"> <h2 class="card-header">Login</h2>
<p class="action-eyebrow">Welcome back</p> <div class="card-body">
<h2 class="action-title">Sign in</h2> <p class="card-text"><strong>Welcome to <?= htmlspecialchars($config['site_name']); ?>!</strong><br />Please enter login credentials:</p>
<p class="action-subtitle">Enter your credentials to continue to <?= htmlspecialchars($config['site_name']); ?></p> <form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login">
</div> <?php include 'csrf_token.php'; ?>
<div class="form-group mb-3">
<div class="action-card-body"> <input type="text" class="form-control w-50 mx-auto" name="username" placeholder="Username"
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=login" class="action-form" novalidate> pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
<?php include CSRF_TOKEN_INCLUDE; ?> required autofocus />
<div class="action-form-group">
<label for="username" class="action-form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" id="username" class="form-control action-form-control" name="username" placeholder="Username"
pattern="[A-Za-z0-9_\-]{3,20}" title="3-20 characters, letters, numbers, - and _"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
required />
</div>
</div> </div>
<div class="form-group mb-3">
<div class="action-form-group"> <input type="password" class="form-control w-50 mx-auto" name="password" placeholder="Password"
<label for="password" class="action-form-label">Password</label> pattern=".{5,}" title="Eight or more characters"
<div class="input-group"> required />
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" id="password" class="form-control action-form-control" name="password" placeholder="Password"
pattern=".{8,}" title="Eight or more characters"
required />
</div>
</div> </div>
<div class="form-group mb-3">
<div class="d-flex justify-content-between align-items-center mb-4"> <label for="remember_me">
<div class="form-check"> <input type="checkbox" id="remember_me" name="remember_me" />
<input type="checkbox" id="remember" name="remember_me" class="form-check-input" <?= isset($_POST['remember_me']) ? 'checked' : '' ?>> remember me
<label for="remember" class="form-check-label">Remember me</label> </label>
</div>
<a href="<?= htmlspecialchars($app_root) ?>?page=login&action=forgot" class="text-decoration-none">Forgot password?</a>
</div> </div>
<input type="submit" class="btn btn-primary" value="Login" />
<div class="action-actions">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-sign-in-alt me-2"></i>Sign in
</button>
</div>
<?php if (isset($_GET['redirect'])):
$loginRawRedirect = $_GET['redirect'];
?>
<input type="hidden" name="redirect" value="<?= htmlspecialchars($loginRawRedirect, ENT_QUOTES, 'UTF-8'); ?>">
<?php endif; ?>
</form> </form>
<div class="mt-3">
<a href="?page=login&action=forgot">forgot password?</a>
</div>
</div> </div>
</div> </div>
<!-- /login form --> <!-- /login form -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,76 +4,15 @@
</div> </div>
</div> </div>
<?php } ?> <?php } ?>
<?php
// Preparing the remaining session time debug message
$sessionDebugMarkup = '';
if (Session::getUsername()) {
$canSeeSessionDebug = false;
if (isset($userId, $userObject) && method_exists($userObject, 'hasRight')) {
$canSeeSessionDebug = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser');
}
if ($canSeeSessionDebug) {
Session::startSession();
$remember = !empty($_SESSION['REMEMBER_ME']);
$timeoutSeconds = $remember ? (30 * 24 * 60 * 60) : 7200;
$lastActivity = $_SESSION['LAST_ACTIVITY'] ?? null;
$remainingLabel = 'Session activity timestamp unavailable.';
$expiresAtLabel = 'unknown expiry';
if ($lastActivity !== null) {
$elapsed = time() - (int)$lastActivity;
$secondsRemaining = max(0, $timeoutSeconds - $elapsed);
$days = intdiv($secondsRemaining, 86400);
$hours = intdiv($secondsRemaining % 86400, 3600);
$minutes = intdiv($secondsRemaining % 3600, 60);
$seconds = $secondsRemaining % 60;
$parts = [];
if ($days > 0) {
$parts[] = $days . ' ' . ($days === 1 ? 'day' : 'days');
}
if ($hours > 0) {
$parts[] = $hours . ' ' . ($hours === 1 ? 'hour' : 'hours');
}
if ($minutes > 0) {
$parts[] = $minutes . ' ' . ($minutes === 1 ? 'minute' : 'minutes');
}
if ($seconds > 0 || empty($parts)) {
$parts[] = $seconds . ' ' . ($seconds === 1 ? 'second' : 'seconds');
}
$remainingLabel = implode(' ', $parts);
$expiresAtLabel = date('Y-m-d H:i:s', time() + $secondsRemaining);
}
ob_start();
?>
<span class="tm-session-debug">
<strong>Session debug:</strong>
<?= $remember ? 'Remember-me' : 'Standard' ?> session expires in <?= htmlspecialchars($remainingLabel) ?> (<?= htmlspecialchars($expiresAtLabel) ?>)
</span>
<?php
$sessionDebugMarkup = ob_get_clean();
}
}
?>
<!-- Footer --> <!-- Footer -->
<div id="footer"> <div id="footer">Jilo Web <?= htmlspecialchars($config['version']) ?> &copy;2024-<?= date('Y') ?> - web interface for <a href="https://lindeas.com/jilo">Jilo</a></div>
&laquo; <?= htmlspecialchars($config['site_name'] . (!empty($config['site_slogan']) ? ' - ' . ucfirst($config['site_slogan']) : '')) ?> &raquo;
v.<?= htmlspecialchars($config['version']) ?> &copy; 2024-<?= date('Y') ?> &mdash; web interface for <a href="https://lindeas.com/jilo">Jilo</a>
<?php if ($sessionDebugMarkup !== ''): ?>
<?= $sessionDebugMarkup ?>
<?php endif; ?>
</div>
<!-- /Footer --> <!-- /Footer -->
<?php if (Session::getUsername() && $page !== 'logout') { ?> </div>
<?php if (isset($currentUser) && $page !== 'logout') { ?>
<script src="static/js/sidebar.js"></script> <script src="static/js/sidebar.js"></script>
<?php } ?> <?php } ?>
@ -103,5 +42,6 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
</script> </script>
</body> </body>
</html> </html>

View File

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

View File

@ -1,184 +1,68 @@
<?php
$navMainDotsPayload = \App\Core\HookDispatcher::applyFilters('nav.main.dot_indicators', [
'dots' => [],
'app_root' => $app_root,
'user_id' => $userId ?? 0,
'db' => $db ?? null,
]);
$navMainDots = [];
if (is_array($navMainDotsPayload)) {
$navMainDots = $navMainDotsPayload['dots'] ?? (is_array($navMainDotsPayload) ? $navMainDotsPayload : []);
}
$navMainHasDot = false; <div class="container-fluid">
if (!empty($navMainDots) && is_array($navMainDots)) {
$navMainHasDot = (bool)array_filter($navMainDots, static function($value) {
return (bool)$value;
});
}
$navSettingsDotsPayload = \App\Core\HookDispatcher::applyFilters('nav.settings.dot_indicators', [ <!-- Menu -->
'dots' => [],
'app_root' => $app_root,
'user_id' => $userId ?? 0,
'db' => $db ?? null,
]);
$navSettingsDots = [];
if (is_array($navSettingsDotsPayload)) {
$navSettingsDots = $navSettingsDotsPayload['dots'] ?? (is_array($navSettingsDotsPayload) ? $navSettingsDotsPayload : []);
}
$navAccountDotsPayload = \App\Core\HookDispatcher::applyFilters('nav.account.dot_indicators', [
'dots' => [],
'app_root' => $app_root,
'user_id' => $userId ?? 0,
'db' => $db ?? null,
]);
$navAccountDots = [];
if (is_array($navAccountDotsPayload)) {
$navAccountDots = $navAccountDotsPayload['dots'] ?? (is_array($navAccountDotsPayload) ? $navAccountDotsPayload : []);
}
$navAccountHasDot = false;
if (!empty($navAccountDots) && is_array($navAccountDots)) {
$navAccountHasDot = (bool)array_filter($navAccountDots, static function($value) {
return (bool)$value;
});
}
?>
<div class="container-fluid p-0">
<!-- Modern Menu -->
<div class="menu-container"> <div class="menu-container">
<div class="modern-header-content"> <ul class="menu-left">
<div class="logo-section"> <div class="container">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="modern-logo-link"> <div class="row">
<div class="modern-logo"> <a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>" class="logo-link">
<img src="<?= htmlspecialchars($app_root) ?>static/logo.png" alt="<?= htmlspecialchars($config['site_name']); ?>"/> <div class="col-4">
</div> <img class="logo" src="<?= htmlspecialchars($app_root) ?>static/jilo-logo.png" alt="JILO"/>
<div class="brand-info"> </div>
<h1 class="brand-name"><?= htmlspecialchars($config['site_name']); ?></h1> </a>
<?php if (!empty($config['site_slogan'])): ?> </div>
<div class="brand-slogan"><?= htmlspecialchars($config['site_slogan']); ?></div>
<?php endif; ?>
</div>
</a>
</div> </div>
<?php if (Session::isValidSession()) { ?> <li class="font-weight-light text-uppercase" style="font-size: 0.5em; color: whitesmoke; margin-right: 70px; align-content: center;">
version&nbsp;<?= htmlspecialchars($config['version']) ?>
</li>
<?php if (isset($_SESSION['username']) && isset($_SESSION['user_id'])) { ?>
<?php foreach ($platformsAll as $platform) { <?php foreach ($platformsAll as $platform) {
$platform_switch_url = switchPlatform($platform['id']); $platform_switch_url = switchPlatform($platform['id']);
?> ?>
<div> <li style="margin-right: 3px;">
<?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?> <?php if ((isset($_REQUEST['platform']) || empty($_SERVER['QUERY_STRING'])) && $platform['id'] == $platform_id) { ?>
Jitsi platforms: <span style="background-color: #fff; border: 1px solid #111; color: #111; border-bottom-color: #fff; padding-bottom: 12px;">
<button class="btn modern-header-btn" type="button" aria-expanded="false">
<?= htmlspecialchars($platform['name']) ?> <?= htmlspecialchars($platform['name']) ?>
</button> </span>
<?php } else { ?> <?php } else { ?>
<a href="<?= htmlspecialchars($platform_switch_url) ?>"> <a href="<?= htmlspecialchars($platform_switch_url) ?>">
<?= htmlspecialchars($platform['name']) ?> <?= htmlspecialchars($platform['name']) ?>
</a> </a>
<?php } ?> <?php } ?>
</div> </li>
<?php } ?> <?php } ?>
<?php } ?> <?php } ?>
</ul>
<div class="header-actions"> <ul class="menu-right">
<?php if (Session::isValidSession()) { ?> <?php if (isset($_SESSION['username']) && isset($_SESSION['user_id'])) { ?>
<div class="dropdown"> <li class="dropdown">
<button class="btn modern-header-btn dropdown-toggle position-relative" type="button" data-toggle="dropdown" aria-expanded="false"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
<i class="fas fa-user-circle me-2"></i><?= htmlspecialchars($currentUser) ?> <i class="fas fa-user"></i>
<?php if ($navAccountHasDot): ?> </a>
<span class="modern-notification-dot" aria-hidden="true"></span> <div class="dropdown-menu dropdown-menu-right">
<?php endif; ?> <h6 class="dropdown-header"><?= htmlspecialchars($currentUser) ?></h6>
</button> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile">
<div class="dropdown-menu dropdown-menu-right modern-dropdown"> <i class="fas fa-id-card"></i>Profile details
<h6 class="dropdown-header modern-dropdown-header"><?= htmlspecialchars($currentUser) ?></h6> </a>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=theme"> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
<i class="fas fa-paint-brush"></i>Change theme <i class="fas fa-shield-alt"></i>Login credentials
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=profile"> <a class="dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
<i class="fas fa-id-card"></i>Profile details <i class="fas fa-sign-out-alt"></i>Logout
</a> </a>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=credentials">
<i class="fas fa-shield-alt"></i>Login credentials
</a>
<?php do_hook('account_menu', [
'app_root' => $app_root,
'dots' => $navAccountDots,
'user_id' => $userId ?? 0,
]); ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=logout">
<i class="fas fa-sign-out-alt"></i>Logout
</a>
</div>
</div> </div>
<div class="dropdown"> </li>
<button class="btn modern-header-btn dropdown-toggle position-relative" type="button" data-toggle="dropdown" aria-expanded="false">
<i class="fas fa-cog"></i>
<?php if ($navMainHasDot): ?>
<span class="modern-notification-dot" aria-hidden="true"></span>
<?php endif; ?>
</button>
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header modern-dropdown-header">settings</h6>
<?php if ($userObject->hasRight($userId, 'superuser')) {?>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=admin">
<span class="tm-nav-link-label">
<i class="fas fa-toolbox"></i>Admin
<?php if (!empty($navSettingsDots['admin'])): ?>
<span class="tm-nav-dot" aria-hidden="true"></span>
<?php endif; ?>
</span>
</a>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file')) {?>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=config">
<i class="fas fa-wrench"></i>Configuration
</a>
<?php } ?>
<?php if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file') ||
$userObject->hasRight($userId, 'edit config file') ||
$userObject->hasRight($userId, 'edit whitelist') ||
$userObject->hasRight($userId, 'edit blacklist') ||
$userObject->hasRight($userId, 'edit ratelimiting')) { ?>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=security">
<i class="fas fa-shield-alt"></i>Security
</a>
<?php do_hook('main_menu', [
'app_root' => $app_root,
'section' => 'main',
'position' => 100,
'dots' => $navMainDots,
]); ?>
</div>
</div>
<?php } ?>
<?php } else { ?> <?php } else { ?>
<button class="btn modern-header-btn" onclick="window.location.href='<?= htmlspecialchars($app_root) ?>?page=login'"> <li><a href="<?= htmlspecialchars($app_root) ?>?page=login">login</a></li>
<i class="fas fa-sign-in-alt me-2"></i>Login <li><a href="<?= htmlspecialchars($app_root) ?>?page=register">register</a></li>
</button>
<?php do_hook('main_public_menu', ['app_root' => $app_root, 'section' => 'main', 'position' => 100]); ?>
<?php } ?> <?php } ?>
</ul>
<div class="dropdown">
<button class="btn modern-header-btn dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
<i class="fas fa-info-circle"></i>
</button>
<div class="dropdown-menu dropdown-menu-right modern-dropdown">
<h6 class="dropdown-header modern-dropdown-header">resources</h6>
<a class="dropdown-item modern-dropdown-item" href="<?= htmlspecialchars($app_root) ?>?page=help">
<i class="fas fa-question-circle"></i>Help
</a>
</div>
</div>
</div>
</div>
</div> </div>
<!-- /Modern Menu --> <!-- /Menu -->

View File

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

View File

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

View File

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

View File

@ -1,139 +1,87 @@
<?php <!-- user profile -->
$user = $userDetails[0] ?? []; <div class="card text-center w-50 mx-auto">
$username = $user['username'] ?? '';
$name = $user['name'] ?? '';
$email = $user['email'] ?? '';
$timezoneName = $user['timezone'] ?? '';
$timezoneOffset = $timezoneName ? getUTCOffset($timezoneName) : '';
$bio = trim($user['bio'] ?? '');
$rightsNames = array_map(function ($right) {
return trim($right['right_name'] ?? '');
}, $userRights);
$rightsNames = array_filter($rightsNames, function ($label) {
return $label !== '';
});
$rightsCount = count($rightsNames);
$displayName = $name ?: $username ?: 'User profile';
$profileUserIdForHooks = (int)($user['user_id'] ?? ($userId ?? 0));
$timezoneDisplay = '';
if ($timezoneName) {
if ($timezoneOffset !== '') {
$offsetLabel = stripos($timezoneOffset, 'UTC') === 0 ? $timezoneOffset : 'UTC' . $timezoneOffset;
$timezoneDisplay = sprintf('%s (%s)', $timezoneName, $offsetLabel);
} else {
$timezoneDisplay = $timezoneName;
}
}
?> <p class="h4 card-header">Profile of <?= htmlspecialchars($userDetails[0]['username']) ?></p>
<div class="card-body">
<section class="tm-directory tm-profile-view"> <div class="row">
<div class="tm-hero-card tm-hero-card--stacked tm-profile-hero">
<div class="tm-profile-hero-main"> <div class="col-md-4 avatar-container">
<div class="tm-profile-avatar-frame"> <div>
<img src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="Avatar of <?= htmlspecialchars($displayName) ?>" /> <img class="avatar-img" src="<?= htmlspecialchars($app_root) . htmlspecialchars($avatar) ?>" alt="avatar" />
</div>
<div class="tm-profile-hero-body">
<h1 class="tm-profile-title"><?= htmlspecialchars($displayName) ?></h1>
<p class="tm-profile-subtitle">Personal details and access summary for your <?= htmlspecialchars($config['site_name']); ?> account.</p>
<div class="tm-profile-hero-meta">
<?php if ($username): ?>
<span class="tm-hero-pill pill-neutral">
<i class="fas fa-user"></i>
@<?= htmlspecialchars($username) ?>
</span>
<?php endif; ?>
<?php if ($timezoneDisplay): ?>
<span class="tm-hero-pill pill-primary">
<i class="fas fa-clock"></i>
<?= htmlspecialchars($timezoneDisplay) ?>
</span>
<?php endif; ?>
<span class="tm-hero-pill pill-accent">
<i class="fas fa-shield-alt"></i>
<?= $rightsCount ?> <?= $rightsCount === 1 ? 'Right' : 'Rights' ?>
</span>
</div> </div>
</div> </div>
<div class="tm-profile-hero-actions">
<a class="btn btn-primary" href="<?= htmlspecialchars($app_root) ?>?page=profile&amp;action=edit"> <div class="col-md-8">
<i class="fas fa-edit"></i> Edit profile
</a> <!--div class="row mb-3">
<?php <div class="col-md-4 text-end">
// Allow plugins to append additional hero buttons for the currently viewed account. <label class="form-label"><small>username:</small></label>
do_hook('profile.hero_actions', [ </div>
'app_root' => $app_root, <div class="col-md-8 text-start bg-light">
'user' => $user, <?= htmlspecialchars($userDetails[0]['username']) ?>
'profile_user_id' => $profileUserIdForHooks, </div>
'session_user_id' => class_exists('Session') && Session::isValidSession() ? (int)Session::getUserId() : 0, </div-->
]);
?> <div class="row mb-3">
<div class="col-md-4 text-end">
<label class="form-label"><small>name:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<?= htmlspecialchars($userDetails[0]['name'] ?? '') ?>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label class="form-label"><small>email:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<?= htmlspecialchars($userDetails[0]['email'] ?? '') ?>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label class="form-label"><small>timezone:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<?php if (!empty($userDetails[0]['timezone'])) { ?>
<?= htmlspecialchars($userDetails[0]['timezone']) ?>&nbsp;&nbsp;<span style="font-size: 0.66em;">(<?= htmlspecialchars(getUTCOffset($userDetails[0]['timezone'])) ?>)</span>
<?php } ?>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label class="form-label"><small>bio:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<textarea class="scroll-box" rows="10" readonly><?= htmlspecialchars($userDetails[0]['bio'] ?? '') ?></textarea>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label class="form-label"><small>rights:</small></label>
</div>
<div class="col-md-8 text-start bg-light">
<?php foreach ($userRights as $right) { ?>
<?= htmlspecialchars($right['right_name'] ?? '') ?>
<br />
<?php } ?>
</div>
</div>
</div> </div>
<p>
<a href="<?= htmlspecialchars($app_root) ?>?page=profile&action=edit" class="btn btn-primary">Edit</a>
</p>
</div> </div>
</div> </div>
</div>
<div class="tm-profile-panels"> <!-- /user profile -->
<article class="tm-profile-panel">
<header>
<h3>Account details</h3>
</header>
<dl class="tm-profile-detail-list">
<div class="tm-profile-detail-item">
<dt>Full name</dt>
<dd><?= $name ? htmlspecialchars($name) : '<span class="tm-profile-placeholder">Not provided</span>' ?></dd>
</div>
<div class="tm-profile-detail-item">
<dt>Email</dt>
<dd><?= $email ? htmlspecialchars($email) : '<span class="tm-profile-placeholder">Not provided</span>' ?></dd>
</div>
<div class="tm-profile-detail-item">
<dt>Username</dt>
<dd><?= $username ? htmlspecialchars($username) : '<span class="tm-profile-placeholder">Not provided</span>' ?></dd>
</div>
<div class="tm-profile-detail-item">
<dt>Timezone</dt>
<dd><?= $timezoneDisplay ? htmlspecialchars($timezoneDisplay) : '<span class="tm-profile-placeholder">Not set</span>' ?></dd>
</div>
</dl>
</article>
<article class="tm-profile-panel">
<header>
<h3>Bio</h3>
</header>
<?php if ($bio !== ''): ?>
<p class="tm-profile-bio"><?= nl2br(htmlspecialchars($bio)) ?></p>
<?php else: ?>
<p class="tm-profile-placeholder">This user hasnt added a bio yet.</p>
<?php endif; ?>
</article>
<article class="tm-profile-panel">
<header>
<h3>User rights</h3>
</header>
<?php if ($rightsCount): ?>
<ul class="tm-profile-rights">
<?php foreach ($rightsNames as $rightLabel): ?>
<li>
<i class="fas fa-check"></i>
<?= htmlspecialchars($rightLabel) ?>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="tm-profile-placeholder">No rights assigned yet.</p>
<?php endif; ?>
</article>
<?php
// Surface extra panels contributed by plugins.
do_hook('profile.additional_panels', [
'subscription' => $subscription ?? null,
'app_root' => $app_root,
'userId' => $profileUserIdForHooks,
'profile_user_id' => $profileUserIdForHooks,
]); ?>
</div>
</section>

View File

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

View File

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

View File

@ -1,137 +0,0 @@
<?php
/**
* Theme switcher template
*
* Displays available themes and allows the user to switch between them.
*
* @var array $themes List of available themes with their data
* - name: Display name
* - screenshotUrl: URL to the screenshot (or null if not available)
* - isActive: Whether this is the current theme
*/
?>
<?php
$activeThemeName = 'Default';
foreach ($themes as $themeData) {
if (!empty($themeData['isActive'])) {
$activeThemeName = $themeData['name'];
break;
}
}
$userTimezone = \App\App::get('user_timezone') ?: 'UTC';
$totalThemes = count($themes);
?>
<section class="tm-directory tm-theme-directory">
<div class="tm-hero-card tm-hero-card--stacked">
<div class="tm-hero-head">
<div class="tm-hero-body">
<div class="tm-hero-heading">
<h1 class="tm-hero-title">Themes</h1>
<p class="tm-hero-subtitle">Personalize <?= htmlspecialchars($config['site_name']); ?> with custom visual styles.</p>
</div>
<div class="tm-hero-meta">
<span class="tm-hero-pill pill-neutral">
<i class="fas fa-layer-group"></i>
<?= $totalThemes ?> available
</span>
<span class="tm-hero-pill pill-primary">
<i class="fas fa-check-circle"></i>
Active: <?= htmlspecialchars($activeThemeName) ?>
</span>
</div>
</div>
</div>
</div>
<div class="tm-theme-gallery">
<div class="tm-theme-grid">
<?php foreach ($themes as $themeId => $theme):
$isActive = !empty($theme['isActive']);
$screenshot = $theme['screenshotUrl'];
?>
<article class="tm-theme-card<?= $isActive ? ' is-active' : '' ?>">
<div class="tm-theme-preview" style="<?= $screenshot ? 'background-image: url(' . htmlspecialchars($screenshot) . ')' : '' ?>">
<?php if (!$screenshot): ?>
<span>No preview available</span>
<?php endif; ?>
</div>
<div class="tm-theme-body">
<div class="tm-theme-heading">
<p class="tm-theme-id">ID: <code><?= htmlspecialchars($themeId) ?></code></p>
<h3 class="tm-theme-name"><?= htmlspecialchars($theme['name']) ?></h3>
</div>
<?php if (!empty($theme['description'])): ?>
<p class="tm-theme-description">
<?= htmlspecialchars($theme['description']) ?>
</p>
<?php endif; ?>
<dl class="tm-theme-meta">
<?php if (!empty($theme['version'])): ?>
<div class="tm-theme-meta-item">
<dt>Version</dt>
<dd><?= htmlspecialchars($theme['version']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($theme['author'])): ?>
<div class="tm-theme-meta-item">
<dt>Author</dt>
<dd><?= htmlspecialchars($theme['author']) ?></dd>
</div>
<?php endif; ?>
</dl>
<?php if (!empty($theme['tags'])): ?>
<ul class="tm-theme-tags">
<?php foreach ($theme['tags'] as $tag): $tagLabel = trim((string)$tag); if ($tagLabel === '') { continue; } ?>
<li><?= htmlspecialchars($tagLabel) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<dl class="tm-theme-stats">
<?php if (!empty($theme['type'])): ?>
<div class="tm-theme-stat">
<dt>Type</dt>
<dd><?= htmlspecialchars($theme['type']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($theme['file_count'])): ?>
<div class="tm-theme-stat">
<dt>Files</dt>
<dd><?= number_format((int)$theme['file_count']) ?></dd>
</div>
<?php endif; ?>
<?php if (!empty($theme['path'])): ?>
<div class="tm-theme-stat">
<dt>Location</dt>
<dd><code><?= htmlspecialchars($theme['path']) ?></code></dd>
</div>
<?php endif; ?>
<?php if (!empty($theme['last_modified'])):
$lastEditedRaw = is_numeric($theme['last_modified'])
? gmdate('Y-m-d H:i:s', (int)$theme['last_modified'])
: $theme['last_modified'];
$lastEdited = app_format_local_datetime($lastEditedRaw, 'M j, Y', $userTimezone) ?: $lastEditedRaw;
?>
<div class="tm-theme-stat">
<dt>Last edit</dt>
<dd><?= htmlspecialchars($lastEdited) ?></dd>
</div>
<?php endif; ?>
</dl>
<div class="tm-theme-actions">
<?php if ($isActive): ?>
<button class="btn btn-outline-secondary" disabled>
<i class="fas fa-check"></i> Active
</button>
<?php else: ?>
<a class="btn btn-primary" href="?page=theme&amp;switch_to=<?= urlencode($themeId) ?>&amp;csrf_token=<?= $csrf_token ?>">
<i class="fas fa-paint-brush"></i> Apply
</a>
<?php endif; ?>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
</div>
</section>

View File

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

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