Files
domnitor/app/Services/ErrorHandler.php
Hosteroid b50377492c Add error log management and bulk admin actions
Introduces error log tracking with new ErrorLog model, controller, views, and migration. Adds admin UI for viewing, resolving, and deleting errors. Implements bulk actions for users and notification groups, refactors domain filtering/pagination, and centralizes admin access checks using Auth::requireAdmin().
2025-10-10 14:01:19 +03:00

316 lines
9.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\ErrorLog;
/**
* ErrorHandler Service
*
* Centralized error handling system:
* - Captures all errors and exceptions
* - Logs to files and database
* - Generates unique error IDs
* - Displays appropriate error pages
* - Sanitizes sensitive data
*/
class ErrorHandler
{
private Logger $logger;
private ?ErrorLog $errorLogModel = null;
private bool $isDevelopment;
public function __construct()
{
$this->logger = new Logger('errors');
// Default to development if APP_ENV not set (show debug info for config errors)
$this->isDevelopment = ($_ENV['APP_ENV'] ?? 'development') === 'development';
// Initialize ErrorLog model if database is available
try {
$this->errorLogModel = new ErrorLog();
} catch (\Exception $e) {
// Database not available, will only use file logging
// Don't use error_log as it might fail too
}
}
/**
* Handle an exception
*/
public function handleException(\Throwable $exception): void
{
$errorData = $this->captureError($exception);
// Log to file
$this->logToFile($errorData);
// Log to database if available
$dbErrorId = $this->logToDatabase($errorData);
// Display error page
$this->displayError($errorData, $dbErrorId);
}
/**
* Handle PHP errors (convert to exception)
*/
public function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
{
// Don't handle suppressed errors (@)
if (!(error_reporting() & $errno)) {
return false;
}
// Ignore certain non-critical errors during error handling itself
if (error_reporting() === 0) {
return false;
}
// Convert to ErrorException and handle it
$exception = new \ErrorException($errstr, 0, $errno, $errfile, $errline);
$this->handleException($exception);
return true;
}
/**
* Handle fatal errors on shutdown
*/
public function handleShutdown(): void
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
$exception = new \ErrorException(
$error['message'],
0,
$error['type'],
$error['file'],
$error['line']
);
$this->handleException($exception);
}
}
/**
* Capture complete error context
*/
private function captureError(\Throwable $exception): array
{
// Generate unique error ID
$errorId = $this->generateErrorId();
// Sanitize request data (remove passwords, tokens, etc.)
$requestData = $this->sanitizeArray(array_merge($_GET, $_POST));
// Sanitize session data
$sessionData = $this->sanitizeArray($_SESSION ?? []);
return [
'error_id' => $errorId,
'error_type' => get_class($exception),
'error_message' => $exception->getMessage(),
'error_file' => $exception->getFile(),
'error_line' => $exception->getLine(),
'stack_trace' => $exception->getTraceAsString(),
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A',
'request_data' => json_encode($requestData),
'user_id' => $_SESSION['user_id'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown',
'ip_address' => $this->getIpAddress(),
'session_data' => json_encode($sessionData),
'php_version' => PHP_VERSION,
'memory_usage' => memory_get_usage(true),
'occurred_at' => date('Y-m-d H:i:s')
];
}
/**
* Generate unique error ID for reference
*/
private function generateErrorId(): string
{
return strtoupper(substr(md5(uniqid('error_', true)), 0, 12));
}
/**
* Get client IP address
*/
private function getIpAddress(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR', // Proxy
'HTTP_X_REAL_IP', // Nginx
'REMOTE_ADDR' // Direct
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ip = $_SERVER[$header];
// Handle multiple IPs (X-Forwarded-For)
if (strpos($ip, ',') !== false) {
$ip = trim(explode(',', $ip)[0]);
}
return $ip;
}
}
return 'Unknown';
}
/**
* Sanitize array to remove sensitive data
*/
private function sanitizeArray(array $data): array
{
$sensitive = ['password', 'password_confirm', 'current_password', 'new_password',
'token', 'csrf_token', 'api_key', 'secret', 'bot_token',
'mail_password', 'webhook_url', 'captcha_secret_key'];
$sanitized = [];
foreach ($data as $key => $value) {
$lowerKey = strtolower($key);
$isSensitive = false;
foreach ($sensitive as $pattern) {
if (strpos($lowerKey, $pattern) !== false) {
$isSensitive = true;
break;
}
}
if ($isSensitive) {
$sanitized[$key] = '***REDACTED***';
} elseif (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value);
} else {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* Log error to file
*/
private function logToFile(array $errorData): void
{
$this->logger->separator('ERROR CAPTURED');
$this->logger->critical('Error occurred', [
'error_id' => $errorData['error_id'],
'type' => $errorData['error_type'],
'message' => $errorData['error_message'],
'file' => $errorData['error_file'],
'line' => $errorData['error_line'],
'uri' => $errorData['request_uri'],
'user_id' => $errorData['user_id'],
'ip' => $errorData['ip_address']
]);
// Log stack trace separately for readability
$this->logger->error('Stack Trace', ['trace' => $errorData['stack_trace']]);
$this->logger->separator('END ERROR');
}
/**
* Log error to database
*/
private function logToDatabase(array $errorData): ?int
{
if ($this->errorLogModel === null) {
return null;
}
try {
return $this->errorLogModel->logError($errorData);
} catch (\Exception $e) {
// Database logging failed, continue with file logging only
error_log("Failed to log error to database: " . $e->getMessage());
return null;
}
}
/**
* Display appropriate error page
*/
private function displayError(array $errorData, ?int $dbErrorId): void
{
// Set HTTP status code
http_response_code(500);
// Clean any output buffers
while (ob_get_level() > 0) {
ob_end_clean();
}
// Extract variables for view (avoid using extract() which might fail)
$error_id = $errorData['error_id'];
$error_type = $errorData['error_type'];
$error_message = $errorData['error_message'];
$error_file = $errorData['error_file'];
$error_line = $errorData['error_line'];
$stack_trace = $errorData['stack_trace'];
$request_method = $errorData['request_method'];
$request_uri = $errorData['request_uri'];
$user_agent = $errorData['user_agent'];
$ip_address = $errorData['ip_address'];
$php_version = $errorData['php_version'];
$memory_usage = $errorData['memory_usage'];
$occurred_at = $errorData['occurred_at'];
$user_info = $this->getUserInfo($errorData['user_id']);
$request_data = json_decode($errorData['request_data'], true);
$session_data = json_decode($errorData['session_data'], true);
// Display debug page in development, clean 500 in production
if ($this->isDevelopment) {
require __DIR__ . '/../Views/errors/debug.php';
} else {
require __DIR__ . '/../Views/errors/500.php';
}
exit;
}
/**
* Get user info for error context
*/
private function getUserInfo(?int $userId): ?array
{
if ($userId === null) {
return null;
}
return [
'id' => $userId,
'username' => $_SESSION['username'] ?? 'Unknown',
'role' => $_SESSION['role'] ?? 'guest',
'email' => $_SESSION['email'] ?? 'Unknown'
];
}
/**
* Static helper to register global handlers
*/
public static function register(): self
{
$handler = new self();
// Set exception handler
set_exception_handler([$handler, 'handleException']);
// Set error handler (converts errors to exceptions)
set_error_handler([$handler, 'handleError']);
// Set shutdown handler (catch fatal errors)
register_shutdown_function([$handler, 'handleShutdown']);
return $handler;
}
}