diff --git a/app/Views/errors/html/debug.css b/app/Views/errors/html/debug.css index b8539a420221..2072418db1bd 100644 --- a/app/Views/errors/html/debug.css +++ b/app/Views/errors/html/debug.css @@ -46,9 +46,16 @@ p.lead { .header .container { padding: 1rem; } +.header-title { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; +} .header h1 { font-size: 2.5rem; font-weight: 500; + min-width: 0; } .header p { font-size: 1.2rem; @@ -65,8 +72,58 @@ p.lead { display: inline; } +.error-report { + flex: 0 0 auto; + margin-top: 0.35rem; +} + +.error-report-button { + align-items: center; + background: rgba(255,255,255,0.35); + border: 1px solid rgba(0,0,0,0.14); + border-radius: 5px; + box-sizing: border-box; + color: var(--main-text-color); + cursor: pointer; + display: inline-flex; + font-size: 0.82rem; + font-weight: 500; + gap: 0.35rem; + height: 1.875rem; + justify-content: center; + line-height: 1; + padding: 0 0.65rem; + transition: background-color 160ms ease-in-out, border-color 160ms ease-in-out, color 160ms ease-in-out, box-shadow 160ms ease-in-out; + white-space: nowrap; + width: 7.15rem; +} + +.error-report-button:hover { + background: rgba(255,255,255,0.6); + border-color: rgba(0,0,0,0.22); + color: var(--dark-text-color); + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.error-report-button:focus-visible { + border-color: rgba(0,0,0,0.35); + outline: 0; + box-shadow: 0 0 0 2px rgba(220,72,20,0.16); +} + +.error-report-button:active { + background: rgba(255,255,255,0.75); +} + +.error-report-icon { + flex: 0 0 auto; + height: 0.72rem; + width: 0.72rem; +} + .environment { background: var(--brand-primary-color); + box-sizing: border-box; color: var(--main-bg-color); text-align: center; padding: calc(4px + 0.2083vw); @@ -75,6 +132,26 @@ p.lead { position: fixed; } +@media (max-width: 40rem) { + .header { + margin-top: 0; + } + + .header p { + font-size: 1.1rem; + line-height: 1.45; + margin-top: 0.35rem; + overflow-wrap: anywhere; + } + + .environment { + font-size: 0.9rem; + line-height: 1.25; + padding: 0.45rem 0.75rem; + position: static; + } +} + .source { background: #343434; color: var(--light-text-color); diff --git a/app/Views/errors/html/debug.js b/app/Views/errors/html/debug.js index 99199cac872c..320f30cdb426 100644 --- a/app/Views/errors/html/debug.js +++ b/app/Views/errors/html/debug.js @@ -114,3 +114,53 @@ function toggle(elem) return false; } + +function copyErrorReport(reportId, button) +{ + var report = document.getElementById(reportId); + + if (navigator.clipboard && window.isSecureContext) + { + navigator.clipboard + .writeText(report.value) + .then(() => showCopiedButton(button)) + .catch(() => copyErrorReportWithFallback(report.value, button)); + + return false; + } + + copyErrorReportWithFallback(report.value, button); + + return false; +} + +function copyErrorReportWithFallback(text, button) +{ + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.left = '-1000px'; + + document.body.appendChild(textarea); + textarea.select(); + + if (document.execCommand('copy')) + { + showCopiedButton(button); + } + + document.body.removeChild(textarea); +} + +function showCopiedButton(button) +{ + button.defaultHtml = button.defaultHtml || button.innerHTML; + button.innerHTML = 'Copied!'; + + window.clearTimeout(button.copyResetTimer); + button.copyResetTimer = window.setTimeout(() => { + button.innerHTML = button.defaultHtml; + }, 1500); +} diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php index 2c4e00911365..eec30a3037dd 100644 --- a/app/Views/errors/html/error_exception.php +++ b/app/Views/errors/html/error_exception.php @@ -3,6 +3,7 @@ use CodeIgniter\CodeIgniter; $errorId = uniqid('error', true); +$copyableErrorReportId = $errorId . 'copyableErrorReport'; ?> @@ -30,7 +31,38 @@ Environment:
-

getCode() ? ' #' . $exception->getCode() : '') ?>

+
+

getCode() ? ' #' . $exception->getCode() : '') ?>

+
+ + +
+

getMessage())) ?> getMessage())) ?>" @@ -342,8 +374,9 @@ setStatusCode(http_response_code()); + $response = service('response'); + $responseStatusCode = http_response_code(); + $response->setStatusCode($responseStatusCode === false || $responseStatusCode === 0 ? $code : $responseStatusCode); ?>

