Migrates logs plugin to App API, updates docs

main
Yasen Pramatarov 2026-01-20 10:06:39 +02:00
parent 58c2651796
commit eb4b5ca7bc
4 changed files with 321 additions and 137 deletions

View File

@ -1,13 +1,38 @@
# Logger plugin # Logger plugin
## Overview ## Overview
The Logger plugin provides a modular, pluggable logging system for the application. The Logger plugin (located in `plugins/logs/`) provides a modular, pluggable logging
It logs user and system events to a MySQL table named `log`. system for the application. It records both user and system events in the `log`
table and exposes retrieval utilities plus a built-in UI at `?page=logs`.
The plugin uses the callable dispatcher pattern with `PluginRouteRegistry` for routing
and follows the App API pattern for service access.
## Features
1. **Log entry management**
- PSR-3-style `log()` method with level + context payloads
- Core helper `app_log()` for simplified access with NullLogger fallback
2. **Filtering & pagination**
- Query by scope, user, time range, message text, or specific user IDs
- Pagination-ready result sets with newest-first sorting
3. **User awareness**
- Stores username via joins for auditing
- Captures current user IP via plugin bootstrap
4. **Auto-migration**
- `logs_ensure_tables()` function creates the `log` table on demand
- Called automatically via `logger.system_init` hook
5. **UI integration**
- Adds a "Logs" entry to the top menu
- Provides list/detail views with tabs for user vs system scopes
- Uses callable dispatcher for route handling
## Installation ## Installation
1. Copy the entire `logger` folder into your project's `plugins/` directory. 1. Copy the `logs` folder into the project's `plugins/` directory.
2. Ensure `"enabled": true` in `plugins/logger/plugin.json`. 2. Enable the plugin via the admin plugin management interface (stored in `settings` table).
3. On first initialization, the plugin will create the `log` table if it does not already exist. 3. The plugin bootstrap automatically:
- Registers the `logs` route prefix with a callable dispatcher
- Sets up the `logs_ensure_tables()` migration function
- Initializes the logger via the `logger.system_init` hook
## Database Schema ## Database Schema
The plugin defines the following table (auto-created): The plugin defines the following table (auto-created):
@ -24,39 +49,120 @@ CREATE TABLE IF NOT EXISTS `log` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
``` ```
## Hook API ## Routing & Dispatcher
Core must call: The plugin registers its route using `PluginRouteRegistry`:
```php ```php
// After DB connect: register_plugin_route_prefix('logs', [
do_hook('logger.system_init', ['db' => $db]); 'dispatcher' => function($action, array $context = []) {
``` require_once PLUGIN_LOGS_PATH . 'controllers/logs.php';
The plugin listens on `logger.system_init`, runs auto-migration, then sets: if (function_exists('logs_plugin_handle')) {
```php return logs_plugin_handle($action, $context);
$GLOBALS['logObject']; // instance of Log }
$GLOBALS['user_IP']; // current user IP return false;
},
'access' => 'private',
'defaults' => ['action' => 'list'],
'plugin' => 'logs',
]);
``` ```
Then in the code use: ## Hook + Loader API
Core must fire the initialization hook after the database connection is ready:
```php ```php
$logObject->insertLog($userId, 'Your message', 'user'); do_hook('logger.system_init', ['db' => $db]);
$data = $logObject->readLog($userId, 'user', $offset, $limit, $filters);
``` ```
The plugin listener:
- calls `logs_ensure_tables()` to create the `log` table if needed
- resolves the current user IP
- exposes `$GLOBALS['logObject']` (`Log` instance) and `$GLOBALS['user_IP']`
When `$logObject` is not available, use `app_log($level, $message, $context)` which falls back to `NullLogger`.
## PHP API
`Log` lives in `plugins/logs/models/Log.php` and receives the database connector.
### Methods
```php
Log::log(string $level, string $message, array $context = []): void
Log::readLog(int $userId, string $scope, int $offset = 0, int $itemsPerPage = 0, array $filters = []): array
```
### Supported log levels
`emergency`, `alert`, `critical`, `error`, `warning`, `notice`, `info`, `debug`
### Supported filters
- `from_time`: `YYYY-MM-DD` lower bound (inclusive)
- `until_time`: `YYYY-MM-DD` upper bound (inclusive)
- `message`: substring match across message text
- `id`: explicit user ID (system scope only)
### Typical usage
```php
app_log('info', 'User updated profile', [
'user_id' => $userId,
'scope' => 'user',
]);
$entries = $logObject->readLog(
$userId,
$scope,
$offset,
$itemsPerPage,
['message' => 'profile']
);
```
## Usage guidelines
1. **When to log**
- User actions, authentication events, configuration changes
- System events, background job outcomes, and security anomalies
2. **Message hygiene**
- Keep messages concise, include essential metadata, avoid sensitive data
3. **Data integrity**
- Validate user input before logging to avoid malformed queries
- Wrap bulk insertions in transactions when necessary
4. **Performance**
- Prefer pagination for large result sets
- Index columns used by custom filters if extending the schema
5. **Retention**
- Schedule archival/log rotation via cron if the table grows quickly
## File Structure ## File Structure
``` ```
plugins/logger/ plugins/logs/
├─ bootstrap.php # registers hook ├─ bootstrap.php # registers route, migration function, hooks & menu
├─ plugin.json # metadata & enabled flag ├─ plugin.json # plugin metadata
├─ README.md # this documentation ├─ README.md # this documentation
├─ controllers/
│ └─ logs.php # procedural handler functions for callable dispatcher
├─ models/ ├─ models/
│ ├─ Log.php # main Log class │ ├─ Log.php # main Log class
│ └─ LoggerFactory.php# migration + factory │ └─ LoggerFactory.php # migration + factory
├─ helpers/ ├─ helpers/
│ └─ logs.php # user IP helper │ ├─ logs_view_helper.php
├─ helpers.php # plugin helper wrapper
└─ migrations/ └─ migrations/
└─ create_log_table.sql └─ create_log_table.sql
``` ```
## Controller Architecture
The controller uses procedural functions instead of classes:
- `logs_plugin_handle($action, $context)` - main dispatcher function
- `logs_plugin_render_list($logObject, $db, $userId, $validSession, $app_root)` - renders log list with filters and pagination
The callable dispatcher pattern provides:
- Clean separation of concerns
- Access to request context (user_id, db, app_root, valid_session)
- Consistent error handling and layout rendering
## Admin Plugin Check
The plugin provides `logs_ensure_tables()` for the admin plugin management interface:
- **Owned tables:** `log` (will be removed on purge)
- **Referenced tables:** `user` (dependency, not removed)
## Uninstall / Disable ## Uninstall / Disable
- Set `"enabled": false` in `plugin.json` or delete the `plugins/logger/` folder. To disable the plugin:
- Core code will default to `NullLogger` and no logs will be written. - Use the admin plugin management interface to disable it (updates the `settings` table), or
- Delete the `plugins/logs/` folder entirely
When disabled, the `app_log()` helper automatically falls back to `NullLogger`, so logging calls remain safe and won't cause errors. To remove plugin data, use the admin plugin management interface to purge the `log` table.

