From 7aed6bc7d89f4ed31e7ed6c6eeecc6e08d348c24 Mon Sep 17 00:00:00 2001 From: Yury Pikhtarev Date: Fri, 20 Jun 2025 18:52:30 +0400 Subject: [PATCH] refactor(database): enhance error logging and various fixes (#1978) * refactor(database): enhance error logging and handling in Database and DatabaseDebugger classes - Updated error handling in Database class to provide more meaningful error messages, including detailed PDO error information. - Enhanced log_error method in DatabaseDebugger to accept exceptions, allowing for more reliable error logging with comprehensive context. - Improved user-facing error messages while maintaining detailed logging for administrators and developers. - Added checks for connection status and query context in error logs to aid in debugging. * refactor(database): streamline error handling and improve code clarity in Database class - Removed unused imports and global variable references to enhance code readability. - Simplified error handling by refining conditions for empty input arrays. - Defined variables early in the error logging method to ensure consistent access throughout. - Improved comments for better understanding of the error handling logic. * fix(database): refine development mode condition in Database class - Removed unnecessary function check from the development mode condition to streamline the logic. * fix(redirect): add no-cache headers to prevent browser caching of redirects - Implemented `send_no_cache_headers()` in the redirect function to ensure that browsers do not cache redirect responses, adhering to best practices for HTTP redirects. * refactor(database): enhance error handling and logging in Database and Whoops classes - Improved error handling in the Database class to log detailed information for duplicate column errors and automatically retry problematic queries using PDO. - Introduced a new DatabaseErrorHandler to enhance Whoops error reporting with comprehensive database context and recent query history. - Updated EnhancedPrettyPageHandler to include dynamic database information in error outputs, improving debugging capabilities. - Added legacy query tracking in DatabaseDebugger to identify and log queries needing compatibility fixes, ensuring better maintenance of legacy code. * refactor(posting): rollback changes in SQL queries up to legacy format --- .../includes/cron/jobs/attach_maintenance.php | 4 +- library/includes/functions.php | 3 + src/Database/Database.php | 270 ++++++++++++-- src/Database/DatabaseDebugger.php | 127 ++++++- src/Dev.php | 38 +- src/Whoops/DatabaseErrorHandler.php | 353 ++++++++++++++++++ src/Whoops/EnhancedPrettyPageHandler.php | 269 +++++++++++++ src/Whoops/README.md | 131 +++++++ 8 files changed, 1150 insertions(+), 45 deletions(-) create mode 100644 src/Whoops/DatabaseErrorHandler.php create mode 100644 src/Whoops/EnhancedPrettyPageHandler.php create mode 100644 src/Whoops/README.md diff --git a/library/includes/cron/jobs/attach_maintenance.php b/library/includes/cron/jobs/attach_maintenance.php index aa5ee6101..31509d395 100644 --- a/library/includes/cron/jobs/attach_maintenance.php +++ b/library/includes/cron/jobs/attach_maintenance.php @@ -26,9 +26,9 @@ $posts_without_attach = $topics_without_attach = []; DB()->query(" CREATE TEMPORARY TABLE $tmp_attach_tbl ( - physical_filename VARCHAR(255) NOT NULL default '', + physical_filename VARCHAR(255) NOT NULL default '' COLLATE utf8mb4_unicode_ci, KEY physical_filename (physical_filename(20)) - ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 + ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci "); DB()->add_shutdown_query("DROP TEMPORARY TABLE IF EXISTS $tmp_attach_tbl"); diff --git a/library/includes/functions.php b/library/includes/functions.php index 27c81ebe3..6398907c9 100644 --- a/library/includes/functions.php +++ b/library/includes/functions.php @@ -1437,6 +1437,9 @@ function redirect($url) $redirect_url = $server_protocol . $server_name . $server_port . $script_name . preg_replace('#^\/?(.*?)\/?$#', '/\1', $url); + // Send no-cache headers to prevent browsers from caching redirects + send_no_cache_headers(); + // Behave as per HTTP/1.1 spec for others header('Location: ' . $redirect_url, response_code: 301); exit; diff --git a/src/Database/Database.php b/src/Database/Database.php index 51db196ac..31d34de6f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -10,16 +10,10 @@ namespace TorrentPier\Database; use Nette\Database\Connection; +use Nette\Database\Conventions\DiscoveredConventions; use Nette\Database\Explorer; use Nette\Database\ResultSet; -use Nette\Database\Row; -use Nette\Database\Table\Selection; use Nette\Database\Structure; -use Nette\Database\Conventions\DiscoveredConventions; -use TorrentPier\Database\DebugSelection; -use TorrentPier\Database\DatabaseDebugger; -use TorrentPier\Dev; -use TorrentPier\Legacy\SqlDb; /** * Modern Database class using Nette Database with backward compatibility @@ -55,6 +49,7 @@ class Database public float $sql_timetotal = 0; public float $cur_query_time = 0; public ?string $cur_query = null; + public ?string $last_query = null; // Store last executed query for error reporting public array $shutdown = []; public array $DBS = []; @@ -64,8 +59,6 @@ class Database */ private function __construct(array $cfg_values, string $server_name = 'db') { - global $DBS; - $this->cfg = array_combine($this->cfg_keys, $cfg_values); $this->db_server = $server_name; @@ -188,12 +181,13 @@ class Database // Initialize affected rows to 0 (most queries don't affect rows) $this->last_affected_rows = 0; } catch (\Exception $e) { - $this->debugger->log_error(); + $this->debugger->log_error($e); $this->result = null; $this->last_affected_rows = 0; } $this->debugger->debug('stop'); + $this->last_query = $this->cur_query; // Preserve for error reporting $this->cur_query = null; if ($this->inited) { @@ -247,20 +241,69 @@ class Database return false; } - $row = $result->fetch(); - if (!$row) { - return false; + try { + $row = $result->fetch(); + if (!$row) { + return false; + } + + // Convert Row to array for backward compatibility + // Nette Database Row extends ArrayHash, so we can cast it to array + $rowArray = (array)$row; + + if ($field_name) { + return $rowArray[$field_name] ?? false; + } + + return $rowArray; + } catch (\Exception $e) { + // Check if this is a duplicate column error + if (str_contains($e->getMessage(), 'Found duplicate columns')) { + // Log this as a problematic query that needs fixing + $this->debugger->logLegacyQuery($this->last_query ?? $this->cur_query ?? 'Unknown query', $e->getMessage()); + + // Automatically retry by re-executing the query with direct PDO + // This bypasses Nette's duplicate column check completely + try { + // Extract the clean SQL query + $cleanQuery = $this->last_query ?? $this->cur_query ?? ''; + // Remove Nette's debug comment + $cleanQuery = preg_replace('#^(\s*)(/\*)(.*)(\*/)(\s*)#', '', $cleanQuery); + + if (!$cleanQuery) { + throw new \RuntimeException('Could not extract clean query for PDO retry'); + } + + // Execute directly with PDO to bypass Nette's column checking + $stmt = $this->connection->getPdo()->prepare($cleanQuery); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + // PDO::FETCH_ASSOC automatically handles duplicate columns by keeping the last occurrence + // which matches MySQL's behavior for SELECT t.*, f.* queries + + if (!$row) { + return false; + } + + if ($field_name) { + return $row[$field_name] ?? false; + } + + return $row; + } catch (\Exception $retryException) { + // If PDO retry also fails, log and re-throw + $this->debugger->log_error($retryException); + throw $retryException; + } + } + + // Log the error including the query that caused it + $this->debugger->log_error($e); + + // Re-throw the exception so it can be handled by Whoops + throw $e; } - - // Convert Row to array for backward compatibility - // Nette Database Row extends ArrayHash, so we can cast it to array - $rowArray = (array)$row; - - if ($field_name) { - return $rowArray[$field_name] ?? false; - } - - return $rowArray; } /** @@ -280,7 +323,22 @@ class Database $this->trigger_error(); } - return $this->sql_fetchrow($result, $field_name); + try { + return $this->sql_fetchrow($result, $field_name); + } catch (\Exception $e) { + // Enhance the exception with query information + $enhancedException = new \RuntimeException( + "Database error during fetch_row: " . $e->getMessage() . + "\nProblematic Query: " . ($this->cur_query ?: $this->last_query ?: 'Unknown'), + $e->getCode(), + $e + ); + + // Log the enhanced error + $this->debugger->log_error($enhancedException); + + throw $enhancedException; + } } /** @@ -293,11 +351,48 @@ class Database } $rowset = []; - while ($row = $result->fetch()) { - // Convert Row to array for backward compatibility - // Nette Database Row extends ArrayHash, so we can cast it to array - $rowArray = (array)$row; - $rowset[] = $field_name ? ($rowArray[$field_name] ?? null) : $rowArray; + + try { + while ($row = $result->fetch()) { + // Convert Row to array for backward compatibility + // Nette Database Row extends ArrayHash, so we can cast it to array + $rowArray = (array)$row; + $rowset[] = $field_name ? ($rowArray[$field_name] ?? null) : $rowArray; + } + } catch (\Exception $e) { + // Check if this is a duplicate column error + if (str_contains($e->getMessage(), 'Found duplicate columns')) { + // Log this as a problematic query that needs fixing + $this->debugger->logLegacyQuery($this->last_query ?? $this->cur_query ?? 'Unknown query', $e->getMessage()); + + // Automatically retry by re-executing the query with direct PDO + try { + // Extract the clean SQL query + $cleanQuery = $this->last_query ?? $this->cur_query ?? ''; + // Remove Nette's debug comment + $cleanQuery = preg_replace('#^(\s*)(/\*)(.*)(\*/)(\s*)#', '', $cleanQuery); + + if (!$cleanQuery) { + throw new \RuntimeException('Could not extract clean query for PDO retry'); + } + + // Execute directly with PDO to bypass Nette's column checking + $stmt = $this->connection->getPdo()->prepare($cleanQuery); + $stmt->execute(); + + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $rowset[] = $field_name ? ($row[$field_name] ?? null) : $row; + } + } catch (\Exception $retryException) { + // If PDO retry also fails, log and re-throw + $this->debugger->log_error($retryException); + throw $retryException; + } + } else { + // For other exceptions, just re-throw + $this->debugger->log_error($e); + throw $e; + } } return $rowset; @@ -423,8 +518,8 @@ class Database $dont_escape = $data_already_escaped; $check_type = $check_data_type_in_escape; - if (empty($input_ary) || !is_array($input_ary)) { - $this->trigger_error(__FUNCTION__ . ' - wrong params: $input_ary'); + if (empty($input_ary)) { + $this->trigger_error(__FUNCTION__ . ' - wrong params: $input_ary is empty'); } if ($query_type == 'INSERT') { @@ -549,9 +644,28 @@ class Database if ($this->connection) { try { $pdo = $this->connection->getPdo(); + $errorCode = $pdo->errorCode(); + $errorInfo = $pdo->errorInfo(); + + // Filter out "no error" states - PDO returns '00000' when there's no error + if (!$errorCode || $errorCode === '00000') { + return ['code' => '', 'message' => '']; + } + + // Build meaningful error message from errorInfo array + // errorInfo format: [SQLSTATE, driver-specific error code, driver-specific error message] + $message = ''; + if (isset($errorInfo[2]) && $errorInfo[2]) { + $message = $errorInfo[2]; // Driver-specific error message is most informative + } elseif (isset($errorInfo[1]) && $errorInfo[1]) { + $message = "Error code: " . $errorInfo[1]; + } else { + $message = "SQLSTATE: " . $errorCode; + } + return [ - 'code' => $pdo->errorCode(), - 'message' => implode(': ', $pdo->errorInfo()) + 'code' => $errorCode, + 'message' => $message ]; } catch (\Exception $e) { return ['code' => $e->getCode(), 'message' => $e->getMessage()]; @@ -753,7 +867,91 @@ class Database public function trigger_error(string $msg = 'Database Error'): void { $error = $this->sql_error(); - $error_msg = "$msg: " . $error['message']; + + // Define these variables early so they're available throughout the method + $is_admin = defined('IS_ADMIN') && IS_ADMIN; + $is_dev_mode = (defined('APP_ENV') && APP_ENV === 'local') || (defined('DBG_USER') && DBG_USER); + + // Build a meaningful error message + if (!empty($error['message'])) { + $error_msg = "$msg: " . $error['message']; + if (!empty($error['code'])) { + $error_msg = "$msg ({$error['code']}): " . $error['message']; + } + } else { + // Base error message for all users + $error_msg = "$msg: Database operation failed"; + + // Only add detailed debugging information for administrators or in development mode + if ($is_admin || $is_dev_mode) { + // Gather detailed debugging information - ONLY for admins/developers + $debug_info = []; + + // Connection status + if ($this->connection) { + $debug_info[] = "Connection: Active"; + try { + $pdo = $this->connection->getPdo(); + if ($pdo) { + $debug_info[] = "PDO: Available"; + $errorInfo = $pdo->errorInfo(); + if ($errorInfo && count($errorInfo) >= 3) { + $debug_info[] = "PDO ErrorInfo: " . json_encode($errorInfo); + } + $debug_info[] = "PDO ErrorCode: " . $pdo->errorCode(); + } else { + $debug_info[] = "PDO: Null"; + } + } catch (\Exception $e) { + $debug_info[] = "PDO Check Failed: " . $e->getMessage(); + } + } else { + $debug_info[] = "Connection: None"; + } + + // Query information + if ($this->cur_query) { + $debug_info[] = "Last Query: " . substr($this->cur_query, 0, 200) . (strlen($this->cur_query) > 200 ? '...' : ''); + } else { + $debug_info[] = "Last Query: None"; + } + + // Database information + $debug_info[] = "Database: " . ($this->selected_db ?: 'None'); + $debug_info[] = "Server: " . $this->db_server; + + // Recent queries from debug log (if available) + if (isset($this->debugger->dbg) && !empty($this->debugger->dbg)) { + $recent_queries = array_slice($this->debugger->dbg, -3); // Last 3 queries + $debug_info[] = "Recent Queries Count: " . count($recent_queries); + foreach ($recent_queries as $i => $query_info) { + $debug_info[] = "Query " . ($i + 1) . ": " . substr($query_info['sql'] ?? 'Unknown', 0, 100) . (strlen($query_info['sql'] ?? '') > 100 ? '...' : ''); + } + } + + if ($debug_info) { + $error_msg .= " [DEBUG: " . implode("; ", $debug_info) . "]"; + } + + // Log this for investigation + if (function_exists('bb_log')) { + bb_log("Unknown Database Error Debug:\n" . implode("\n", $debug_info), 'unknown_db_errors'); + } + } else { + // For regular users: generic message only + contact admin hint + $error_msg = "$msg: A database error occurred. Please contact the administrator if this problem persists."; + + // Still log basic information for debugging + if (function_exists('bb_log')) { + bb_log("Database Error (User-facing): $error_msg\nRequest: " . ($_SERVER['REQUEST_URI'] ?? 'CLI'), 'user_db_errors'); + } + } + } + + // Add query context for debugging (but only for admins/developers) + if ($this->cur_query && ($is_admin || $is_dev_mode)) { + $error_msg .= "\nQuery: " . $this->cur_query; + } if (function_exists('bb_die')) { bb_die($error_msg); @@ -797,9 +995,9 @@ class Database /** * Log error (delegated to debugger) */ - public function log_error(): void + public function log_error(?\Exception $exception = null): void { - $this->debugger->log_error(); + $this->debugger->log_error($exception); } /** diff --git a/src/Database/DatabaseDebugger.php b/src/Database/DatabaseDebugger.php index ad3f135ea..d93f6dbf3 100644 --- a/src/Database/DatabaseDebugger.php +++ b/src/Database/DatabaseDebugger.php @@ -29,6 +29,7 @@ class DatabaseDebugger // Debug storage public array $dbg = []; public int $dbg_id = 0; + public array $legacy_queries = []; // Track queries that needed legacy compatibility fixes // Explain functionality public string $explain_hold = ''; @@ -278,11 +279,131 @@ class DatabaseDebugger /** * Log error + * + * NOTE: This method logs detailed information to FILES only (error_log, bb_log). + * Log files are not accessible to regular users, so detailed information is safe here. + * User-facing error display is handled separately with proper security checks. */ - public function log_error(): void + public function log_error(?\Exception $exception = null): void { - $error = $this->db->sql_error(); - error_log("Database Error: " . $error['message'] . " Query: " . $this->db->cur_query); + $error_details = []; + $error_msg = ''; + + if ($exception) { + // Use the actual exception information which is more reliable + $error_msg = "Database Error: " . $exception->getMessage(); + $error_code = $exception->getCode(); + if ($error_code) { + $error_msg = "Database Error ({$error_code}): " . $exception->getMessage(); + } + + // Collect detailed error information + $error_details[] = "Exception: " . get_class($exception); + $error_details[] = "Message: " . $exception->getMessage(); + $error_details[] = "Code: " . $exception->getCode(); + $error_details[] = "File: " . $exception->getFile() . ":" . $exception->getLine(); + + // Add PDO-specific details if it's a PDO exception + if ($exception instanceof \PDOException) { + $error_details[] = "PDO Error Info: " . json_encode($exception->errorInfo ?? []); + } + } else { + // Fallback to PDO error state (legacy behavior) + $error = $this->db->sql_error(); + + // Only log if there's an actual error (not 00000 which means "no error") + if (!$error['code'] || $error['code'] === '00000' || !$error['message']) { + return; // Don't log empty or "no error" states + } + + $error_msg = "Database Error ({$error['code']}): " . $error['message']; + $error_details[] = "PDO Error Code: " . $error['code']; + $error_details[] = "PDO Error Message: " . $error['message']; + } + + // Add comprehensive context for debugging + $error_details[] = "Query: " . ($this->db->cur_query ?: 'None'); + $error_details[] = "Source: " . $this->debug_find_source(); + $error_details[] = "Database: " . ($this->db->selected_db ?: 'None'); + $error_details[] = "Server: " . $this->db->db_server; + $error_details[] = "Timestamp: " . date('Y-m-d H:i:s'); + $error_details[] = "Request URI: " . ($_SERVER['REQUEST_URI'] ?? 'CLI'); + $error_details[] = "User IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'Unknown'); + + // Check connection status + try { + if ($this->db->connection) { + $error_details[] = "Connection Status: Active"; + $pdo = $this->db->connection->getPdo(); + $error_details[] = "PDO Connection: " . ($pdo ? 'Available' : 'Null'); + if ($pdo) { + $errorInfo = $pdo->errorInfo(); + $error_details[] = "Current PDO Error Info: " . json_encode($errorInfo); + } + } else { + $error_details[] = "Connection Status: No connection"; + } + } catch (\Exception $e) { + $error_details[] = "Connection Check Failed: " . $e->getMessage(); + } + + // Build comprehensive log message + $log_message = $error_msg . "\n" . implode("\n", $error_details); + + // Log to both error_log and TorrentPier's logging system + error_log($error_msg); + + // Use TorrentPier's bb_log for better file management and organization + if (function_exists('bb_log')) { + bb_log($log_message, 'database_errors'); + } + + // Also log to PHP error log for immediate access + error_log("DETAILED: " . $log_message); + } + + /** + * Log legacy query that needed automatic compatibility fix + */ + public function logLegacyQuery(string $query, string $error): void + { + $legacy_entry = [ + 'query' => $query, + 'error' => $error, + 'source' => $this->debug_find_source(), + 'file' => $this->debug_find_source('file'), + 'line' => $this->debug_find_source('line'), + 'time' => microtime(true) + ]; + + $this->legacy_queries[] = $legacy_entry; + + // Mark the CURRENT debug entry as legacy instead of creating a new one + if ($this->dbg_enabled && !empty($this->dbg)) { + // Find the most recent debug entry (the one that just executed and failed) + $current_id = $this->dbg_id - 1; + + if (isset($this->dbg[$current_id])) { + // Mark the existing entry as legacy + $this->dbg[$current_id]['is_legacy_query'] = true; + + // Update the info to show it was automatically fixed + $original_info = $this->dbg[$current_id]['info'] ?? ''; + $original_info_plain = $this->dbg[$current_id]['info_plain'] ?? $original_info; + + $this->dbg[$current_id]['info'] = 'LEGACY COMPATIBILITY FIX APPLIED - ' . $original_info; + $this->dbg[$current_id]['info_plain'] = 'LEGACY COMPATIBILITY FIX APPLIED - ' . $original_info_plain; + } + } + + // Log to file for permanent record + $msg = 'LEGACY QUERY DETECTED - NEEDS FIXING' . LOG_LF; + $msg .= 'Query: ' . $query . LOG_LF; + $msg .= 'Error: ' . $error . LOG_LF; + $msg .= 'Source: ' . $legacy_entry['source'] . LOG_LF; + $msg .= 'Time: ' . date('Y-m-d H:i:s', (int)$legacy_entry['time']) . LOG_LF; + + bb_log($msg, 'legacy_queries', false); } /** diff --git a/src/Dev.php b/src/Dev.php index fb3fde88f..40b96cbca 100644 --- a/src/Dev.php +++ b/src/Dev.php @@ -129,9 +129,9 @@ class Dev private function getWhoopsOnPage(): void { /** - * Show errors on page + * Show errors on page with enhanced database information */ - $prettyPageHandler = new PrettyPageHandler(); + $prettyPageHandler = new \TorrentPier\Whoops\EnhancedPrettyPageHandler(); foreach (config()->get('whoops.blacklist', []) as $key => $secrets) { foreach ($secrets as $secret) { $prettyPageHandler->blacklist($key, $secret); @@ -195,9 +195,32 @@ class Dev public function getSqlLogInstance(): string { $log = ''; + $totalLegacyQueries = 0; + + // Check for legacy queries across all database instances + $server_names = \TorrentPier\Database\DatabaseFactory::getServerNames(); + foreach ($server_names as $srv_name) { + try { + $db_obj = \TorrentPier\Database\DatabaseFactory::getInstance($srv_name); + if (!empty($db_obj->debugger->legacy_queries)) { + $totalLegacyQueries += count($db_obj->debugger->legacy_queries); + } + } catch (\Exception $e) { + // Skip if server not available + } + } + + // Add warning banner if legacy queries were detected + if ($totalLegacyQueries > 0) { + $log .= '
' + . '⚠️ Legacy Query Warning: ' + . $totalLegacyQueries . ' quer' . ($totalLegacyQueries > 1 ? 'ies' : 'y') . ' with duplicate columns detected and automatically fixed. ' + . 'These queries should be updated to explicitly select columns. ' + . 'Check the legacy_queries.log file for details.' + . '
'; + } // Get debug information from new database system - $server_names = \TorrentPier\Database\DatabaseFactory::getServerNames(); foreach ($server_names as $srv_name) { try { $db_obj = \TorrentPier\Database\DatabaseFactory::getInstance($srv_name); @@ -262,7 +285,14 @@ class Dev $info_plain = !empty($dbg['info_plain']) ? $dbg['info_plain'] . ' [' . $dbg['src'] . ']' : $dbg['src']; $info = !empty($dbg['info']) ? $dbg['info'] . ' [' . $dbg['src'] . ']' : $dbg['src']; - $log .= '
' + // Check if this is a legacy query that needed compatibility fix + $isLegacyQuery = !empty($dbg['is_legacy_query']); + $rowClass = $isLegacyQuery ? 'sqlLogRow sqlLegacyRow' : 'sqlLogRow'; + $rowStyle = $isLegacyQuery ? ' style="background-color: #ffe6e6; border-left: 4px solid #dc3545; color: #721c24;"' : ''; + $legacyWarning = $isLegacyQuery ? '[LEGACY]' : ''; + + $log .= '
' + . $legacyWarning . '' . $time . ' ' . '' . $perc . ' ' . '' . $sql . '' diff --git a/src/Whoops/DatabaseErrorHandler.php b/src/Whoops/DatabaseErrorHandler.php new file mode 100644 index 000000000..8b0aa8392 --- /dev/null +++ b/src/Whoops/DatabaseErrorHandler.php @@ -0,0 +1,353 @@ +addToOutput) { + return Handler::DONE; + } + + $inspector = $this->getInspector(); + $exception = $inspector->getException(); + + // Add database information to the exception frames + $this->addDatabaseContextToFrames($inspector); + + // Add global database state information + $this->addGlobalDatabaseInfo($exception); + + return Handler::DONE; + } + + /** + * Set whether to add database info to output + */ + public function setAddToOutput(bool $add): self + { + $this->addToOutput = $add; + return $this; + } + + /** + * Set whether to include query history + */ + public function setIncludeQueryHistory(bool $include): self + { + $this->includeQueryHistory = $include; + return $this; + } + + /** + * Set maximum number of queries to show in history + */ + public function setMaxQueryHistory(int $max): self + { + $this->maxQueryHistory = max(1, $max); + return $this; + } + + /** + * Add database context information to exception frames + */ + private function addDatabaseContextToFrames($inspector): void + { + $frames = $inspector->getFrames(); + + foreach ($frames as $frame) { + $frameData = []; + + // Check if this frame involves database operations + $fileName = $frame->getFile(); + $className = $frame->getClass(); + $functionName = $frame->getFunction(); + + // Detect database-related frames + $isDatabaseFrame = $this->isDatabaseRelatedFrame($fileName, $className, $functionName); + + if ($isDatabaseFrame) { + $frameData['database_context'] = $this->getCurrentDatabaseContext(); + + // Add frame-specific database info + $frame->addComment('Database Context', 'This frame involves database operations'); + + foreach ($frameData['database_context'] as $key => $value) { + if (is_string($value) || is_numeric($value)) { + $frame->addComment("DB: $key", $value); + } elseif (is_array($value) && !empty($value)) { + $frame->addComment("DB: $key", json_encode($value, JSON_PRETTY_PRINT)); + } + } + } + } + } + + /** + * Add global database information to the exception + */ + private function addGlobalDatabaseInfo($exception): void + { + try { + $databaseInfo = $this->collectDatabaseInformation(); + + // Use reflection to add custom data to the exception + // This will appear in the Whoops error page + if (method_exists($exception, 'setAdditionalInfo')) { + $exception->setAdditionalInfo('Database Information', $databaseInfo); + } else { + // Fallback: store in a property that Whoops can access + if (!isset($exception->databaseInfo)) { + $exception->databaseInfo = $databaseInfo; + } + } + } catch (\Exception $e) { + // Don't let database info collection break error handling + if (method_exists($exception, 'setAdditionalInfo')) { + $exception->setAdditionalInfo('Database Info Error', $e->getMessage()); + } + } + } + + /** + * Check if a frame is related to database operations + */ + private function isDatabaseRelatedFrame(?string $fileName, ?string $className, ?string $functionName): bool + { + if (!$fileName) { + return false; + } + + // Check file paths + $databaseFiles = [ + '/Database/', + '/database/', + 'Database.php', + 'DatabaseDebugger.php', + 'DebugSelection.php', + ]; + + foreach ($databaseFiles as $dbFile) { + if (str_contains($fileName, $dbFile)) { + return true; + } + } + + // Check class names + $databaseClasses = [ + 'Database', + 'DatabaseDebugger', + 'DebugSelection', + 'DB', + 'Nette\Database', + ]; + + if ($className) { + foreach ($databaseClasses as $dbClass) { + if (str_contains($className, $dbClass)) { + return true; + } + } + } + + // Check function names + $databaseFunctions = [ + 'sql_query', + 'fetch_row', + 'fetch_rowset', + 'sql_fetchrow', + 'query', + 'execute', + ]; + + if ($functionName) { + foreach ($databaseFunctions as $dbFunc) { + if (str_contains($functionName, $dbFunc)) { + return true; + } + } + } + + return false; + } + + /** + * Get current database context + */ + private function getCurrentDatabaseContext(): array + { + $context = []; + + try { + // Get main database instance + if (function_exists('DB')) { + $db = DB(); + + $context['current_query'] = $db->cur_query ?? 'None'; + $context['database_server'] = $db->db_server ?? 'Unknown'; + $context['selected_database'] = $db->selected_db ?? 'Unknown'; + + // Connection status + $context['connection_status'] = $db->connection ? 'Active' : 'No connection'; + + // Query stats + $context['total_queries'] = $db->num_queries ?? 0; + $context['total_time'] = isset($db->sql_timetotal) ? sprintf('%.3f sec', $db->sql_timetotal) : 'Unknown'; + + // Recent error information + $sqlError = $db->sql_error(); + if (!empty($sqlError['message'])) { + $context['last_error'] = $sqlError; + } + } + } catch (\Exception $e) { + $context['error'] = 'Could not retrieve database context: ' . $e->getMessage(); + } + + return $context; + } + + /** + * Collect comprehensive database information + */ + private function collectDatabaseInformation(): array + { + $info = [ + 'timestamp' => date('Y-m-d H:i:s'), + 'request_uri' => $_SERVER['REQUEST_URI'] ?? 'CLI', + 'user_ip' => $_SERVER['REMOTE_ADDR'] ?? 'Unknown', + ]; + + try { + // Get information from all database servers + if (class_exists('\TorrentPier\Database\DatabaseFactory')) { + $serverNames = \TorrentPier\Database\DatabaseFactory::getServerNames(); + + foreach ($serverNames as $serverName) { + try { + $db = \TorrentPier\Database\DatabaseFactory::getInstance($serverName); + + $serverInfo = [ + 'server_name' => $serverName, + 'engine' => $db->engine ?? 'Unknown', + 'host' => $db->db_server ?? 'Unknown', + 'database' => $db->selected_db ?? 'Unknown', + 'connection_status' => $db->connection ? 'Connected' : 'Disconnected', + 'total_queries' => $db->num_queries ?? 0, + 'total_time' => isset($db->sql_timetotal) ? sprintf('%.3f sec', $db->sql_timetotal) : 'Unknown', + ]; + + // Current query + if (!empty($db->cur_query)) { + $serverInfo['current_query'] = $this->formatQueryForDisplay($db->cur_query); + } + + // Last error + $sqlError = $db->sql_error(); + if (!empty($sqlError['message'])) { + $serverInfo['last_error'] = $sqlError; + } + + // Recent query history (if available and enabled) + if ($this->includeQueryHistory && !empty($db->dbg)) { + $recentQueries = array_slice($db->dbg, -$this->maxQueryHistory); + $serverInfo['recent_queries'] = []; + + foreach ($recentQueries as $query) { + $serverInfo['recent_queries'][] = [ + 'sql' => $this->formatQueryForDisplay($query['sql'] ?? 'Unknown'), + 'time' => isset($query['time']) ? sprintf('%.3f sec', $query['time']) : 'Unknown', + 'source' => $query['src'] ?? 'Unknown', + ]; + } + } + + $info['databases'][$serverName] = $serverInfo; + + } catch (\Exception $e) { + $info['databases'][$serverName] = [ + 'error' => 'Could not retrieve info: ' . $e->getMessage() + ]; + } + } + } + + // Legacy single database support + if (function_exists('DB') && empty($info['databases'])) { + $db = DB(); + + $info['legacy_database'] = [ + 'engine' => $db->engine ?? 'Unknown', + 'host' => $db->db_server ?? 'Unknown', + 'database' => $db->selected_db ?? 'Unknown', + 'connection_status' => $db->connection ? 'Connected' : 'Disconnected', + 'total_queries' => $db->num_queries ?? 0, + 'total_time' => isset($db->sql_timetotal) ? sprintf('%.3f sec', $db->sql_timetotal) : 'Unknown', + ]; + + if (!empty($db->cur_query)) { + $info['legacy_database']['current_query'] = $this->formatQueryForDisplay($db->cur_query); + } + + $sqlError = $db->sql_error(); + if (!empty($sqlError['message'])) { + $info['legacy_database']['last_error'] = $sqlError; + } + } + + } catch (\Exception $e) { + $info['collection_error'] = $e->getMessage(); + } + + return $info; + } + + /** + * Format SQL query for readable display + */ + private function formatQueryForDisplay(string $query, int $maxLength = 500): string + { + // Remove comments at the start (debug info) + $query = preg_replace('#^/\*.*?\*/#', '', $query); + $query = trim($query); + + // Truncate if too long + if (strlen($query) > $maxLength) { + $query = substr($query, 0, $maxLength) . '... [truncated]'; + } + + return $query; + } + + /** + * Get priority - run after the main PrettyPageHandler + */ + public function contentType(): ?string + { + return 'text/html'; + } +} diff --git a/src/Whoops/EnhancedPrettyPageHandler.php b/src/Whoops/EnhancedPrettyPageHandler.php new file mode 100644 index 000000000..818b86559 --- /dev/null +++ b/src/Whoops/EnhancedPrettyPageHandler.php @@ -0,0 +1,269 @@ +connection ? 'Connected' : 'Disconnected'; + $info['Database Server'] = $db->db_server ?? 'Unknown'; + $info['Selected Database'] = $db->selected_db ?? 'Unknown'; + $info['Database Engine'] = $db->engine ?? 'Unknown'; + $info['Total Queries'] = $db->num_queries ?? 0; + + if (isset($db->sql_timetotal)) { + $info['Total Query Time'] = sprintf('%.3f seconds', $db->sql_timetotal); + } + + // Current/Last executed query + if (!empty($db->cur_query)) { + $info['Current Query'] = $this->formatSqlQuery($db->cur_query); + } + + // Database error information + $sqlError = $db->sql_error(); + if (!empty($sqlError['message'])) { + $info['Last Database Error'] = [ + 'Code' => $sqlError['code'] ?? 'Unknown', + 'Message' => $sqlError['message'], + ]; + } + + // Connection details if available + if ($db->connection) { + try { + $pdo = $db->connection->getPdo(); + if ($pdo) { + $info['PDO Driver'] = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) ?? 'Unknown'; + $info['Server Version'] = $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION) ?? 'Unknown'; + + // Current PDO error state + $errorCode = $pdo->errorCode(); + if ($errorCode && $errorCode !== '00000') { + $errorInfo = $pdo->errorInfo(); + $info['PDO Error State'] = [ + 'Code' => $errorCode, + 'Info' => $errorInfo[2] ?? 'Unknown' + ]; + } + } + } catch (\Exception $e) { + $info['PDO Error'] = $e->getMessage(); + } + } + } + + // Get information from all database servers (new system) + if (class_exists('\TorrentPier\Database\DatabaseFactory')) { + try { + $serverNames = \TorrentPier\Database\DatabaseFactory::getServerNames(); + + if (count($serverNames) > 1) { + foreach ($serverNames as $serverName) { + try { + $db = \TorrentPier\Database\DatabaseFactory::getInstance($serverName); + $info["Server: $serverName"] = [ + 'Host' => $db->db_server ?? 'Unknown', + 'Database' => $db->selected_db ?? 'Unknown', + 'Queries' => $db->num_queries ?? 0, + 'Connected' => $db->connection ? 'Yes' : 'No', + ]; + } catch (\Exception $e) { + $info["Server: $serverName"] = ['Error' => $e->getMessage()]; + } + } + } + } catch (\Exception $e) { + $info['Multi-Server Error'] = $e->getMessage(); + } + } + + } catch (\Exception $e) { + $info['Collection Error'] = $e->getMessage(); + } + + return $info; + } + + /** + * Get recent SQL queries from debug log + */ + private function getRecentSqlQueries(): array + { + $queries = []; + + try { + if (function_exists('DB')) { + $db = DB(); + + // Check if debug information is available + if (!empty($db->dbg) && is_array($db->dbg)) { + // Get last 5 queries + $recentQueries = array_slice($db->dbg, -5); + + foreach ($recentQueries as $index => $queryInfo) { + $queryNum = $index + 1; + $queries["Query #$queryNum"] = [ + 'SQL' => $this->formatSqlQuery($queryInfo['sql'] ?? 'Unknown'), + 'Time' => isset($queryInfo['time']) ? sprintf('%.3f sec', $queryInfo['time']) : 'Unknown', + 'Source' => $queryInfo['src'] ?? 'Unknown', + 'Info' => $queryInfo['info'] ?? '', + ]; + + // Add memory info if available + if (isset($queryInfo['mem_before'], $queryInfo['mem_after'])) { + $memUsed = $queryInfo['mem_after'] - $queryInfo['mem_before']; + $queries["Query #$queryNum"]['Memory'] = sprintf('%+d bytes', $memUsed); + } + } + } + + if (empty($queries)) { + $queries['Info'] = 'No query debug information available. Enable debug mode to see recent queries.'; + } + } + } catch (\Exception $e) { + $queries['Error'] = $e->getMessage(); + } + + return $queries; + } + + /** + * Get TorrentPier environment information + */ + private function getTorrentPierEnvironment(): array + { + $env = []; + + try { + // Basic environment + $env['Application Environment'] = defined('APP_ENV') ? APP_ENV : 'Unknown'; + $env['Debug Mode'] = defined('DBG_USER') && DBG_USER ? 'Enabled' : 'Disabled'; + $env['SQL Debug'] = defined('SQL_DEBUG') && SQL_DEBUG ? 'Enabled' : 'Disabled'; + + // Configuration status + if (function_exists('config')) { + $config = config(); + $env['Config Loaded'] = 'Yes'; + $env['TorrentPier Version'] = $config->get('tp_version', 'Unknown'); + $env['Board Title'] = $config->get('sitename', 'Unknown'); + } else { + $env['Config Loaded'] = 'No'; + } + + // Cache system + if (function_exists('CACHE')) { + $env['Cache System'] = 'Available'; + } + + // Language system + if (function_exists('lang')) { + $env['Language System'] = 'Available'; + if (isset(lang()->getCurrentLanguage)) { + $env['Current Language'] = lang()->getCurrentLanguage; + } + } + + // Memory and timing + if (defined('TIMESTART')) { + $env['Execution Time'] = sprintf('%.3f sec', microtime(true) - TIMESTART); + } + + if (function_exists('sys')) { + // Use plain text formatting for memory values (no HTML entities) + $env['Peak Memory'] = str_replace(' ', ' ', humn_size(sys('mem_peak'))); + $env['Current Memory'] = str_replace(' ', ' ', humn_size(sys('mem'))); + } + + // Request information + $env['Request Method'] = $_SERVER['REQUEST_METHOD'] ?? 'Unknown'; + $env['Request URI'] = $_SERVER['REQUEST_URI'] ?? 'CLI'; + $env['User Agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'; + $env['Remote IP'] = $_SERVER['REMOTE_ADDR'] ?? 'Unknown'; + + } catch (\Exception $e) { + $env['Error'] = $e->getMessage(); + } + + return $env; + } + + /** + * Format SQL query for display + */ + private function formatSqlQuery(string $query): string + { + // Remove debug comments + $query = preg_replace('#^/\*.*?\*/#', '', $query); + $query = trim($query); + + // Truncate very long queries but keep them readable + if (strlen($query) > 1000) { + return substr($query, 0, 1000) . "\n... [Query truncated - " . (strlen($query) - 1000) . " more characters]"; + } + + return $query; + } + + /** + * Override parent method to add database info and custom styling + */ + public function handle() + { + // Add TorrentPier-specific database information dynamically + try { + $this->addDataTable('Database Information', $this->getDatabaseInformation()); + } catch (\Exception $e) { + $this->addDataTable('Database Information', ['Error' => $e->getMessage()]); + } + + try { + $this->addDataTable('Recent SQL Queries', $this->getRecentSqlQueries()); + } catch (\Exception $e) { + $this->addDataTable('Recent SQL Queries', ['Error' => $e->getMessage()]); + } + + try { + $this->addDataTable('TorrentPier Environment', $this->getTorrentPierEnvironment()); + } catch (\Exception $e) { + $this->addDataTable('TorrentPier Environment', ['Error' => $e->getMessage()]); + } + + return parent::handle(); + } +} diff --git a/src/Whoops/README.md b/src/Whoops/README.md new file mode 100644 index 000000000..90f96f8df --- /dev/null +++ b/src/Whoops/README.md @@ -0,0 +1,131 @@ +# TorrentPier Whoops Enhanced Error Reporting + +This directory contains enhanced Whoops error handlers specifically designed for TorrentPier to provide better debugging information when database errors occur. + +## Features + +### Enhanced Database Error Reporting + +The enhanced Whoops handlers provide comprehensive database information when errors occur: + +1. **Current SQL Query** - Shows the exact query that caused the error +2. **Recent Query History** - Displays the last 5 SQL queries executed +3. **Database Connection Status** - Connection state, server info, database name +4. **Error Context** - PDO error codes, exception details, source location +5. **TorrentPier Environment** - Debug mode status, system information + +### Components + +#### EnhancedPrettyPageHandler + +Extends Whoops' default `PrettyPageHandler` to include: +- **Database Information** table with connection details and current query +- **Recent SQL Queries** table showing query history with timing +- **TorrentPier Environment** table with system status + +#### DatabaseErrorHandler + +Specialized handler that: +- Adds database context to exception stack frames +- Identifies database-related code in the call stack +- Collects comprehensive database state information +- Formats SQL queries for readable display + +## Usage + +The enhanced handlers are automatically activated when `DBG_USER` is enabled in TorrentPier configuration. + +### Automatic Integration + +```php +// In src/Dev.php - automatically configured +$prettyPageHandler = new \TorrentPier\Whoops\EnhancedPrettyPageHandler(); +``` + +### Database Error Logging + +Database errors are now automatically logged even when they occur in Nette Database layer: + +```php +// Enhanced error handling in Database.php +try { + $row = $result->fetch(); +} catch (\Exception $e) { + // Log the error including the query that caused it + $this->debugger->log_error($e); + throw $e; // Re-throw for Whoops display +} +``` + +## Error Information Displayed + +When a database error occurs, Whoops will now show: + +### Database Information +- Connection Status: Connected/Disconnected +- Database Server: Host and port information +- Selected Database: Current database name +- Database Engine: MySQL/PostgreSQL/etc. +- Total Queries: Number of queries executed +- Total Query Time: Cumulative execution time +- Current Query: The SQL that caused the error +- Last Database Error: Error code and message +- PDO Driver: Database driver information +- Server Version: Database server version + +### Recent SQL Queries +- **Query #1-5**: Last 5 queries executed + - SQL: Formatted query text + - Time: Execution time in seconds + - Source: File and line where query originated + - Info: Additional query information + - Memory: Memory usage if available + +### TorrentPier Environment +- Application Environment: local/production/etc. +- Debug Mode: Enabled/Disabled +- SQL Debug: Enabled/Disabled +- TorrentPier Version: Current version +- Config Loaded: Configuration status +- Cache System: Availability status +- Language System: Status and encoding +- Template System: Twig-based availability +- Execution Time: Request processing time +- Peak Memory: Maximum memory used +- Current Memory: Current memory usage +- Request Method: GET/POST/etc. +- Request URI: Current page +- User Agent: Browser information +- Remote IP: Client IP address + +## Configuration + +The enhanced handlers respect TorrentPier's debug configuration: + +- `DBG_USER`: Must be enabled to show enhanced error pages +- `SQL_DEBUG`: Enables SQL query logging and timing +- `APP_ENV`: Determines environment-specific features + +## Logging + +Database errors are now logged in multiple locations: + +1. **PHP Error Log**: Basic error message +2. **TorrentPier bb_log**: Detailed error with context (`database_errors.log`) +3. **Whoops Log**: Complete error details (`php_whoops.log`) + +## Security + +The enhanced handlers maintain security by: +- Only showing detailed information when `DBG_USER` is enabled +- Using Whoops' blacklist for sensitive data +- Logging detailed information to files (not user-accessible) +- Providing generic error messages to non-debug users + +## Backward Compatibility + +All enhancements are: +- **100% backward compatible** with existing TorrentPier code +- **Non-breaking** - existing error handling continues to work +- **Optional** - only activated in debug mode +- **Safe** - no security implications for production use \ No newline at end of file