diff --git a/app/Views/errors/html/error_report.php b/app/Views/errors/html/error_report.php new file mode 100644 index 000000000000..5927c4720d3a --- /dev/null +++ b/app/Views/errors/html/error_report.php @@ -0,0 +1,127 @@ +setStatusCode($code); + +$report = [ + '# ' . $reportTitle, + '', + '## Exception', + '', + '- Type: ' . $type, + '- Status Code: ' . $code, + '- Status: ' . $reportResponse->getReasonPhrase(), + $messageLines ? '- Message:' : '- Message: ' . $reportMessage, +]; + +if ($messageLines) { + $report[] = ''; + $report[] = '```text'; + $report[] = $reportMessage; + $report[] = '```'; +} + +$report[] = ''; +$report[] = '## Environment'; +$report[] = ''; +$report[] = '- PHP: ' . PHP_VERSION; +$report[] = '- CodeIgniter: ' . CodeIgniter::CI_VERSION; +$report[] = '- Environment: ' . ENVIRONMENT; +$report[] = '- SAPI: ' . PHP_SAPI; +$report[] = '- Time: ' . date('Y-m-d H:i:s e'); +$report[] = '- Memory Usage: ' . number_format(memory_get_usage(true) / 1024 / 1024, 2) . ' MB'; + +$reportRequest = service('request'); + +if ($reportRequest instanceof IncomingRequest) { + $reportPath = '/' . ltrim($reportRequest->getPath(), '/'); + $reportUri = $reportRequest->getUri(); + $reportUrl = $reportPath; + + if ($reportUri->getHost() !== '') { + $reportUrl = URI::createURIString( + $reportUri->getScheme(), + $reportUri->getHost() . ($reportUri->getPort() === null ? '' : ':' . $reportUri->getPort()), + $reportPath, + ); + } + + $report[] = ''; + $report[] = '## Request'; + $report[] = ''; + $report[] = '- Method: ' . $reportRequest->getMethod(); + $report[] = '- Path: ' . $reportPath; + $report[] = '- URL: ' . $reportUrl; + $report[] = '- User Agent: ' . $reportRequest->getUserAgent()->getAgentString(); +} + +$report[] = ''; +$report[] = '## Source'; +$report[] = ''; +$report[] = '`' . clean_path($file) . ':' . $line . '`'; + +if (is_file($file) && is_readable($file)) { + $sourceLines = file($file, FILE_IGNORE_NEW_LINES); + + if ($sourceLines !== false) { + $startLine = max($line - 5, 1); + $endLine = min($line + 5, count($sourceLines)); + + $report[] = ''; + $report[] = '```php'; + + for ($sourceLine = $startLine; $sourceLine <= $endLine; $sourceLine++) { + $report[] = sprintf( + '%s%4d %s', + $sourceLine === $line ? '>' : ' ', + $sourceLine, + $sourceLines[$sourceLine - 1], + ); + } + + $report[] = '```'; + } +} + +$previousException = $exception->getPrevious(); + +if ($previousException instanceof Throwable) { + $report[] = ''; + $report[] = '## Previous Exceptions'; + + while ($previousException instanceof Throwable) { + $report[] = '* ' . $previousException::class . ' - ' . $previousException->getMessage(); + $report[] = ' ' . clean_path($previousException->getFile()) . ':' . $previousException->getLine(); + + $previousException = $previousException->getPrevious(); + } +} + +if ($trace !== []) { + $report[] = ''; + $report[] = '## Stack Trace'; + $report[] = ''; + $report[] = '```text'; + + foreach (array_slice($trace, 0, 50) as $reportIndex => $reportRow) { + $reportLocation = isset($reportRow['file'], $reportRow['line']) + ? clean_path($reportRow['file']) . ':' . $reportRow['line'] + : '{PHP internal code}'; + $reportCall = ($reportRow['class'] ?? '') . ($reportRow['type'] ?? '') . ($reportRow['function'] ?? ''); + + $report[] = $reportIndex . ' ' . $reportLocation . ($reportCall === '' ? '' : ' ' . $reportCall . '()'); + } + + $report[] = '```'; +} + +echo esc(implode("\n", $report)) . "\n"; diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index 43086860b0d6..0b44a9d83b23 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -16,9 +16,13 @@ use App\Controllers\Home; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\IniTestTrait; use CodeIgniter\Test\StreamFilterTrait; +use Config\App; use Config\Exceptions as ExceptionsConfig; use Config\Services; use PHPUnit\Framework\Attributes\Group; @@ -104,6 +108,65 @@ public function testCollectVars(): void foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) { $this->assertArrayHasKey($key, $vars); } + + $this->assertArrayNotHasKey('copyableErrorReport', $vars); + } + + public function testCopyErrorReportIncludesPreviousExceptions(): void + { + $previous = new RuntimeException('Root cause.'); + $exception = new RuntimeException('Top level.', 0, $previous); + + $report = $this->extractCopyableErrorReport($this->renderHtmlException($exception)); + + $this->assertStringContainsString('## Previous Exceptions', $report); + $this->assertStringContainsString('* CodeIgniter\Exceptions\RuntimeException - Root cause.', $report); + } + + public function testCopyErrorReportOmitsSensitiveRequestDataAndTraceArgs(): void + { + $exception = $this->createExceptionWithSensitiveTraceArgument(); + + $_COOKIE['debug_cookie'] = 'cookie-secret'; + $_POST['debug_post'] = 'post-secret'; + + try { + $report = $this->extractCopyableErrorReport($this->renderHtmlException($exception)); + + $this->assertStringNotContainsString('secret-token', $report); + $this->assertStringNotContainsString('cookie-secret', $report); + $this->assertStringNotContainsString('post-secret', $report); + $this->assertStringNotContainsString('$_COOKIE', $report); + $this->assertStringNotContainsString('$_POST', $report); + } finally { + unset($_COOKIE['debug_cookie'], $_POST['debug_post']); + } + } + + public function testCopyErrorReportOmitsQueryStringFromUrl(): void + { + $config = new App(); + $secret = 'query-secret'; + $token = '?token='; + $request = new IncomingRequest( + $config, + new SiteURI($config, '/orders?token=' . $secret, 'example.test', 'https'), + null, + new UserAgent(), + ); + + Services::injectMock('request', $request); + + try { + $report = $this->extractCopyableErrorReport($this->renderHtmlException(new RuntimeException('Query test.'))); + + $this->assertStringContainsString('- Path: /orders', $report); + $this->assertStringContainsString('- URL: https://example.test/orders', $report); + $this->assertStringNotContainsString($secret, $report); + $this->assertStringNotContainsString($token, $report); + } finally { + $this->resetServices(); + } } public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void @@ -141,6 +204,27 @@ public function testHandleWebPageNotFoundExceptionAcceptHTML(): void $this->assertStringContainsString('404 - Page Not Found', (string) $output); } + public function testHandleWebRuntimeExceptionAcceptHTMLIncludesCopyErrorReport(): void + { + $output = $this->renderHtmlException(new RuntimeException('Something went wrong.')); + $report = $this->extractCopyableErrorReport($output); + + $this->assertStringContainsString('Copy Details', $output); + $this->assertStringContainsString('# Something went wrong.', $report); + + foreach (['## Exception', '## Environment', '## Request', '## Source', '## Stack Trace'] as $section) { + $this->assertStringContainsString($section, $report); + } + } + + public function testHandleWebRuntimeExceptionEscapesCopyErrorReport(): void + { + $output = $this->renderHtmlException(new RuntimeException('')); + + $this->assertStringNotContainsString('', $output); + $this->assertStringContainsString('</textarea><script>alert(1)</script>', $output); + } + public function testHandleCLIPageNotFoundException(): void { $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); @@ -385,4 +469,35 @@ public function testSanitizeDataWithScalars(): void $this->assertFalse($sanitizeData(false)); $this->assertNull($sanitizeData(null)); } + + private function createExceptionWithSensitiveTraceArgument(): RuntimeException + { + return new RuntimeException('Trace argument test.'); + } + + private function extractCopyableErrorReport(string $output): string + { + $this->assertSame(1, preg_match('#]*>\K.*?(?=)#s', $output, $matches)); + + return html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + private function renderHtmlException(RuntimeException $exception): string + { + $this->backupIniValues([ + 'highlight.comment', 'highlight.default', 'highlight.html', 'highlight.keyword', 'highlight.string', + ]); + + $render = self::getPrivateMethodInvoker($this->handler, 'render'); + + ob_start(); + + try { + $render($exception, 500, APPPATH . 'Views/errors/html/error_exception.php'); + + return ob_get_clean(); + } finally { + $this->restoreIniValues(); + } + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 7670598cde1f..db0d44479842 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -234,6 +234,11 @@ Others - Added ``$db->getLastException()`` which returns the typed exception even when ``DBDebug`` is ``false``. See :ref:`database-get-last-exception`. - Added ``DatabaseException::getDatabaseCode()`` returning the native driver error code as ``int|string``; ``getCode()`` is constrained to ``int`` by PHP's ``Throwable`` interface and cannot carry string SQLSTATE codes. +Debug +===== + +- Added a **Copy Details** button to detailed HTML exception pages. + Model ===== diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index d2738db27158..dbb95275ec0e 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -76,6 +76,18 @@ Config - app/Config/Mimes.php - ``Config\Mimes::$mimes`` added a new key ``md`` for Markdown files. +Error Views +----------- + +- app/Views/errors/html/debug.css + - Added styles for the **Copy Details** button. +- app/Views/errors/html/debug.js + - Added clipboard handling for the **Copy Details** button. +- app/Views/errors/html/error_exception.php + - Added a **Copy Details** button to detailed HTML exception pages. +- app/Views/errors/html/error_report.php + - Added a Markdown error report partial used by the **Copy Details** button. + All Changes ===========