View File

@ -1,20 +1,63 @@
<?php <?php
// Logs plugin bootstrap /**
* Logs Plugin Bootstrap
*
* Initializes the logs plugin using the App API pattern.
*/
if (!defined('PLUGIN_LOGS_PATH')) { if (!defined('PLUGIN_LOGS_PATH')) {
define('PLUGIN_LOGS_PATH', __DIR__ . '/'); define('PLUGIN_LOGS_PATH', __DIR__ . '/');
} }
// We add the plugin helpers wrapper // Load plugin helpers
require_once PLUGIN_LOGS_PATH . 'helpers.php'; require_once PLUGIN_LOGS_PATH . 'helpers.php';
// List here all the controllers in "/controllers/" that we need as pages // Register route with callable dispatcher
$GLOBALS['plugin_controllers']['logs'] = [ register_plugin_route_prefix('logs', [
'logs' 'dispatcher' => function($action, array $context = []) {
]; require_once PLUGIN_LOGS_PATH . 'controllers/logs.php';
if (function_exists('logs_plugin_handle')) {
return logs_plugin_handle($action, $context);
}
return false;
},
'access' => 'private',
'defaults' => ['action' => 'list'],
'plugin' => 'logs',
]);
// Migration function for admin plugin check
if (!function_exists('logs_ensure_tables')) {
function logs_ensure_tables(): void {
static $ensured = false;
if ($ensured) {
return;
}
$db = \App\App::db();
if (!$db || !method_exists($db, 'getConnection')) {
return;
}
$pdo = $db->getConnection();
if (!$pdo instanceof \PDO) {
return;
}
$migrationFile = __DIR__ . '/migrations/create_log_table.sql';
if (is_readable($migrationFile)) {
$sql = file_get_contents($migrationFile);
if ($sql !== false && trim($sql) !== '') {
$pdo->exec($sql);
}
}
$ensured = true;
}
}
// Logger plugin bootstrap // Logger plugin bootstrap
register_hook('logger.system_init', function(array $context) { register_hook('logger.system_init', function(array $context) {
// Ensure tables exist
logs_ensure_tables();
// Load plugin-specific LoggerFactory class // Load plugin-specific LoggerFactory class
require_once __DIR__ . '/models/LoggerFactory.php'; require_once __DIR__ . '/models/LoggerFactory.php';
[$logger, $userIP] = LoggerFactory::create($context['db']); [$logger, $userIP] = LoggerFactory::create($context['db']);

View File

@ -1,122 +1,157 @@
<?php <?php
/** /**
* Logs listings * Logs Plugin Controller
* *
* This page ("logs") retrieves and displays logs within a time range * Procedural handler used by the callable dispatcher of the logs plugin.
* 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/Log.php';
require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php'; require_once PLUGIN_LOGS_PATH . 'models/LoggerFactory.php';
require_once dirname(__FILE__, 4) . '/app/classes/user.php'; require_once APP_PATH . 'classes/user.php';
require_once APP_PATH . 'helpers/theme.php';
// Check for rights; user or system function logs_plugin_handle(string $action, array $context = []): bool {
$has_system_access = ($userObject->hasRight($userId, 'superuser') || $validSession = (bool)($context['valid_session'] ?? false);
$app_root = $context['app_root'] ?? (\App\App::get('app_root') ?? '/');
$db = $context['db'] ?? \App\App::db();
$userId = $context['user_id'] ?? null;
if (!$db || !$userId) {
\Feedback::flash('ERROR', 'DEFAULT', 'Logs service unavailable.');
header('Location: ' . $app_root);
exit;
}
// Get logger instance from globals (set by logger.system_init hook)
$logObject = $GLOBALS['logObject'] ?? null;
if (!$logObject) {
\Feedback::flash('ERROR', 'DEFAULT', 'Logger not initialized.');
header('Location: ' . $app_root);
exit;
}
switch ($action) {
case 'list':
default:
logs_plugin_render_list($logObject, $db, $userId, $validSession, $app_root);
return true;
}
}
function logs_plugin_render_list($logObject, $db, int $userId, bool $validSession, string $app_root): void {
// Load User class for permissions check
$userObject = new \User($db);
// Check for rights; user or system
$has_system_access = ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view app logs')); $userObject->hasRight($userId, 'view app logs'));
// Get current page for pagination // Get current page for pagination
$currentPage = $_REQUEST['page_num'] ?? 1; $currentPage = $_REQUEST['page_num'] ?? 1;
$currentPage = (int)$currentPage; $currentPage = (int)$currentPage;
// Get selected tab // Get selected tab
$selected_tab = $_REQUEST['tab'] ?? 'user'; $selected_tab = $_REQUEST['tab'] ?? 'user';
if ($selected_tab === 'system' && !$has_system_access) { if ($selected_tab === 'system' && !$has_system_access) {
$selected_tab = 'user'; $selected_tab = 'user';
} }
// Set scope based on selected tab // Set scope based on selected tab
$scope = ($selected_tab === 'system') ? 'system' : 'user'; $scope = ($selected_tab === 'system') ? 'system' : 'user';
// specify time range // Specify time range
include '../app/helpers/time_range.php'; include APP_PATH . 'helpers/time_range.php';
// Prepare search filters // Prepare search filters
$filters = []; $filters = [];
if (isset($_REQUEST['from_time']) && !empty($_REQUEST['from_time'])) { if (isset($_REQUEST['from_time']) && !empty($_REQUEST['from_time'])) {
$filters['from_time'] = $_REQUEST['from_time']; $filters['from_time'] = $_REQUEST['from_time'];
} }
if (isset($_REQUEST['until_time']) && !empty($_REQUEST['until_time'])) { if (isset($_REQUEST['until_time']) && !empty($_REQUEST['until_time'])) {
$filters['until_time'] = $_REQUEST['until_time']; $filters['until_time'] = $_REQUEST['until_time'];
} }
if (isset($_REQUEST['message']) && !empty($_REQUEST['message'])) { if (isset($_REQUEST['message']) && !empty($_REQUEST['message'])) {
$filters['message'] = $_REQUEST['message']; $filters['message'] = $_REQUEST['message'];
} }
if ($scope === 'system' && isset($_REQUEST['id']) && !empty($_REQUEST['id'])) { if ($scope === 'system' && isset($_REQUEST['id']) && !empty($_REQUEST['id'])) {
$filters['id'] = $_REQUEST['id']; $filters['id'] = $_REQUEST['id'];
} }
// pagination variables // Pagination variables
$items_per_page = 15; $items_per_page = 15;
$offset = ($currentPage - 1) * $items_per_page; $offset = ($currentPage - 1) * $items_per_page;
// Build params for pagination // Build params for pagination
$params = ''; $params = '';
if (!empty($_REQUEST['from_time'])) { if (!empty($_REQUEST['from_time'])) {
$params .= '&from_time=' . urlencode($_REQUEST['from_time']); $params .= '&from_time=' . urlencode($_REQUEST['from_time']);
} }
if (!empty($_REQUEST['until_time'])) { if (!empty($_REQUEST['until_time'])) {
$params .= '&until_time=' . urlencode($_REQUEST['until_time']); $params .= '&until_time=' . urlencode($_REQUEST['until_time']);
} }
if (!empty($_REQUEST['message'])) { if (!empty($_REQUEST['message'])) {
$params .= '&message=' . urlencode($_REQUEST['message']); $params .= '&message=' . urlencode($_REQUEST['message']);
} }
if (!empty($_REQUEST['id'])) { if (!empty($_REQUEST['id'])) {
$params .= '&id=' . urlencode($_REQUEST['id']); $params .= '&id=' . urlencode($_REQUEST['id']);
} }
if (isset($_REQUEST['tab'])) { if (isset($_REQUEST['tab'])) {
$params .= '&tab=' . urlencode($_REQUEST['tab']); $params .= '&tab=' . urlencode($_REQUEST['tab']);
} }
// prepare the result // Prepare the result
$search = $logObject->readLog($userId, $scope, $offset, $items_per_page, $filters); $search = $logObject->readLog($userId, $scope, $offset, $items_per_page, $filters);
$search_all = $logObject->readLog($userId, $scope, 0, 0, $filters); $search_all = $logObject->readLog($userId, $scope, 0, 0, $filters);
if (!empty($search)) { $logs = [];
// we get total items and number of pages $totalPages = 0;
$item_count = 0;
if (!empty($search)) {
// Get total items and number of pages
$item_count = count($search_all); $item_count = count($search_all);
$totalPages = ceil($item_count / $items_per_page); $totalPages = ceil($item_count / $items_per_page);
$logs = array(); $logs = [];
$logs['records'] = array(); $logs['records'] = [];
foreach ($search as $item) { foreach ($search as $item) {
// when we show only user's logs, omit user_id column // When we show only user's logs, omit user_id column
if ($scope === 'user') { if ($scope === 'user') {
$log_record = array( // assign title to the field
// assign title to the field in the array record $log_record = [
'time' => $item['time'], 'time' => $item['time'],
'log level' => $item['level'], 'log level' => $item['level'],
'log message' => $item['message'] 'log message' => $item['message']
); ];
} else { } else {
$log_record = array( // assign title to the field
// assign title to the field in the array record $log_record = [
'userID' => $item['user_id'], 'userID' => $item['user_id'],
'username' => $item['username'], 'username' => $item['username'],
'time' => $item['time'], 'time' => $item['time'],
'log level' => $item['level'], 'log level' => $item['level'],
'log message' => $item['message'] 'log message' => $item['message']
); ];
} }
// populate the result array $logs['records'][] = $log_record;
array_push($logs['records'], $log_record);
} }
}
$username = $userObject->getUserDetails($userId)[0]['username'];
$page = 'logs'; // For pagination template
\App\Helpers\Theme::include('page-header');
\App\Helpers\Theme::include('page-menu');
if ($validSession) {
\App\Helpers\Theme::include('page-sidebar');
}
include APP_PATH . 'helpers/feedback.php';
require_once PLUGIN_LOGS_PATH . 'helpers/logs_view_helper.php';
include PLUGIN_LOGS_PATH . 'views/logs.php';
\App\Helpers\Theme::include('page-footer');
} }
$username = $userObject->getUserDetails($userId)[0]['username'];
// Get any new feedback messages
include_once dirname(__FILE__, 4) . '/app/helpers/feedback.php';
// Load plugin helpers
require_once PLUGIN_LOGS_PATH . 'helpers/logs_view_helper.php';
// Display messages list
include PLUGIN_LOGS_PATH . 'views/logs.php';

View File

@ -1,5 +1,5 @@
{ {
"name": "Logger Plugin", "name": "Logger Plugin",
"version": "1.0.1", "version": "1.0.2",
"description": "Initializes logging system via LoggerFactory" "description": "Initializes logging system via LoggerFactory"
} }