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