/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php
577 строк
33 KB
Javier Eguiluz
[ErrorHandler] Redesign the exception page
07 авг 2026, 16:48
07 авг 2026, 16:48
1bc09eb
Код
Авторство
О чём код?
<?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier <fabien@symfony.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\ErrorHandler\ErrorRenderer; use Psr\Log\LoggerInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Log\DebugLoggerConfigurator; use Symfony\Component\VarDumper\Cloner\Data; use Symfony\Component\VarDumper\Dumper\HtmlDumper; /** * @author Yonel Ceruto <yonelceruto@gmail.com> */ class HtmlErrorRenderer implements ErrorRendererInterface { private const GHOST_ADDONS = [ '02-14' => self::GHOST_HEART, '02-29' => self::GHOST_PLUS, '10-18' => self::GHOST_GIFT, ]; private const GHOST_GIFT = 'M124.00534057617188,5.3606138080358505 C124.40059661865234,4.644828304648399 125.1237564086914,3.712414965033531 123.88127899169922,3.487462028861046 C123.5351715087890 … [Строка слишком длинная. Вы можете скачать файл] private const GHOST_HEART = 'M125.91386369681868,8.305165958366445 C128.95033202169043,-0.40540639102854037 140.8469835342744,8.305165958366445 125.91386369681868,19.504526138305664 C110.98208663272044,8.305165958366445 122.87795231771452,-0.40540639102854037 125.91386369681868,8.305165958366445 z'; private const GHOST_PLUS = 'M111.36824226379395,8.969108581542969 L118.69175148010254,8.969108581542969 L118.69175148010254,1.6455793380737305 L126.20429420471191,1.6455793380737305 L126.20429420471191,8.969108581542969 L133.52781105041504,8.969108581542969 L133.52781105041504,16.481630325317383 L126.20429420471191,16.481630325317383 L126.20429420471191,23.805158615112305 L118.69175148010254,23.805158615112305 L118.69175148010254,16.481630325317383 L111.36824226379395,16.481630325317383 z'; private bool|\Closure $debug; private string $charset; private FileLinkFormatter $fileLinkFormat; private string|\Closure $outputBuffer; private static string $template = 'views/error.html.php'; /** * @param bool|callable $debug The debugging mode as a boolean or a callable that should return it * @param string|callable $outputBuffer The output buffer as a string or a callable that should return it */ public function __construct( bool|callable $debug = false, ?string $charset = null, string|FileLinkFormatter|null $fileLinkFormat = null, private ?string $projectDir = null, string|callable $outputBuffer = '', private ?LoggerInterface $logger = null, ) { $this->debug = \is_bool($debug) ? $debug : $debug(...); $this->charset = $charset ?: (\ini_get('default_charset') ?: 'UTF-8'); $this->fileLinkFormat = $fileLinkFormat instanceof FileLinkFormatter ? $fileLinkFormat : new FileLinkFormatter($fileLinkFormat); $this->outputBuffer = \is_string($outputBuffer) ? $outputBuffer : $outputBuffer(...); } public function render(\Throwable $exception): FlattenException { $headers = ['Content-Type' => 'text/html; charset='.$this->charset]; if (\is_bool($this->debug) ? $this->debug : ($this->debug)($exception)) { $headers['X-Debug-Exception'] = rawurlencode(substr($exception->getMessage(), 0, 2000)); $headers['X-Debug-Exception-File'] = rawurlencode($exception->getFile()).':'.$exception->getLine(); } $exception = FlattenException::createWithDataRepresentation($exception, null, $headers); return $exception->setAsString($this->renderException($exception)); } /** * Gets the HTML content associated with the given exception. */ public function getBody(FlattenException $exception): string { return $this->renderException($exception, 'views/exception.html.php'); } /** * Gets the JavaScript associated with the given exception. */ public function getJavaScript(): string { return $this->include('assets/js/exception.js'); } /** * Gets the stylesheet associated with the given exception. */ public function getStylesheet(): string { if (!$this->debug) { return $this->include('assets/css/error.css'); } return $this->include('assets/css/exception.css'); } public static function isDebug(RequestStack $requestStack, bool $debug): \Closure { return static function () use ($requestStack, $debug): bool { if (!$request = $requestStack->getCurrentRequest()) { return $debug; } return $debug && $request->attributes->getBoolean('showException', true); }; } public static function getAndCleanOutputBuffer(RequestStack $requestStack): \Closure { return static function () use ($requestStack): string { if (!$request = $requestStack->getCurrentRequest()) { return ''; } $startObLevel = $request->headers->get('X-Php-Ob-Level', -1); if (ob_get_level() <= $startObLevel) { return ''; } Response::closeOutputBuffers($startObLevel + 1, true); return ob_get_clean(); }; } private function renderException(FlattenException $exception, string $debugTemplate = 'views/exception_full.html.php'): string { $debug = \is_bool($this->debug) ? $this->debug : ($this->debug)($exception); $statusText = $this->escape($exception->getStatusText()); $statusCode = $this->escape($exception->getStatusCode()); if (!$debug) { return $this->include(self::$template, [ 'statusText' => $statusText, 'statusCode' => $statusCode, ]); } $exceptionMessage = $this->escape($exception->getMessage()); return $this->include($debugTemplate, [ 'exception' => $exception, 'exceptionMessage' => $exceptionMessage, 'statusText' => $statusText, 'statusCode' => $statusCode, 'logger' => null !== $this->logger && class_exists(DebugLoggerConfigurator::class) ? DebugLoggerConfigurator::getDebugLogger($this->logger) : null, 'currentContent' => \is_string($this->outputBuffer) ? $this->outputBuffer : ($this->outputBuffer)(), ]); } /** * Whether to show the "Exception properties" block for the given exception class. * * It's only meaningful for an application's (or a third-party library's) own exception classes, * which may carry custom properties worth inspecting. Ignore built-in PHP exceptions (\RuntimeException, * \TypeError, …), and HTTP exceptions, whose statusCode/headers are already shown in the status line. */ private function showExceptionProperties(string $class): bool { return class_exists($class) && new \ReflectionClass($class)->isUserDefined() && !is_a($class, 'Symfony\Component\HttpKernel\Exception\HttpExceptionInterface', true); } /** * Whether the exception class is defined outside the application's own code (vendors, the * framework, or PHP built-ins). */ private function isVendorExceptionClass(string $class): bool { if (!class_exists($class)) { return true; } return $this->isVendorTraceFile(new \ReflectionClass($class)->getFileName() ?: null); } /** * Renders the exception's own properties as a table (one row per property), dumping each value * with VarDumper so nested arrays/objects stay expandable. */ private function dumpExceptionProperties(Data $data): string { $dumper = $this->createHtmlDumper(); $rows = ''; foreach ($data->getValue() as $key => $value) { // strip VarCloner's visibility prefix (e.g. "\0*\0name" / "\0Class\0name") to get the bare name $name = \is_string($key) && str_contains($key, "\0") ? substr($key, strrpos($key, "\0") + 1) : $key; $dump = $dumper->dump($value, true); // the shared <script>/<style> header rides along with the first dump; emit it only once $dumper->setDumpHeader(''); $rows .= '<tr><th class="exception-property-name">'.$this->escape((string) $name).'</th><td class="exception-property-value">'.$dump.'</td></tr>'; } return '<table class="exception-properties-table">'.$rows.'</table>'; } private function createHtmlDumper(): HtmlDumper { $dumper = new HtmlDumper(); $dumper->setTheme('light'); $dumper->setStyles([ 'default' => 'background:none; color:var(--code-foreground); font:13px/1.6 var(--font-mono); word-wrap:break-word; white-space:pre-wrap; position:relative; z-index:99999; word-break:break-all', 'num' => 'color:var(--code-syntax-variable-other-marker)', 'const' => 'color:var(--code-syntax-variable-other-marker)', 'str' => 'color:var(--code-syntax-string)', 'note' => 'color:var(--code-syntax-title)', 'ref' => 'color:var(--code-syntax-comment)', 'public' => 'color:var(--code-foreground)', 'protected' => 'color:var(--code-foreground)', 'private' => 'color:var(--code-foreground)', 'meta' => 'color:var(--code-syntax-function-title)', 'key' => 'color:var(--code-syntax-string)', 'index' => 'color:var(--code-syntax-variable-other-marker)', 'ellipsis' => 'color:var(--code-syntax-comment)', ]); return $dumper; } private function formatArgs(array $args, bool $formatForHtml = false): string { $result = []; foreach ($args as $key => $item) { if ('object' === $item[0]) { $formattedValue = $formatForHtml ? \sprintf('<span class="trace-arg-object">%s</span>', $this->abbrClass($item[1])) : \sprintf('object(%s)', $this->abbrClass($item[1])); } elseif ('array' === $item[0]) { $formattedValue = $this->formatArgToken('array', 'keyword', $formatForHtml).'('.(\is_array($item[1]) ? $this->formatArgs($item[1], $formatForHtml) : $item[1]).')'; } elseif ('null' === $item[0]) { $formattedValue = $this->formatArgToken('null', 'literal', $formatForHtml); } elseif ('boolean' === $item[0]) { $formattedValue = $this->formatArgToken(strtolower(var_export($item[1], true)), 'literal', $formatForHtml); } elseif ('resource' === $item[0]) { $formattedValue = $this->formatArgToken('resource', 'keyword', $formatForHtml); } elseif ('integer' === $item[0] || 'float' === $item[0]) { $formattedValue = $this->formatArgToken($this->escape(var_export($item[1], true)), 'literal', $formatForHtml); } elseif (preg_match('/[^\x07-\x0D\x1B\x20-\xFF]/', $item[1])) { $formattedValue = $formatForHtml ? '<span class="trace-arg-object">binary string</span>' : 'binary string'; } else { $formattedValue = $this->formatArgToken(str_replace("\n", '', $this->escape(var_export($item[1], true))), 'string', $formatForHtml); } $result[] = \is_int($key) ? $formattedValue : $this->formatArgToken("'".$this->escape($key)."'", 'key', $formatForHtml).' => '.$formattedValue; } return implode(', ', $result); } /** * Formats the given (already-escaped) content for rendering it on text or HTML (e.g. wraps it in a <span> * to allow syntax highlighting in HTML rendering). */ private function formatArgToken(string $html, string $class, bool $formatForHtml): string { return $formatForHtml ? \sprintf('<span class="trace-arg trace-arg-%s">%s</span>', $class, $html) : $html; } private function formatArgsAsText(array $args): string { return strip_tags($this->formatArgs($args)); } private function escape(string $string): string { return htmlspecialchars($string, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); } private function abbrClass(string $fqcnClass): string { $parts = explode('\\', $fqcnClass); $className = array_pop($parts); if ($className === $fqcnClass) { return $fqcnClass; } return \sprintf('<abbr title="%s">%s</abbr>', $fqcnClass, $className); } private function getFileRelative(string $file): ?string { $file = str_replace('\\', '/', $file); if (null !== $this->projectDir && str_starts_with($file, $this->projectDir)) { return ltrim(substr($file, \strlen($this->projectDir)), '/'); } return null; } /** * Tells whether a stack trace frame belongs to "vendor" code rather than the application. * * A frame is considered to belong to the application only when its file lives inside the project * directory but outside vendor/ and the compiled cache (var/cache/). Everything else (third-party * dependencies, the framework, the compiled container, and fileless internal calls) is "vendor". */ private function isVendorTraceFile(?string $file): bool { if (!$file) { return true; } if (null !== $this->projectDir) { $relativePath = $this->getFileRelative($file); return null === $relativePath || str_starts_with($relativePath, 'vendor/') || str_starts_with($relativePath, 'var/cache/'); } // no project dir configured: best-effort match on the absolute path $file = str_replace('\\', '/', $file); return str_contains($file, '/vendor/') || str_contains($file, '/var/cache/'); } /** * Formats a file path. * * @param string $file An absolute file path * @param int $line The line number * @param string $text Use this text for the link rather than the file path */ private function formatFile(string $file, int $line, ?string $text = null): string { $file = trim($file); if (null === $text) { $text = $file; if (null !== $rel = $this->getFileRelative($text)) { $rel = explode('/', $rel, 2); $text = \sprintf('<abbr title="%s%2$s">%s</abbr>%s', $this->projectDir, $rel[0], '/'.($rel[1] ?? '')); } } if (0 < $line) { $text .= ' at line '.$line; } if (!file_exists($file)) { return $text; } $link = $this->fileLinkFormat->format($file, $line); return \sprintf('<a href="%s" title="Click to open this file" class="file_link">%s</a>', $this->escape($link), $text); } /** * Returns an excerpt of a code file around the given line number. * * @param string $file A file path * @param int $line The selected line number * @param int $srcContext The number of displayed lines around or -1 for the whole file */ private function fileExcerpt(string $file, int $line, int $srcContext = 3): string { if (is_file($file) && is_readable($file)) { // highlight_file could throw warnings // see https://bugs.php.net/25725 $code = @highlight_file($file, true); // remove main pre/code tags $code = preg_replace('#^<pre.*?>\s*<code.*?>(.*)</code>\s*</pre>#s', '\\1', $code); // split multiline span tags $code = preg_replace_callback('#<span ([^>]++)>((?:[^<\\n]*+\\n)++[^<]*+)</span>#', static fn ($m) => "<span $m[1]>".str_replace("\n", "</span>\n<span $m[1]>", $m[2]).'</span>', $code); $content = explode("\n", $code); if (0 > $srcContext) { $srcContext = \count($content); } $excerpt = []; for ($i = max($line - $srcContext, 1), $max = min($line + $srcContext, \count($content)); $i <= $max; ++$i) { $excerpt[$i] = $this->fixCodeMarkup($content[$i - 1]); } // de-indent: drop the whitespace prefix shared by every (non-blank) line of the excerpt, // so a deeply-nested snippet isn't pushed to the right; relative indentation is preserved $numOfSpacesToRemove = $this->countCommonLeadingWhitespace($excerpt); $lines = []; foreach ($excerpt as $i => $html) { $isSelected = $i === $line; $lines[] = '<div class="trace-code-line '.($isSelected ? 'selected' : '').'" '.($isSelected ? 'aria-current="true"' : '').'><span class="trace-code-ln" aria-hidden="true">'.$i.'</span><code>'.($numOfSpacesToRemove ? $this->stripLeadingWhitespace($html, $numOfSpacesToRemove) : $html).'</code></div>'; } // size the line-number gutter to the widest number (each row is its own grid, so the // column can't auto-align across rows) to keep different size numbers (e.g. 9 and 10) aligned $lnChars = \strlen((string) $max); return '<div class="trace-code-lines" style="--ln-chars: '.$lnChars.'">'.implode('', $lines).'</div>'; } return ''; } /** * Number of leading whitespace characters shared by every non-blank line, i.e. the common * indentation that can be stripped from a syntax-highlighted excerpt without losing relative depth. */ private function countCommonLeadingWhitespace(array $htmlLines): int { $min = null; foreach ($htmlLines as $html) { if ('' === trim(html_entity_decode(strip_tags($html)))) { continue; // blank / whitespace-only lines don't constrain the common prefix } $count = 0; for ($i = 0, $len = \strlen($html); $i < $len; ++$i) { if ('<' === $html[$i]) { if (false === $end = strpos($html, '>', $i)) { break; } $i = $end; } elseif (' ' === $html[$i] || "\t" === $html[$i]) { ++$count; } else { break; } } $min = null === $min ? $count : min($min, $count); if (0 === $min) { break; } } return $min ?? 0; } /** * Removes the first $count leading whitespace characters of a syntax-highlighted line, * skipping (and preserving) any HTML tags that wrap the indentation. For example: * * $html = '<span style="..."> $tag = null;</span>' * $count = 4 * $output = '<span style="..."> $tag = null;</span>' */ private function stripLeadingWhitespace(string $html, int $count): string { $result = ''; $removed = 0; for ($i = 0, $len = \strlen($html); $i < $len; ++$i) { $c = $html[$i]; if ('<' === $c) { // a tag: copy it verbatim (it doesn't count as indentation) if (false === $end = strpos($html, '>', $i)) { return $result.substr($html, $i); } $result .= substr($html, $i, $end - $i + 1); $i = $end; } elseif ($removed < $count && (' ' === $c || "\t" === $c)) { // leading whitespace still within the strip budget: drop it ++$removed; } else { // first non-strippable character: keep this and everything after it return $result.substr($html, $i); } } return $result; } private function fixCodeMarkup(string $line): string { // </span> ending tag from previous line $opening = strpos($line, '<span'); $closing = strpos($line, '</span>'); if (false !== $closing && (false === $opening || $closing < $opening)) { $line = substr_replace($line, '', $closing, 7); } // missing </span> tag at the end of line $opening = strrpos($line, '<span'); $closing = strrpos($line, '</span>'); if (false !== $opening && (false === $closing || $closing < $opening)) { $line .= '</span>'; } return trim($line); } private function formatFileFromText(string $text): string { return preg_replace_callback('/in ("|")?(.+?)\1(?: +(?:on|at))? +line (\d+)/s', fn ($match) => 'in '.$this->formatFile($match[2], $match[3]), $text) ?? $text; } private function formatLogMessage(string $message, array $context): string { if ($context && str_contains($message, '{')) { $replacements = []; foreach ($context as $key => $val) { if (\is_scalar($val)) { $replacements['{'.$key.'}'] = $val; } } if ($replacements) { $message = strtr($message, $replacements); } } return $this->escape($message); } /** * Pretty-prints a log's context as syntax-highlighted JSON. * * The context is rendered as JSON (the format it is logged in) and then tokenized in a single * left-to-right pass: strings/keys are matched first so that numbers or keywords appearing inside * a string value aren't highlighted again. Tokens are wrapped in spans mapped to the shared * --code-syntax-* theme variables, so the dump follows the page's light/dark theme. */ private function formatLogContext(array $context): string { $json = json_encode($context, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_PARTIAL_OUTPUT_ON_ERROR); $json = $this->escape((string) $json); return preg_replace_callback( '/("(?:\\\\.|[^&\\\\]|&(?!quot;))*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/', static function (array $m): string { if ('' !== $m[1]) { $isKey = isset($m[2]) && '' !== $m[2]; return '<span class="log-json-'.($isKey ? 'key' : 'str').'">'.$m[1].'</span>'.($m[2] ?? ''); } if (isset($m[3]) && '' !== $m[3]) { return '<span class="log-json-kw">'.$m[3].'</span>'; } return '<span class="log-json-num">'.$m[4].'</span>'; }, $json, ) ?? $json; } private function addElementToGhost(): string { if (!isset(self::GHOST_ADDONS[date('m-d')])) { return ''; } return '<path d="'.self::GHOST_ADDONS[date('m-d')].'" fill="#fff" fill-opacity="0.6"></path>'; } private function include(string $name, array $context = []): string { extract($context, \EXTR_SKIP); ob_start(); include is_file(\dirname(__DIR__).'/Resources/'.$name) ? \dirname(__DIR__).'/Resources/'.$name : $name; return trim(ob_get_clean()); } /** * Allows overriding the default non-debug template. * * @param string $template path to the custom template file to render */ public static function setTemplate(string $template): void { self::$template = $template; } }