mirror of
https://github.com/torrentpier/torrentpier
synced 2025-08-19 21:03:54 -07:00
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
This commit is contained in:
parent
7e723d6ad8
commit
7aed6bc7d8
8 changed files with 1150 additions and 45 deletions
|
@ -26,9 +26,9 @@ $posts_without_attach = $topics_without_attach = [];
|
||||||
|
|
||||||
DB()->query("
|
DB()->query("
|
||||||
CREATE TEMPORARY TABLE $tmp_attach_tbl (
|
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))
|
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");
|
DB()->add_shutdown_query("DROP TEMPORARY TABLE IF EXISTS $tmp_attach_tbl");
|
||||||
|
|
||||||
|
|
|
@ -1437,6 +1437,9 @@ function redirect($url)
|
||||||
|
|
||||||
$redirect_url = $server_protocol . $server_name . $server_port . $script_name . preg_replace('#^\/?(.*?)\/?$#', '/\1', $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
|
// Behave as per HTTP/1.1 spec for others
|
||||||
header('Location: ' . $redirect_url, response_code: 301);
|
header('Location: ' . $redirect_url, response_code: 301);
|
||||||
exit;
|
exit;
|
||||||
|
|
|
@ -10,16 +10,10 @@
|
||||||
namespace TorrentPier\Database;
|
namespace TorrentPier\Database;
|
||||||
|
|
||||||
use Nette\Database\Connection;
|
use Nette\Database\Connection;
|
||||||
|
use Nette\Database\Conventions\DiscoveredConventions;
|
||||||
use Nette\Database\Explorer;
|
use Nette\Database\Explorer;
|
||||||
use Nette\Database\ResultSet;
|
use Nette\Database\ResultSet;
|
||||||
use Nette\Database\Row;
|
|
||||||
use Nette\Database\Table\Selection;
|
|
||||||
use Nette\Database\Structure;
|
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
|
* Modern Database class using Nette Database with backward compatibility
|
||||||
|
@ -55,6 +49,7 @@ class Database
|
||||||
public float $sql_timetotal = 0;
|
public float $sql_timetotal = 0;
|
||||||
public float $cur_query_time = 0;
|
public float $cur_query_time = 0;
|
||||||
public ?string $cur_query = null;
|
public ?string $cur_query = null;
|
||||||
|
public ?string $last_query = null; // Store last executed query for error reporting
|
||||||
|
|
||||||
public array $shutdown = [];
|
public array $shutdown = [];
|
||||||
public array $DBS = [];
|
public array $DBS = [];
|
||||||
|
@ -64,8 +59,6 @@ class Database
|
||||||
*/
|
*/
|
||||||
private function __construct(array $cfg_values, string $server_name = 'db')
|
private function __construct(array $cfg_values, string $server_name = 'db')
|
||||||
{
|
{
|
||||||
global $DBS;
|
|
||||||
|
|
||||||
$this->cfg = array_combine($this->cfg_keys, $cfg_values);
|
$this->cfg = array_combine($this->cfg_keys, $cfg_values);
|
||||||
$this->db_server = $server_name;
|
$this->db_server = $server_name;
|
||||||
|
|
||||||
|
@ -188,12 +181,13 @@ class Database
|
||||||
// Initialize affected rows to 0 (most queries don't affect rows)
|
// Initialize affected rows to 0 (most queries don't affect rows)
|
||||||
$this->last_affected_rows = 0;
|
$this->last_affected_rows = 0;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->debugger->log_error();
|
$this->debugger->log_error($e);
|
||||||
$this->result = null;
|
$this->result = null;
|
||||||
$this->last_affected_rows = 0;
|
$this->last_affected_rows = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->debugger->debug('stop');
|
$this->debugger->debug('stop');
|
||||||
|
$this->last_query = $this->cur_query; // Preserve for error reporting
|
||||||
$this->cur_query = null;
|
$this->cur_query = null;
|
||||||
|
|
||||||
if ($this->inited) {
|
if ($this->inited) {
|
||||||
|
@ -247,20 +241,69 @@ class Database
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = $result->fetch();
|
try {
|
||||||
if (!$row) {
|
$row = $result->fetch();
|
||||||
return false;
|
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();
|
$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 = [];
|
$rowset = [];
|
||||||
while ($row = $result->fetch()) {
|
|
||||||
// Convert Row to array for backward compatibility
|
try {
|
||||||
// Nette Database Row extends ArrayHash, so we can cast it to array
|
while ($row = $result->fetch()) {
|
||||||
$rowArray = (array)$row;
|
// Convert Row to array for backward compatibility
|
||||||
$rowset[] = $field_name ? ($rowArray[$field_name] ?? null) : $rowArray;
|
// 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;
|
return $rowset;
|
||||||
|
@ -423,8 +518,8 @@ class Database
|
||||||
$dont_escape = $data_already_escaped;
|
$dont_escape = $data_already_escaped;
|
||||||
$check_type = $check_data_type_in_escape;
|
$check_type = $check_data_type_in_escape;
|
||||||
|
|
||||||
if (empty($input_ary) || !is_array($input_ary)) {
|
if (empty($input_ary)) {
|
||||||
$this->trigger_error(__FUNCTION__ . ' - wrong params: $input_ary');
|
$this->trigger_error(__FUNCTION__ . ' - wrong params: $input_ary is empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($query_type == 'INSERT') {
|
if ($query_type == 'INSERT') {
|
||||||
|
@ -549,9 +644,28 @@ class Database
|
||||||
if ($this->connection) {
|
if ($this->connection) {
|
||||||
try {
|
try {
|
||||||
$pdo = $this->connection->getPdo();
|
$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 [
|
return [
|
||||||
'code' => $pdo->errorCode(),
|
'code' => $errorCode,
|
||||||
'message' => implode(': ', $pdo->errorInfo())
|
'message' => $message
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return ['code' => $e->getCode(), 'message' => $e->getMessage()];
|
return ['code' => $e->getCode(), 'message' => $e->getMessage()];
|
||||||
|
@ -753,7 +867,91 @@ class Database
|
||||||
public function trigger_error(string $msg = 'Database Error'): void
|
public function trigger_error(string $msg = 'Database Error'): void
|
||||||
{
|
{
|
||||||
$error = $this->sql_error();
|
$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')) {
|
if (function_exists('bb_die')) {
|
||||||
bb_die($error_msg);
|
bb_die($error_msg);
|
||||||
|
@ -797,9 +995,9 @@ class Database
|
||||||
/**
|
/**
|
||||||
* Log error (delegated to debugger)
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -29,6 +29,7 @@ class DatabaseDebugger
|
||||||
// Debug storage
|
// Debug storage
|
||||||
public array $dbg = [];
|
public array $dbg = [];
|
||||||
public int $dbg_id = 0;
|
public int $dbg_id = 0;
|
||||||
|
public array $legacy_queries = []; // Track queries that needed legacy compatibility fixes
|
||||||
|
|
||||||
// Explain functionality
|
// Explain functionality
|
||||||
public string $explain_hold = '';
|
public string $explain_hold = '';
|
||||||
|
@ -278,11 +279,131 @@ class DatabaseDebugger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log error
|
* 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_details = [];
|
||||||
error_log("Database Error: " . $error['message'] . " Query: " . $this->db->cur_query);
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
38
src/Dev.php
38
src/Dev.php
|
@ -129,9 +129,9 @@ class Dev
|
||||||
private function getWhoopsOnPage(): void
|
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 (config()->get('whoops.blacklist', []) as $key => $secrets) {
|
||||||
foreach ($secrets as $secret) {
|
foreach ($secrets as $secret) {
|
||||||
$prettyPageHandler->blacklist($key, $secret);
|
$prettyPageHandler->blacklist($key, $secret);
|
||||||
|
@ -195,9 +195,32 @@ class Dev
|
||||||
public function getSqlLogInstance(): string
|
public function getSqlLogInstance(): string
|
||||||
{
|
{
|
||||||
$log = '';
|
$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 .= '<div style="background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 10px; margin-bottom: 10px; border-radius: 4px;">'
|
||||||
|
. '<strong>⚠️ Legacy Query Warning:</strong> '
|
||||||
|
. $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.'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Get debug information from new database system
|
// Get debug information from new database system
|
||||||
$server_names = \TorrentPier\Database\DatabaseFactory::getServerNames();
|
|
||||||
foreach ($server_names as $srv_name) {
|
foreach ($server_names as $srv_name) {
|
||||||
try {
|
try {
|
||||||
$db_obj = \TorrentPier\Database\DatabaseFactory::getInstance($srv_name);
|
$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_plain = !empty($dbg['info_plain']) ? $dbg['info_plain'] . ' [' . $dbg['src'] . ']' : $dbg['src'];
|
||||||
$info = !empty($dbg['info']) ? $dbg['info'] . ' [' . $dbg['src'] . ']' : $dbg['src'];
|
$info = !empty($dbg['info']) ? $dbg['info'] . ' [' . $dbg['src'] . ']' : $dbg['src'];
|
||||||
|
|
||||||
$log .= '<div onclick="$(this).toggleClass(\'sqlHighlight\');" class="sqlLogRow" title="' . htmlspecialchars($info_plain) . '">'
|
// 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 ? '<span style="color: #dc3545; font-weight: bold; margin-right: 8px;">[LEGACY]</span>' : '';
|
||||||
|
|
||||||
|
$log .= '<div onclick="$(this).toggleClass(\'sqlHighlight\');" class="' . $rowClass . '" title="' . htmlspecialchars($info_plain) . '"' . $rowStyle . '>'
|
||||||
|
. $legacyWarning
|
||||||
. '<span style="letter-spacing: -1px;">' . $time . ' </span>'
|
. '<span style="letter-spacing: -1px;">' . $time . ' </span>'
|
||||||
. '<span class="copyElement" data-clipboard-target="#' . $id . '" title="Copy to clipboard" style="color: rgb(128,128,128); letter-spacing: -1px;">' . $perc . '</span> '
|
. '<span class="copyElement" data-clipboard-target="#' . $id . '" title="Copy to clipboard" style="color: rgb(128,128,128); letter-spacing: -1px;">' . $perc . '</span> '
|
||||||
. '<span style="letter-spacing: 0;" id="' . $id . '">' . $sql . '</span>'
|
. '<span style="letter-spacing: 0;" id="' . $id . '">' . $sql . '</span>'
|
||||||
|
|
353
src/Whoops/DatabaseErrorHandler.php
Normal file
353
src/Whoops/DatabaseErrorHandler.php
Normal file
|
@ -0,0 +1,353 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TorrentPier – Bull-powered BitTorrent tracker engine
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) 2005-2025 TorrentPier (https://torrentpier.com)
|
||||||
|
* @link https://github.com/torrentpier/torrentpier for the canonical source repository
|
||||||
|
* @license https://github.com/torrentpier/torrentpier/blob/master/LICENSE MIT License
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace TorrentPier\Whoops;
|
||||||
|
|
||||||
|
use Whoops\Handler\Handler;
|
||||||
|
use Whoops\Handler\HandlerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database Error Handler for Whoops
|
||||||
|
*
|
||||||
|
* Enhances error reporting by adding database query information,
|
||||||
|
* recent SQL activity, and database error details to the error output.
|
||||||
|
*/
|
||||||
|
class DatabaseErrorHandler extends Handler implements HandlerInterface
|
||||||
|
{
|
||||||
|
private bool $addToOutput = true;
|
||||||
|
private bool $includeQueryHistory = true;
|
||||||
|
private int $maxQueryHistory = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the exception and add database information
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (!$this->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';
|
||||||
|
}
|
||||||
|
}
|
269
src/Whoops/EnhancedPrettyPageHandler.php
Normal file
269
src/Whoops/EnhancedPrettyPageHandler.php
Normal file
|
@ -0,0 +1,269 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TorrentPier – Bull-powered BitTorrent tracker engine
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) 2005-2025 TorrentPier (https://torrentpier.com)
|
||||||
|
* @link https://github.com/torrentpier/torrentpier for the canonical source repository
|
||||||
|
* @license https://github.com/torrentpier/torrentpier/blob/master/LICENSE MIT License
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace TorrentPier\Whoops;
|
||||||
|
|
||||||
|
use Whoops\Handler\PrettyPageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced PrettyPageHandler for TorrentPier
|
||||||
|
*
|
||||||
|
* Extends Whoops' default handler to include database query information
|
||||||
|
* and other TorrentPier-specific debugging details in the error output.
|
||||||
|
*/
|
||||||
|
class EnhancedPrettyPageHandler extends PrettyPageHandler
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
// Add TorrentPier-specific database information
|
||||||
|
// Note: We add these during handle() to ensure they're fresh and available
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive database information
|
||||||
|
*/
|
||||||
|
private function getDatabaseInformation(): array
|
||||||
|
{
|
||||||
|
$info = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get main database instance information
|
||||||
|
if (function_exists('DB')) {
|
||||||
|
$db = DB();
|
||||||
|
|
||||||
|
$info['Connection Status'] = $db->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();
|
||||||
|
}
|
||||||
|
}
|
131
src/Whoops/README.md
Normal file
131
src/Whoops/README.md
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue