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.
This commit is contained in:
Yury Pikhtarev 2025-06-20 16:52:42 +04:00
commit d467813447
No known key found for this signature in database
7 changed files with 981 additions and 40 deletions

View file

@ -105,7 +105,9 @@ switch ($mode) {
if (!$topic_id) {
bb_die($lang['NO_TOPIC_ID']);
}
$sql = "SELECT f.*, t.*
$sql = "SELECT
f.forum_id, f.forum_name, f.forum_status, f.forum_last_post_id, f.allow_reg_tracker,
t.topic_id, t.topic_title, t.topic_status, t.topic_type, t.topic_first_post_id, t.topic_last_post_id, t.topic_allow_robots
FROM " . BB_FORUMS . " f, " . BB_TOPICS . " t
WHERE t.topic_id = $topic_id
AND f.forum_id = t.forum_id
@ -119,8 +121,12 @@ switch ($mode) {
bb_simple_die($lang['NO_POST_ID']);
}
$select_sql = 'SELECT f.*, t.*, p.*';
$select_sql .= !$submit ? ', pt.*, u.username, u.user_id' : '';
// Use specific column names to avoid duplicate column errors
$select_sql = 'SELECT
f.forum_id, f.forum_name, f.forum_status, f.forum_last_post_id,
t.topic_id, t.topic_title, t.topic_status, t.topic_type, t.topic_first_post_id, t.topic_last_post_id,
p.post_id, p.poster_id, p.post_time, p.poster_rg_id, p.attach_rg_sig';
$select_sql .= !$submit ? ', pt.post_text, u.username, u.user_id' : '';
$from_sql = "FROM " . BB_POSTS . " p, " . BB_TOPICS . " t, " . BB_FORUMS . " f";
$from_sql .= !$submit ? ", " . BB_POSTS_TEXT . " pt, " . BB_USERS . " u" : '';
@ -150,9 +156,9 @@ if ($post_info = DB()->fetch_row($sql)) {
$is_auth = auth(AUTH_ALL, $forum_id, $userdata, $post_info);
if ($post_info['forum_status'] == FORUM_LOCKED && !$is_auth['auth_mod']) {
if ($post_info['forum_status'] == FORUM_LOCKED && !($is_auth['auth_mod'] ?? false)) {
bb_die($lang['FORUM_LOCKED']);
} elseif ($mode != 'newtopic' && $mode != 'new_rel' && $post_info['topic_status'] == TOPIC_LOCKED && !$is_auth['auth_mod']) {
} elseif ($mode != 'newtopic' && $mode != 'new_rel' && $post_info['topic_status'] == TOPIC_LOCKED && !($is_auth['auth_mod'] ?? false)) {
bb_die($lang['TOPIC_LOCKED']);
}
@ -170,9 +176,9 @@ if ($post_info = DB()->fetch_row($sql)) {
$switch_rg_sig = (bool)$post_info['attach_rg_sig'];
// Can this user edit/delete the post?
if ($post_info['poster_id'] != $userdata['user_id'] && !$is_auth['auth_mod']) {
if ($post_info['poster_id'] != $userdata['user_id'] && !($is_auth['auth_mod'] ?? false)) {
$auth_err = ($delete || $mode == 'delete') ? $lang['DELETE_OWN_POSTS'] : $lang['EDIT_OWN_POSTS'];
} elseif (!$post_data['last_post'] && !$is_auth['auth_mod'] && ($mode == 'delete' || $delete)) {
} elseif (!$post_data['last_post'] && !($is_auth['auth_mod'] ?? false) && ($mode == 'delete' || $delete)) {
$auth_err = $lang['CANNOT_DELETE_REPLIED'];
}
@ -195,9 +201,10 @@ if ($post_info = DB()->fetch_row($sql)) {
// The user is not authed, if they're not logged in then redirect
// them, else show them an error message
if (!$is_auth[$is_auth_type]) {
if (!isset($is_auth[$is_auth_type]) || !$is_auth[$is_auth_type]) {
if (!IS_GUEST) {
bb_die(sprintf($lang['SORRY_' . strtoupper($is_auth_type)], $is_auth[$is_auth_type . '_type']));
$auth_type_msg = isset($is_auth[$is_auth_type . '_type']) ? $is_auth[$is_auth_type . '_type'] : 'Unknown auth type';
bb_die(sprintf($lang['SORRY_' . strtoupper($is_auth_type)], $auth_type_msg));
}
switch ($mode) {
@ -274,7 +281,10 @@ $topic_has_new_posts = false;
if (!IS_GUEST && $mode != 'newtopic' && ($submit || $preview || $mode == 'quote' || $mode == 'reply') && isset($_COOKIE[COOKIE_TOPIC])) {
if ($topic_last_read = max((int)(@$tracking_topics[$topic_id]), (int)(@$tracking_forums[$forum_id]))) {
$sql = "SELECT p.*, pt.post_text, u.username, u.user_rank
$sql = "SELECT
p.post_id, p.poster_id, p.post_time, p.topic_id,
pt.post_text,
u.username, u.user_rank, u.user_id
FROM " . BB_POSTS . " p, " . BB_POSTS_TEXT . " pt, " . BB_USERS . " u
WHERE p.topic_id = " . (int)$topic_id . "
AND u.user_id = p.poster_id
@ -337,7 +347,7 @@ if (($delete || $mode == 'delete') && !$confirm) {
\TorrentPier\Legacy\Post::prepare_post($mode, $post_data, $error_msg, $username, $subject, $message);
if (!$error_msg) {
$topic_type = (isset($post_data['topic_type']) && $topic_type != $post_data['topic_type'] && !$is_auth['auth_sticky'] && !$is_auth['auth_announce']) ? $post_data['topic_type'] : $topic_type;
$topic_type = (isset($post_data['topic_type']) && $topic_type != $post_data['topic_type'] && !($is_auth['auth_sticky'] ?? false) && !($is_auth['auth_announce'] ?? false)) ? $post_data['topic_type'] : $topic_type;
\TorrentPier\Legacy\Post::submit_post($mode, $post_data, $return_message, $return_meta, $forum_id, $topic_id, $post_id, $topic_type, DB()->escape($username), DB()->escape($subject), DB()->escape($message), $update_post_time, $poster_rg_id, $attach_rg_sig, (int)$robots_indexing);
@ -511,7 +521,7 @@ if ($mode == 'newtopic' || ($mode == 'editpost' && $post_data['first_post'])) {
// Topic type selection
$template->assign_block_vars('switch_type_toggle', []);
if ($is_auth['auth_sticky']) {
if ($is_auth['auth_sticky'] ?? false) {
$topic_type_toggle .= '<label><input type="radio" name="topictype" value="' . POST_STICKY . '"';
if (isset($post_data['topic_type']) && ($post_data['topic_type'] == POST_STICKY || $topic_type == POST_STICKY)) {
$topic_type_toggle .= ' checked';
@ -519,7 +529,7 @@ if ($mode == 'newtopic' || ($mode == 'editpost' && $post_data['first_post'])) {
$topic_type_toggle .= ' /> ' . $lang['POST_STICKY'] . '</label>&nbsp;&nbsp;';
}
if ($is_auth['auth_announce']) {
if ($is_auth['auth_announce'] ?? false) {
$topic_type_toggle .= '<label><input type="radio" name="topictype" value="' . POST_ANNOUNCE . '"';
if (isset($post_data['topic_type']) && ($post_data['topic_type'] == POST_ANNOUNCE || $topic_type == POST_ANNOUNCE)) {
$topic_type_toggle .= ' checked';
@ -534,7 +544,7 @@ if ($mode == 'newtopic' || ($mode == 'editpost' && $post_data['first_post'])) {
//bt
$topic_dl_type = $post_info['topic_dl_type'] ?? 0;
if ($post_info['allow_reg_tracker'] && $post_data['first_post'] && ($topic_dl_type || $is_auth['auth_mod'])) {
if ($post_info['allow_reg_tracker'] && $post_data['first_post'] && ($topic_dl_type || ($is_auth['auth_mod'] ?? false))) {
$sql = "
SELECT tor.attach_id
FROM " . BB_POSTS . " p
@ -551,7 +561,7 @@ if ($post_info['allow_reg_tracker'] && $post_data['first_post'] && ($topic_dl_ty
$dl_type_name = 'topic_dl_type';
$dl_type_val = $topic_dl_type ? 1 : 0;
if (!$post_info['allow_reg_tracker'] && !$is_auth['auth_mod']) {
if (!$post_info['allow_reg_tracker'] && !($is_auth['auth_mod'] ?? false)) {
$dl_ds = ' disabled ';
$dl_hid = '<input type="hidden" name="topic_dl_type" value="' . $dl_type_val . '" />';
$dl_type_name = '';
@ -640,13 +650,13 @@ if ($mode == 'newtopic' || $post_data['first_post']) {
// Update post time
if ($mode == 'editpost' && $post_data['last_post'] && !$post_data['first_post']) {
$template->assign_vars([
'SHOW_UPDATE_POST_TIME' => ($is_auth['auth_mod'] || ($post_data['poster_post'] && $post_info['post_time'] + 3600 * 3 > TIMENOW)),
'SHOW_UPDATE_POST_TIME' => (($is_auth['auth_mod'] ?? false) || ($post_data['poster_post'] && $post_info['post_time'] + 3600 * 3 > TIMENOW)),
'UPDATE_POST_TIME_CHECKED' => ($post_data['poster_post'] && ($post_info['post_time'] + 3600 * 2 > TIMENOW)),
]);
}
// Topic review
if ($mode == 'reply' && $is_auth['auth_read']) {
if ($mode == 'reply' && ($is_auth['auth_read'] ?? false)) {
\TorrentPier\Legacy\Post::topic_review($topic_id);
}

View file

@ -49,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 = [];
@ -186,6 +187,7 @@ class Database
}
$this->debugger->debug('stop');
$this->last_query = $this->cur_query; // Preserve for error reporting
$this->cur_query = null;
if ($this->inited) {
@ -239,6 +241,7 @@ class Database
return false;
}
try {
$row = $result->fetch();
if (!$row) {
return false;
@ -253,6 +256,54 @@ class Database
}
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;
}
}
/**
@ -272,7 +323,22 @@ class Database
$this->trigger_error();
}
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;
}
}
/**
@ -285,12 +351,49 @@ class Database
}
$rowset = [];
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;
}

View file

@ -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 = '';
@ -361,6 +362,50 @@ class DatabaseDebugger
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);
}
/**
* Set slow query marker
*/

View file

@ -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 .= '<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
$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 .= '<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 class="copyElement" data-clipboard-target="#' . $id . '" title="Copy to clipboard" style="color: rgb(128,128,128); letter-spacing: -1px;">' . $perc . '</span>&nbsp;'
. '<span style="letter-spacing: 0;" id="' . $id . '">' . $sql . '</span>'

View 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';
}
}

View 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('&nbsp;', ' ', humn_size(sys('mem_peak')));
$env['Current Memory'] = str_replace('&nbsp;', ' ', 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
View 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