mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
- Add monolog/monolog dependency (^3.10)
- Create app/Logger.php central factory with channels: app, admin, error, audit
- Each channel gets RotatingFileHandler (30-day retention) with pass-through LineFormatter
preserving existing JSON format contracts
- Rewrite AppLogger as thin facade delegating to Logger::get('app')
- Rewrite ErrorHandler::log() to delegate to Logger::get('error')
- Rewrite AdminLogger file output to delegate to Logger::get('admin'), keep DB writes
- Add Monolog file shadow to Audit via Logger::get('audit') (Option A per monolog-plan)
- Log level controlled by LOG_LEVEL env var (defaults: DEBUG in cli-server, WARNING otherwise)
- Graceful NullHandler fallback when log directory is not writable
- Update SystemController LOG_FILES: remove php_error, add app/admin/error/audit
- JSON app logs parsed to readable one-liners in the log viewer
- Remove nginx config tab (parametres + fragment + template + css)
- Friendly empty-state message when app log files don't exist yet (notYet)
- PHP tail fallback when exec() unavailable
- All 228 PHPUnit tests pass, no call sites changed
108 lines
3.9 KiB
PHP
108 lines
3.9 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Data-level audit logger.
|
|
*
|
|
* Writes a row to the `audit_log` table for every INSERT, UPDATE, or DELETE
|
|
* on core data tables. Unlike AdminLogger (which tracks admin *actions*),
|
|
* this captures the actual data change — before and after snapshots.
|
|
*
|
|
* Usage:
|
|
* Audit::log($db, $actor, 'DELETE', 'tags', $tagId, $oldRow);
|
|
* Audit::log($db, $actor, 'UPDATE', 'theses', $thesisId, $oldRow, $newRow);
|
|
* Audit::log($db, $actor, 'INSERT', 'tags', $newId, null, $newRow);
|
|
*/
|
|
class Audit
|
|
{
|
|
/**
|
|
* Log a data mutation.
|
|
*
|
|
* @param Database $db Database instance.
|
|
* @param string $actor Who triggered this (IP, username, 'system', etc.)
|
|
* @param string $action 'INSERT', 'UPDATE', or 'DELETE'.
|
|
* @param string $tableName The table being mutated.
|
|
* @param int|null $recordId The primary key of the affected row.
|
|
* @param array|null $oldData Row data before the mutation (null for INSERT).
|
|
* @param array|null $newData Row data after the mutation (null for DELETE).
|
|
*/
|
|
public static function log(
|
|
Database $db,
|
|
string $actor,
|
|
string $action,
|
|
string $tableName,
|
|
?int $recordId,
|
|
?array $oldData = null,
|
|
?array $newData = null
|
|
): void {
|
|
// DB write is the primary path — best-effort, never crash.
|
|
try {
|
|
$stmt = $db->getConnection()->prepare(
|
|
'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data)
|
|
VALUES (?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([
|
|
$actor,
|
|
$action,
|
|
$tableName,
|
|
$recordId,
|
|
$oldData !== null ? self::safeJsonEncode($oldData) : null,
|
|
$newData !== null ? self::safeJsonEncode($newData) : null,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
// Audit logging is best-effort — never crash the app over it.
|
|
error_log('[Audit] DB write failed: ' . $e->getMessage());
|
|
}
|
|
|
|
// File shadow — structured JSON-line log for debuggability
|
|
// (Option A from monolog-plan: keep Audit DB logic as-is, add file trace)
|
|
try {
|
|
$entry = [
|
|
'timestamp' => date('c'),
|
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
|
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
|
'actor' => $actor,
|
|
'action' => $action,
|
|
'table' => $tableName,
|
|
'record_id' => $recordId,
|
|
];
|
|
if ($oldData !== null) {
|
|
$entry['old_data'] = $oldData;
|
|
}
|
|
if ($newData !== null) {
|
|
$entry['new_data'] = $newData;
|
|
}
|
|
|
|
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
Logger::get('audit')->info($line);
|
|
} catch (\Throwable $e) {
|
|
// File shadow is also best-effort
|
|
error_log('[Audit] file shadow write failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the actor string from the current request context.
|
|
*/
|
|
public static function actor(): string
|
|
{
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'cli';
|
|
$user = $_SESSION['admin_user'] ?? null;
|
|
return $user ? "$user@$ip" : $ip;
|
|
}
|
|
|
|
/**
|
|
* JSON-encode data, redacting sensitive/password fields.
|
|
*/
|
|
private static function safeJsonEncode(array $data): string
|
|
{
|
|
$safe = $data;
|
|
// Redact password-like fields
|
|
foreach (['password', 'pass', 'secret', 'token', 'credential'] as $key) {
|
|
if (array_key_exists($key, $safe)) {
|
|
$safe[$key] = '[REDACTED]';
|
|
}
|
|
}
|
|
return json_encode($safe, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
|
}
|
|
}
|