[TASK] Refine DebugExceptionHandler
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Error / DebugExceptionHandler.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Core\Error;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 /**
19 * A basic but solid exception handler which catches everything which
20 * falls through the other exception handlers and provides useful debugging
21 * information.
22 */
23 class DebugExceptionHandler extends AbstractExceptionHandler
24 {
25 /**
26 * Constructs this exception handler - registers itself as the default exception handler.
27 */
28 public function __construct()
29 {
30 set_exception_handler([$this, 'handleException']);
31 }
32
33 /**
34 * Formats and echoes the exception as XHTML.
35 *
36 * @param \Throwable $exception The throwable object.
37 */
38 public function echoExceptionWeb(\Throwable $exception)
39 {
40 $this->sendStatusHeaders($exception);
41 $this->writeLogEntries($exception, self::CONTEXT_WEB);
42
43 $content = $this->getContent($exception);
44 $css = $this->getStylesheet();
45
46 echo <<<HTML
47 <!DOCTYPE html>
48 <html>
49 <head>
50 <meta charset="UTF-8" />
51 <title>TYPO3 Exception</title>
52 <meta name="robots" content="noindex,nofollow" />
53 <style>$css</style>
54 </head>
55 <body>
56 $content
57 </body>
58 </html>
59 HTML;
60 }
61
62 /**
63 * Formats and echoes the exception for the command line
64 *
65 * @param \Throwable $exception The throwable object.
66 */
67 public function echoExceptionCLI(\Throwable $exception)
68 {
69 $filePathAndName = $exception->getFile();
70 $exceptionCodeNumber = $exception->getCode() > 0 ? '#' . $exception->getCode() . ': ' : '';
71 $this->writeLogEntries($exception, self::CONTEXT_CLI);
72 echo LF . 'Uncaught TYPO3 Exception ' . $exceptionCodeNumber . $exception->getMessage() . LF;
73 echo 'thrown in file ' . $filePathAndName . LF;
74 echo 'in line ' . $exception->getLine() . LF . LF;
75 die(1);
76 }
77
78 /**
79 * Generates the HTML for the error output.
80 *
81 * @param \Throwable $throwable
82 * @return string
83 */
84 protected function getContent(\Throwable $throwable): string
85 {
86 $content = '';
87
88 // exceptions can be chained
89 // for easier debugging, all exceptions are displayed to the developer
90 $throwables = $this->getAllThrowables($throwable);
91 $count = count($throwables);
92 foreach ($throwables as $position => $e) {
93 $content .= $this->getSingleThrowableContent($e, $position + 1, $count);
94 }
95
96 $exceptionInfo = '';
97 if ($throwable->getCode() > 0) {
98 $wikiLink = TYPO3_URL_EXCEPTION . 'debug/' . $throwable->getCode();
99 $exceptionInfo = <<<INFO
100 <div class="container">
101 <div class="callout">
102 <h4 class="callout-title">Get help in the TYPO3 Wiki</h4>
103 <div class="callout-body">
104 <p>
105 If you need help solving this exception, you can have a look at the TYPO3 Wiki.
106 There you can find solutions provided by the TYPO3 community.
107 Once you have found a solution to the problem, help others by contributing to the wiki page.
108 </p>
109 <p>
110 <a href="$wikiLink" target="_blank">Find a solution for this exception in the TYPO3 wiki.</a>
111 </p>
112 </div>
113 </div>
114 </div>
115 INFO;
116 }
117
118 $typo3Logo = $this->getTypo3LogoAsSvg();
119
120 return <<<HTML
121 <div class="exception-summary">
122 <div class="container">
123 <div class="exception-message-wrapper">
124 <div class="exception-illustration hidden-xs-down">$typo3Logo</div>
125 <h1 class="exception-message break-long-words">Whoops, looks like something went wrong.</h1>
126 </div>
127 </div>
128 </div>
129
130 $exceptionInfo
131
132 <div class="container">
133 $content
134 </div>
135 HTML;
136 }
137
138 /**
139 * Renders the HTML for a single throwable.
140 *
141 * @param \Throwable $throwable
142 * @param int $index
143 * @param int $total
144 * @return string
145 */
146 protected function getSingleThrowableContent(\Throwable $throwable, int $index, int $total): string
147 {
148 $exceptionTitle = $this->formatClass(get_class($throwable));
149 $exceptionMessage = $this->escapeHtml($throwable->getMessage());
150
151 // The trace does not contain the step where the exception is thrown.
152 // To display it as well it is added manually to the trace.
153 $trace = $throwable->getTrace();
154 array_unshift($trace, [
155 'file' => $throwable->getFile(),
156 'line' => $throwable->getLine(),
157 'args' => [],
158 ]);
159
160 $backtraceCode = $this->getBacktraceCode($trace);
161
162 return <<<HTML
163 <div class="trace">
164 <div class="trace-head">
165 <h3 class="trace-class">
166 <span class="text-muted">({$index}/{$total})</span>
167 <span class="exception-title">{$exceptionTitle}</span>
168 </h3>
169 <p class="trace-message break-long-words">{$exceptionMessage}</p>
170 </div>
171 <div class="trace-body">
172 {$backtraceCode}
173 </div>
174 </div>
175 HTML;
176 }
177
178 /**
179 * Generates the stylesheet needed to display the error page.
180 *
181 * @return string
182 */
183 protected function getStylesheet(): string
184 {
185 return <<<STYLESHEET
186 body {
187 background-color: #ffffff;
188 color: #000000;
189 font: 100%/1.3 Verdana, Arial, Helvetica, sans-serif;
190 margin: 0;
191 }
192
193 a {
194 color: #f49800;
195 text-decoration: underline;
196 }
197
198 a:hover {
199 text-decoration: none;
200 }
201
202 abbr[title] {
203 border-bottom: none;
204 cursor: help;
205 text-decoration: none;
206 }
207
208 pre {
209 font: 100%/1.3 "Courier New", Courier, monospace;
210 background-color: #ffffff;
211 overflow-x: auto;
212 color: #000000;
213 line-height: 0;
214 border-top: 1px solid #b9b9b9;
215 border-bottom: 1px solid #b9b9b9;
216 }
217
218 pre span {
219 display: block;
220 line-height: 1.3em;
221 }
222
223 pre span:before {
224 display: inline-block;
225 content: attr(data-line);
226 border-right: 1px solid #b9b9b9;
227 margin-right: 0.5em;
228 padding-right: 0.5em;
229 background-color: #f4f4f4;
230 width: 4em;
231 text-align: right;
232 color: #515151;
233 }
234
235 pre span.highlight {
236 background-color: #e7f2fe;
237 }
238
239 .break-long-words {
240 -ms-word-break: break-all;
241 word-break: break-all;
242 word-break: break-word;
243 -webkit-hyphens: auto;
244 -moz-hyphens: auto;
245 hyphens: auto;
246 }
247
248 .callout {
249 background-color: #fafafa;
250 border-left: 3px solid #8c8c8c;
251 margin: 2em 0;
252 padding: 1em;
253 }
254
255 .callout-title {
256 margin: 0;
257 }
258
259 .callout-body p:last-child {
260 margin-bottom: 0;
261 }
262
263 .container {
264 max-width: 1024px;
265 margin: 0 auto;
266 padding: 0 15px;
267 }
268
269 .exception-illustration {
270 width: 3em;
271 height: 3em;
272 float: left;
273 margin-right: 1em;
274 }
275
276 .exception-illustration svg {
277 width: 100%;
278 }
279
280 .exception-illustration svg path {
281 fill: #f49800;
282 }
283
284 .exception-summary {
285 background: #000000;
286 color: #ffffff;
287 padding: 1.5em 0;
288 margin-bottom: 2em;
289 }
290
291 .exception-summary h1 {
292 margin: 0;
293 }
294
295 .text-muted {
296 color: #8c8c8c;
297 }
298
299 .trace {
300 background-color: #fafafa;
301 margin-bottom: 2em;
302 border: 1px solid #b9b9b9;
303 box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 1px;
304 }
305
306 .trace-arguments {
307 color: #8c8c8c;
308 }
309
310 .trace-body {
311 }
312
313 .trace-call {
314 margin-bottom: 1em;
315 }
316
317 .trace-class {
318 margin: 0;
319 }
320
321 .trace-file pre {
322 margin-top: 1em;
323 margin-bottom: 0;
324 }
325
326 .trace-head {
327 background-color: #eaeaea;
328 padding: 1em;
329 }
330
331 .trace-message {
332 margin-bottom: 0;
333 }
334
335 .trace-step {
336 padding: 1em;
337 border-bottom: 1px solid #b9b9b9;
338 }
339
340 .trace-step:nth-child(even)
341 {
342 background-color: #ffffff;
343 }
344
345 .trace-step:last-child {
346 border-bottom: none;
347 }
348 STYLESHEET;
349 }
350
351 /**
352 * Renders the backtrace as HTML.
353 *
354 * @param array $trace
355 * @return string
356 */
357 protected function getBacktraceCode(array $trace): string
358 {
359 $content = '';
360
361 foreach ($trace as $index => $step) {
362 $content .= '<div class="trace-step">';
363 $args = $this->flattenArgs($step['args']);
364
365 if (isset($step['function'])) {
366 $content .= '<div class="trace-call">' . sprintf(
367 'at <span class="trace-class">%s</span><span class="trace-type">%s</span><span class="trace-method">%s</span>(<span class="trace-arguments">%s</span>)',
368 $this->formatClass($step['class'] ?? ''),
369 $step['type'],
370 $step['function'],
371 $this->formatArgs($args)
372 ) . '</div>';
373 }
374
375 if (isset($step['file']) && isset($step['line'])) {
376 $content .= $this->getCodeSnippet($step['file'], $step['line']);
377 }
378
379 $content .= '</div>';
380 }
381
382 return $content;
383 }
384
385 /**
386 * Returns a code snippet from the specified file.
387 *
388 * @param string $filePathAndName Absolute path and file name of the PHP file
389 * @param int $lineNumber Line number defining the center of the code snippet
390 * @return string The code snippet
391 */
392 protected function getCodeSnippet(string $filePathAndName, int $lineNumber): string
393 {
394 $showLinesAround = 4;
395
396 $content = '<div class="trace-file">';
397 $content .= '<div class="trace-file-head">' . $this->formatPath($filePathAndName, $lineNumber) . '</div>';
398
399 if (@file_exists($filePathAndName)) {
400 $phpFile = @file($filePathAndName);
401 if (is_array($phpFile)) {
402 $startLine = $lineNumber > $showLinesAround ? $lineNumber - $showLinesAround : 1;
403 $phpFileCount = count($phpFile);
404 $endLine = $lineNumber < $phpFileCount - $showLinesAround ? $lineNumber + $showLinesAround + 1 : $phpFileCount + 1;
405 if ($endLine > $startLine) {
406 $content .= '<div class="trace-file-content">';
407 $content .= '<pre>';
408
409 for ($line = $startLine; $line < $endLine; $line++) {
410 $codeLine = str_replace(TAB, ' ', $phpFile[$line - 1]);
411 $spanClass = '';
412 if ($line === $lineNumber) {
413 $spanClass = 'highlight';
414 }
415
416 $content .= '<span class="' . $spanClass . '" data-line="' . $line . '">' . $this->escapeHtml($codeLine) . '</span>';
417 }
418
419 $content .= '</pre>';
420 $content .= '</div>';
421 }
422 }
423 }
424
425 $content .= '</div>';
426
427 return $content;
428 }
429
430 /**
431 * Formats a path adding a line number.
432 *
433 * @param string $path The full path of the file.
434 * @param int $line The line number.
435 * @return string
436 */
437 protected function formatPath(string $path, int $line): string
438 {
439 $file = $this->escapeHtml(preg_match('#[^/\\\\]*+$#', $path, $file) ? $file[0] : $path);
440
441 return sprintf(
442 '<span class="block trace-file-path">in <abbr title="%s%3$s"><strong>%s</strong>%s</abbr></span>',
443 $this->escapeHtml($path),
444 $file,
445 0 < $line ? ' line ' . $line : ''
446 );
447 }
448
449 /**
450 * Formats the arguments of a method call.
451 *
452 * @param array $args The flattened args of method/function call
453 * @return string
454 */
455 protected function formatArgs(array $args): string
456 {
457 $result = [];
458 foreach ($args as $key => $item) {
459 if ('object' === $item[0]) {
460 $formattedValue = sprintf('<em>object</em>(%s)', $this->formatClass($item[1]));
461 } elseif ('array' === $item[0]) {
462 $formattedValue = sprintf('<em>array</em>(%s)', is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]);
463 } elseif ('null' === $item[0]) {
464 $formattedValue = '<em>null</em>';
465 } elseif ('boolean' === $item[0]) {
466 $formattedValue = '<em>' . strtolower(var_export($item[1], true)) . '</em>';
467 } elseif ('resource' === $item[0]) {
468 $formattedValue = '<em>resource</em>';
469 } else {
470 $formattedValue = str_replace("\n", '', $this->escapeHtml(var_export($item[1], true)));
471 }
472
473 $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", $this->escapeHtml($key), $formattedValue);
474 }
475
476 return implode(', ', $result);
477 }
478
479 protected function flattenArgs(array $args, int $level = 0, int &$count = 0): array
480 {
481 $result = [];
482 foreach ($args as $key => $value) {
483 if (++$count > 1e4) {
484 return ['array', '*SKIPPED over 10000 entries*'];
485 }
486 if ($value instanceof \__PHP_Incomplete_Class) {
487 // is_object() returns false on PHP<=7.1
488 $result[$key] = ['incomplete-object', $this->getClassNameFromIncomplete($value)];
489 } elseif (is_object($value)) {
490 $result[$key] = ['object', get_class($value)];
491 } elseif (is_array($value)) {
492 if ($level > 10) {
493 $result[$key] = ['array', '*DEEP NESTED ARRAY*'];
494 } else {
495 $result[$key] = ['array', $this->flattenArgs($value, $level + 1, $count)];
496 }
497 } elseif (null === $value) {
498 $result[$key] = ['null', null];
499 } elseif (is_bool($value)) {
500 $result[$key] = ['boolean', $value];
501 } elseif (is_int($value)) {
502 $result[$key] = ['integer', $value];
503 } elseif (is_float($value)) {
504 $result[$key] = ['float', $value];
505 } elseif (is_resource($value)) {
506 $result[$key] = ['resource', get_resource_type($value)];
507 } else {
508 $result[$key] = ['string', (string)$value];
509 }
510 }
511
512 return $result;
513 }
514
515 protected function getClassNameFromIncomplete(\__PHP_Incomplete_Class $value): string
516 {
517 $array = new \ArrayObject($value);
518
519 return $array['__PHP_Incomplete_Class_Name'];
520 }
521
522 protected function escapeHtml(string $str): string
523 {
524 return htmlspecialchars($str, ENT_COMPAT | ENT_SUBSTITUTE);
525 }
526
527 protected function formatClass(string $class): string
528 {
529 $parts = explode('\\', $class);
530 $shortClassName = array_pop($parts);
531
532 if (strpos($class, 'class@anonymous') === 0) {
533 $shortClassName = 'class@anonymous';
534 }
535
536 return sprintf('<abbr title="%s">%s</abbr>', $class, $shortClassName);
537 }
538
539 protected function getTypo3LogoAsSvg(): string
540 {
541 return <<<SVG
542 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M11.1 10.3c-.2 0-.3.1-.5.1C9 10.4 6.8 5 6.8 3.2c0-.7.2-.9.4-1.1-2 .2-4.2.9-4.9 1.8-.2.2-.3.6-.3 1 0 2.8 3 9.2 5.1 9.2 1 0 2.6-1.6 4-3.8m-1-8.4c1.9 0 3.9.3 3.9 1.4 0 2.2-1.4 4.9-2.1 4.9C10.6 8.3 9 4.7 9 2.9c0-.8.3-1 1.1-1"></path></svg>
543 SVG;
544 }
545
546 protected function getAllThrowables(\Throwable $throwable): array
547 {
548 $all = [$throwable];
549
550 while ($throwable = $throwable->getPrevious()) {
551 $all[] = $throwable;
552 }
553
554 return $all;
555 }
556 }