diff --git a/plugins/logs/README.md b/plugins/logs/README.md index 876a9b9..3f94ac4 100644 --- a/plugins/logs/README.md +++ b/plugins/logs/README.md @@ -1,13 +1,38 @@ # 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`. +The Logger plugin (located in `plugins/logs/`) provides a modular, pluggable logging +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 -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. +1. Copy the `logs` folder into the project's `plugins/` directory. +2. Enable the plugin via the admin plugin management interface (stored in `settings` table). +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 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; ``` -## Hook API -Core must call: +## Routing & Dispatcher +The plugin registers its route using `PluginRouteRegistry`: ```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 +register_plugin_route_prefix('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', +]); ``` -Then in the code use: +## Hook + Loader API +Core must fire the initialization hook after the database connection is ready: ```php -$logObject->insertLog($userId, 'Your message', 'user'); -$data = $logObject->readLog($userId, 'user', $offset, $limit, $filters); +do_hook('logger.system_init', ['db' => $db]); ``` +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 ``` -plugins/logger/ -├─ bootstrap.php # registers hook -├─ plugin.json # metadata & enabled flag -├─ README.md # this documentation +plugins/logs/ +├─ bootstrap.php # registers route, migration function, hooks & menu +├─ plugin.json # plugin metadata +├─ README.md # this documentation +├─ controllers/ +│ └─ logs.php # procedural handler functions for callable dispatcher ├─ models/ -│ ├─ Log.php # main Log class -│ └─ LoggerFactory.php# migration + factory +│ ├─ Log.php # main Log class +│ └─ LoggerFactory.php # migration + factory ├─ helpers/ -│ └─ logs.php # user IP helper +│ ├─ logs_view_helper.php +├─ helpers.php # plugin helper wrapper └─ migrations/ └─ 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 -- Set `"enabled": false` in `plugin.json` or delete the `plugins/logger/` folder. -- Core code will default to `NullLogger` and no logs will be written. +To disable the plugin: +- 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. diff --git a/plugins/logs/bootstrap.php b/plugins/logs/bootstrap.php index 40ddd3b..ead7280 100644 --- a/plugins/logs/bootstrap.php +++ b/plugins/logs/bootstrap.php @@ -1,20 +1,63 @@ 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 register_hook('logger.system_init', function(array $context) { + // Ensure tables exist + logs_ensure_tables(); + // Load plugin-specific LoggerFactory class require_once __DIR__ . '/models/LoggerFactory.php'; [$logger, $userIP] = LoggerFactory::create($context['db']); diff --git a/plugins/logs/controllers/logs.php b/plugins/logs/controllers/logs.php index 8a333f8..813debb 100644 --- a/plugins/logs/controllers/logs.php +++ b/plugins/logs/controllers/logs.php @@ -1,122 +1,157 @@ hasRight($userId, 'superuser') || - $userObject->hasRight($userId, 'view app logs')); +function logs_plugin_handle(string $action, array $context = []): bool { + $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; -// Get current page for pagination -$currentPage = $_REQUEST['page_num'] ?? 1; -$currentPage = (int)$currentPage; + if (!$db || !$userId) { + \Feedback::flash('ERROR', 'DEFAULT', 'Logs service unavailable.'); + header('Location: ' . $app_root); + exit; + } -// Get selected tab -$selected_tab = $_REQUEST['tab'] ?? 'user'; -if ($selected_tab === 'system' && !$has_system_access) { - $selected_tab = 'user'; -} + // 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; + } -// 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 level' => $item['level'], - '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 level' => $item['level'], - 'log message' => $item['message'] - ); - } - - // populate the result array - array_push($logs['records'], $log_record); + switch ($action) { + case 'list': + default: + logs_plugin_render_list($logObject, $db, $userId, $validSession, $app_root); + return true; } } -$username = $userObject->getUserDetails($userId)[0]['username']; +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')); -// Get any new feedback messages -include_once dirname(__FILE__, 4) . '/app/helpers/feedback.php'; + // Get current page for pagination + $currentPage = $_REQUEST['page_num'] ?? 1; + $currentPage = (int)$currentPage; -// Load plugin helpers -require_once PLUGIN_LOGS_PATH . 'helpers/logs_view_helper.php'; + // Get selected tab + $selected_tab = $_REQUEST['tab'] ?? 'user'; + if ($selected_tab === 'system' && !$has_system_access) { + $selected_tab = 'user'; + } -// Display messages list -include PLUGIN_LOGS_PATH . 'views/logs.php'; + // Set scope based on selected tab + $scope = ($selected_tab === 'system') ? 'system' : 'user'; + + // Specify time range + include APP_PATH . '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); + + $logs = []; + $totalPages = 0; + $item_count = 0; + + if (!empty($search)) { + // Get total items and number of pages + $item_count = count($search_all); + $totalPages = ceil($item_count / $items_per_page); + + $logs = []; + $logs['records'] = []; + + foreach ($search as $item) { + // When we show only user's logs, omit user_id column + if ($scope === 'user') { + // assign title to the field + $log_record = [ + 'time' => $item['time'], + 'log level' => $item['level'], + 'log message' => $item['message'] + ]; + } else { + // assign title to the field + $log_record = [ + 'userID' => $item['user_id'], + 'username' => $item['username'], + 'time' => $item['time'], + 'log level' => $item['level'], + 'log message' => $item['message'] + ]; + } + + $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'); +} diff --git a/plugins/logs/plugin.json b/plugins/logs/plugin.json index cad8f87..78cd09d 100644 --- a/plugins/logs/plugin.json +++ b/plugins/logs/plugin.json @@ -1,5 +1,5 @@ { "name": "Logger Plugin", - "version": "1.0.1", + "version": "1.0.2", "description": "Initializes logging system via LoggerFactory" }