Adds Log plugin

main
Yasen Pramatarov 2025-04-25 10:02:49 +03:00
parent fe91a91081
commit 761c27c0d3
7 changed files with 481 additions and 0 deletions

View File

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

View File

@ -0,0 +1,31 @@
<?php
// Logger plugin bootstrap
register_hook('logger.system_init', function(array $context) {
// Load plugin-specific LoggerFactory class
require_once __DIR__ . '/models/LoggerFactory.php';
[$logger, $userIP] = LoggerFactory::create($context['db']);
// Expose to globals for routing logic
$GLOBALS['logObject'] = $logger;
$GLOBALS['user_IP'] = $userIP;
});
// Add to allowed URLs
register_hook('filter_allowed_urls', function($urls) {
$urls[] = 'logs';
return $urls;
});
// Configuration for top menu injection
define('LOGS_MAIN_MENU_SECTION', 'main'); // section of the top menu
define('LOGS_MAIN_MENU_POSITION', 20); // lower = earlier in menu
register_hook('main_menu', function($ctx) {
$section = defined('LOGS_MAIN_MENU_SECTION') ? LOGS_MAIN_MENU_SECTION : 'main';
$position = defined('LOGS_MAIN_MENU_POSITION') ? LOGS_MAIN_MENU_POSITION : 100;
// We use $section/$position for sorting/insertion logic in the menu template
echo '
<a class="dropdown-item" href="?page=logs">
<i class="fas fa-list"></i>Logs
</a>';
});

View File

@ -0,0 +1,117 @@
<?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.
*/
// Define plugin base path if not already defined
if (!defined('PLUGIN_LOGS_PATH')) {
define('PLUGIN_LOGS_PATH', dirname(__FILE__, 2) . '/');
}
require_once PLUGIN_LOGS_PATH . 'models/Log.php';
require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php';
require_once dirname(__FILE__, 4) . '/app/classes/user.php';
// Check for rights; user or system
$has_system_access = ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, '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($userId, $scope, $offset, $items_per_page, $filters);
$search_all = $logObject->readLog($userId, $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($userId)[0]['username'];
// Get any new feedback messages
include dirname(__FILE__, 4) . '/app/helpers/feedback.php';
// Display messages list
include PLUGIN_LOGS_PATH . 'views/logs.php';

View File

@ -0,0 +1,122 @@
<?php
/**
* class Log
*
* Handles logging events into a database and reading log entries.
*/
class Log {
/**
* @var PDO|null $db The database connection instance.
*/
private $db;
/**
* Logs constructor.
* Initializes the database connection.
*
* @param object $database The database object to initialize the connection.
*/
public function __construct($database) {
$this->db = $database->getConnection();
}
/**
* Insert a log event into the database.
*
* @param int $userId 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($userId, $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' => $userId,
':scope' => $scope,
':message' => $message,
]);
return true;
} catch (Exception $e) {
return $e->getMessage();
}
}
/**
* Retrieve log entries from the database.
*
* @param int $userId The ID of the user whose logs are being retrieved.
* @param string $scope The scope of the logs ('user' or 'system').
* @param int $offset The offset for pagination. Default is 0.
* @param int $items_per_page The number of log entries to retrieve per page. Default is no limit.
* @param array $filters Optional array of filters (from_time, until_time, message, id)
*
* @return array An array of log entries.
*/
public function readLog($userId, $scope, $offset = 0, $items_per_page = '', $filters = []) {
$params = [];
$where_clauses = [];
// Base query with user join
$base_sql = 'SELECT l.*, u.username
FROM 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'] = $userId;
}
// Add time range filters if specified
if (!empty($filters['from_time'])) {
$where_clauses[] = 'l.time >= :from_time';
$params[':from_time'] = $filters['from_time'] . ' 00:00:00';
}
if (!empty($filters['until_time'])) {
$where_clauses[] = 'l.time <= :until_time';
$params[':until_time'] = $filters['until_time'] . ' 23:59:59';
}
// Add message search if specified
if (!empty($filters['message'])) {
$where_clauses[] = 'l.message LIKE :message';
$params[':message'] = '%' . $filters['message'] . '%';
}
// Add user ID search if specified
if (!empty($filters['id'])) {
$where_clauses[] = 'l.user_id = :search_user_id';
$params[':search_user_id'] = $filters['id'];
}
// Combine WHERE clauses
$sql = $base_sql;
if (!empty($where_clauses)) {
$sql .= ' WHERE ' . implode(' AND ', $where_clauses);
}
// Add ordering
$sql .= ' ORDER BY l.time DESC';
// Add pagination
if ($items_per_page) {
$items_per_page = (int)$items_per_page;
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
}
$query = $this->db->prepare($sql);
$query->execute($params);
return $query->fetchAll(PDO::FETCH_ASSOC);
}
}

View File

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

View File

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

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 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&nbsp;(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><?= $row['userID'] ? '<strong>' . htmlspecialchars($row['username'] . " ({$row['userID']})") . '</strong>' : '<span class="text-muted font-weight-normal small">SYSTEM</span>' ?></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 -->