handleException(...)); } /** * Formats and echoes the exception as XHTML. * * @param \Throwable $exception The throwable object. */ public function echoExceptionWeb(\Throwable $exception) { $this->sendStatusHeaders($exception); $this->writeLogEntries($exception, self::CONTEXT_WEB); $content = $this->getContent($exception); $css = $this->getStylesheet(); $js = $this->getJavascript(); echo << TYPO3 Exception $content HTML; } /** * Formats and echoes the exception for the command line * * @param \Throwable $exception The throwable object. */ public function echoExceptionCLI(\Throwable $exception) { $filePathAndName = $exception->getFile(); $exceptionCodeNumber = $exception->getCode() > 0 ? '#' . $exception->getCode() . ': ' : ''; $this->writeLogEntries($exception, self::CONTEXT_CLI); echo LF . 'Uncaught TYPO3 Exception ' . $exceptionCodeNumber . $exception->getMessage() . LF; echo 'thrown in file ' . $filePathAndName . LF; echo 'in line ' . $exception->getLine() . LF . LF; die(1); } /** * Generates the HTML for the error output. */ protected function getContent(\Throwable $throwable): string { $content = ''; // exceptions can be chained // for easier debugging, all exceptions are displayed to the developer $throwables = $this->getAllThrowables($throwable); $count = count($throwables); foreach ($throwables as $position => $e) { $content .= $this->getSingleThrowableContent($e, $position + 1, $count); } $exceptionInfo = ''; if ($throwable->getCode() > 0) { $documentationLink = Typo3Information::URL_EXCEPTION . 'debug/' . $throwable->getCode(); $exceptionInfo = <<
Get help in the TYPO3 Documentation

If you need help solving this exception, you can have a look at the TYPO3 Documentation. There you can find solutions provided by the TYPO3 community. Once you have found a solution to the problem, help others by contributing to the documentation page.

Find a solution for this exception in the TYPO3 Documentation.

INFO; } $typo3Logo = $this->getTypo3LogoAsSvg(); try { // This outside dependency class is always loaded before the exception handler is setup. // So it is safe to access without affecting the output of this handler. $projectPath = Environment::getProjectPath() . DIRECTORY_SEPARATOR; } catch (\Throwable) { // just in case something goes wrong. $projectPath = ''; } $projectPathEscaped = $this->escapeHtml($projectPath); return <<
$typo3Logo

Whoops, looks like something went wrong.

$exceptionInfo
$content
HTML; } /** * Renders the HTML for a single throwable. */ protected function getSingleThrowableContent(\Throwable $throwable, int $index, int $total): string { $exceptionTitle = get_class($throwable); $exceptionCode = $throwable->getCode() ? '#' . $throwable->getCode() . ' ' : ''; $exceptionMessage = $this->escapeHtml($throwable->getMessage()); // The trace does not contain the step where the exception is thrown. // To display it as well it is added manually to the trace. $trace = $throwable->getTrace(); array_unshift($trace, [ 'file' => $throwable->getFile(), 'line' => $throwable->getLine(), 'args' => [], ]); $backtraceCode = $this->getBacktraceCode($trace); return <<

({$index}/{$total}) {$exceptionCode}{$exceptionTitle}

{$exceptionMessage}

{$backtraceCode}
HTML; } /** * Generates the stylesheet needed to display the error page. */ protected function getStylesheet(): string { return << *:first-child { margin-top: 0; } .exception-page .trace-step > *:last-child { margin-bottom: 0; } .exception-page .trace-step:nth-child(even) { background-color: #fafafa; } .exception-page .trace-step:last-child { border-bottom: none; } .exception-page .copy-button { cursor: pointer; border: 0.1rem solid transparent; background-color: transparent; padding: 0; margin-left: 1rem; } .exception-page .copy-button:hover { border: 0.1rem solid #b9b9b9; } .exception-page #stacktrace-action-buttons { display: inline-flex; justify-content: center; gap: 0.5rem; margin-top: 1rem; } .exception-page .stacktrace-action-button { cursor: pointer; padding: 0.5rem; -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: rgba(0,0,0,0); box-sizing: border-box; background-color: color(srgb 0.97 0.97 0.97); border: 1px solid color(srgb 0.75 0.75 0.75); border-radius: .75em; color: color(srgb 0.1 0.1 0.1); display: inline-flex; font-weight: 400; gap: .35em; justify-content: center; outline-offset: 0; text-decoration: none; --typo3-transition-color: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,opacity .15s ease-in-out; transition: var(--typo3-transition-color); user-select: none; vertical-align: middle; white-space: nowrap; margin-bottom: 0; margin-top: 0; } pre.plaintextFallback { margin: 2rem auto; border: 1px solid black; max-height: 250px; font-size: 0.8em; padding: 1rem; } STYLESHEET; } /** * Returns JavaScript functionality. Loaded from a TypeScript build. * It is loaded inline, to not need to load additional URIs or build routes to assets. * Also, it does not use ES6 module loading to be light-weight and dependency free. */ protected function getJavascript(): string { return file_get_contents(__DIR__ . '/../../Resources/Public/JavaScript/utility/debug-exception-handler-service.js'); } /** * Renders the backtrace as HTML. */ protected function getBacktraceCode(array $trace): string { $content = ''; foreach ($trace as $step) { $content .= '
'; $args = $this->flattenArgs($step['args'] ?? []); if (isset($step['function'])) { $content .= '
' . sprintf( 'at %s%s%s(%s)', $step['class'] ?? '', $step['type'] ?? '', $step['function'], $this->formatArgs($args) ) . '
'; } if (isset($step['file']) && isset($step['line'])) { $content .= $this->getCodeSnippet($step['file'], $step['line']); } $content .= '
'; } return $content; } /** * Returns a code snippet from the specified file. * * @param string $filePathAndName Absolute path and file name of the PHP file * @param int $lineNumber Line number defining the center of the code snippet * @return string The code snippet */ protected function getCodeSnippet(string $filePathAndName, int $lineNumber): string { $showLinesAround = 4; $content = '
'; $content .= '
' . $this->formatPath($filePathAndName, $lineNumber) . '
'; if (@file_exists($filePathAndName)) { $phpFile = @file($filePathAndName); if (is_array($phpFile)) { $startLine = $lineNumber > $showLinesAround ? $lineNumber - $showLinesAround : 1; $phpFileCount = count($phpFile); $endLine = $lineNumber < $phpFileCount - $showLinesAround ? $lineNumber + $showLinesAround + 1 : $phpFileCount + 1; if ($endLine > $startLine) { $content .= '
'; $content .= '
';

                    for ($line = $startLine; $line < $endLine; $line++) {
                        $codeLine = str_replace("\t", ' ', $phpFile[$line - 1]);
                        $spanClass = '';
                        if ($line === $lineNumber) {
                            $spanClass = 'highlight';
                        }

                        $content .= '' . $this->escapeHtml($codeLine) . '';
                    }

                    $content .= '
'; $content .= '
'; } } } $content .= '
'; return $content; } /** * Formats a path adding a line number. * * @param string $path The full path of the file. * @param int $line The line number. */ protected function formatPath(string $path, int $line): string { // "data-lineno" is evaluated by debug-exception-handler-service.js return sprintf( 'in %s%s', $line > 0 ? $line : 1, $this->escapeHtml($path), $line > 0 ? ' line ' . $line : '' ); } /** * Formats the arguments of a method call. * * @param array $args The flattened args of method/function call */ protected function formatArgs(array $args): string { $result = []; foreach ($args as $key => $item) { if ($item[0] === 'object') { $formattedValue = sprintf('object(%s)', $item[1]); } elseif ($item[0] === 'array') { $formattedValue = sprintf('array(%s)', is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]); } elseif ($item[0] === 'null') { $formattedValue = 'null'; } elseif ($item[0] === 'boolean') { $formattedValue = '' . strtolower(var_export($item[1], true)) . ''; } elseif ($item[0] === 'resource') { $formattedValue = 'resource'; } else { $formattedValue = str_replace("\n", '', $this->escapeHtml(var_export($item[1], true))); } $result[] = is_int($key) ? $formattedValue : sprintf("'%s' => %s", $this->escapeHtml($key), $formattedValue); } return implode(', ', $result); } protected function flattenArgs(array $args, int $level = 0, int &$count = 0): array { $result = []; foreach ($args as $key => $value) { if (++$count > 1e4) { return ['array', '*SKIPPED over 10000 entries*']; } if ($value instanceof \__PHP_Incomplete_Class) { // is_object() returns false on PHP<=7.1 $result[$key] = ['incomplete-object', $this->getClassNameFromIncomplete($value)]; } elseif (is_object($value)) { $result[$key] = ['object', get_class($value)]; } elseif (is_array($value)) { if ($level > 10) { $result[$key] = ['array', '*DEEP NESTED ARRAY*']; } else { $result[$key] = ['array', $this->flattenArgs($value, $level + 1, $count)]; } } elseif ($value === null) { $result[$key] = ['null', null]; } elseif (is_bool($value)) { $result[$key] = ['boolean', $value]; } elseif (is_int($value)) { $result[$key] = ['integer', $value]; } elseif (is_float($value)) { $result[$key] = ['float', $value]; } elseif (is_resource($value)) { $result[$key] = ['resource', get_resource_type($value)]; } else { $result[$key] = ['string', (string)$value]; } } return $result; } protected function getClassNameFromIncomplete(\__PHP_Incomplete_Class $value): string { $array = new \ArrayObject($value); return $array['__PHP_Incomplete_Class_Name']; } protected function escapeHtml(string $str): string { return htmlspecialchars($str, ENT_COMPAT | ENT_SUBSTITUTE); } protected function getTypo3LogoAsSvg(): string { return << SVG; } protected function getAllThrowables(\Throwable $throwable): array { $all = [$throwable]; while ($throwable = $throwable->getPrevious()) { $all[] = $throwable; } return $all; } }