[TASK] Bring back the exception code in exception messages
[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-page">
122 <div class="exception-summary">
123 <div class="container">
124 <div class="exception-message-wrapper">
125 <div class="exception-illustration hidden-xs-down">$typo3Logo</div>
126 <h1 class="exception-message break-long-words">Whoops, looks like something went wrong.</h1>
127 </div>
128 </div>
129 </div>
130
131 $exceptionInfo
132
133 <div class="container">
134 $content
135 </div>
136 </div>
137 HTML;
138 }
139
140 /**
141 * Renders the HTML for a single throwable.
142 *
143 * @param \Throwable $throwable
144 * @param int $index
145 * @param int $total
146 * @return string
147 */
148 protected function getSingleThrowableContent(\Throwable $throwable, int $index, int $total): string
149 {
150 $exceptionTitle = get_class($throwable);
151 $exceptionCode = $throwable->getCode() ? '#' . $throwable->getCode() . ' ' : '';
152 $exceptionMessage = $this->escapeHtml($throwable->getMessage());
153
154 // The trace does not contain the step where the exception is thrown.
155 // To display it as well it is added manually to the trace.
156 $trace = $throwable->getTrace();
157 array_unshift($trace, [
158 'file' => $throwable->getFile(),
159 'line' => $throwable->getLine(),
160 'args' => [],
161 ]);
162
163 $backtraceCode = $this->getBacktraceCode($trace);
164
165 return <<<HTML
166 <div class="trace">
167 <div class="trace-head">
168 <h3 class="trace-class">
169 <span class="text-muted">({$index}/{$total})</span>
170 <span class="exception-title">{$exceptionCode}{$exceptionTitle}</span>
171 </h3>
172 <p class="trace-message break-long-words">{$exceptionMessage}</p>
173 </div>
174 <div class="trace-body">
175 {$backtraceCode}
176 </div>
177 </div>
178 HTML;
179 }
180
181 /**
182 * Generates the stylesheet needed to display the error page.
183 *
184 * @return string
185 */
186 protected function getStylesheet(): string
187 {
188 return <<<STYLESHEET
189 html {
190 font-family: sans-serif;
191 line-height: 1.15;
192 -webkit-text-size-adjust: 100%;
193 -ms-text-size-adjust: 100%;
194 -ms-overflow-style: scrollbar;
195 -webkit-tap-highlight-color: transparent;
196 }
197 body {
198 margin: 0;
199 font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
200 font-size: 1rem;
201 font-weight: 400;
202 line-height: 1.5;
203 color: #212121;
204 text-align: left;
205 background-color: #eaeaea;
206 }
207
208 a {
209 color: #ff8700;
210 text-decoration: underline;
211 }
212
213 a:hover {
214 text-decoration: none;
215 }
216
217 abbr[title] {
218 border-bottom: none;
219 cursor: help;
220 text-decoration: none;
221 }
222
223 code,
224 kbd,
225 pre,
226 samp {
227 font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
228 font-size: 1em;
229 }
230
231 pre {
232 background-color: #ffffff;
233 overflow-x: auto;
234 border: 1px solid rgba(0,0,0,0.125);
235 }
236
237 pre span {
238 display: block;
239 line-height: 1.3em;
240 }
241
242 pre span:before {
243 display: inline-block;
244 content: attr(data-line);
245 border-right: 1px solid #b9b9b9;
246 margin-right: 0.5em;
247 padding-right: 0.5em;
248 background-color: #f4f4f4;
249 width: 4em;
250 text-align: right;
251 color: #515151;
252 }
253
254 pre span.highlight {
255 background-color: #cce5ff;
256 }
257
258 .break-long-words {
259 -ms-word-break: break-all;
260 word-break: break-all;
261 word-break: break-word;
262 -webkit-hyphens: auto;
263 -moz-hyphens: auto;
264 hyphens: auto;
265 }
266
267 .callout {
268 padding: 1.5rem;
269 background-color: #fff;
270 margin-bottom: 2em;
271 box-shadow: 0 2px 1px rgba(0,0,0,.15);
272 border-left: 3px solid #8c8c8c;
273 }
274
275 .callout-title {
276 margin: 0;
277 }
278
279 .callout-body p:last-child {
280 margin-bottom: 0;
281 }
282
283 .container {
284 max-width: 1140px;
285 margin: 0 auto;
286 padding: 0 30px;
287 }
288
289 .exception-page {
290 position: relative;
291 height: 100vh;
292 overflow-x: hidden;
293 overflow-y: scroll;
294 }
295
296 .exception-illustration {
297 width: 3em;
298 height: 3em;
299 float: left;
300 margin-right: 1rem;
301 }
302
303 .exception-illustration svg {
304 width: 100%;
305 }
306
307 .exception-illustration svg path {
308 fill: #ff8700;
309 }
310
311 .exception-summary {
312 background: #000000;
313 color: #fff;
314 padding: 1.5rem 0;
315 margin-bottom: 2rem;
316 }
317
318 .exception-summary h1 {
319 margin: 0;
320 }
321
322 .text-muted {
323 opacity: 0.5;
324 }
325
326 .trace {
327 background-color: #fff;
328 margin-bottom: 2rem;
329 box-shadow: 0 2px 1px rgba(0,0,0,.15);
330 }
331
332 .trace-arguments {
333 color: #8c8c8c;
334 }
335
336 .trace-body {
337 }
338
339 .trace-call {
340 margin-bottom: 1rem;
341 }
342
343 .trace-class {
344 margin: 0;
345 }
346
347 .trace-file pre {
348 margin-top: 1.5rem;
349 margin-bottom: 0;
350 }
351
352 .trace-head {
353 color: #721c24;
354 background-color: #f8d7da;
355 padding: 1.5rem;
356 }
357
358 .trace-message {
359 margin-bottom: 0;
360 }
361
362 .trace-step {
363 padding: 1.5rem;
364 border-bottom: 1px solid #b9b9b9;
365 }
366
367 .trace-step > *:first-child {
368 margin-top: 0;
369 }
370
371 .trace-step > *:last-child {
372 margin-bottom: 0;
373 }
374
375 .trace-step:nth-child(even)
376 {
377 background-color: #fafafa;
378 }
379
380 .trace-step:last-child {
381 border-bottom: none;
382 }
383 STYLESHEET;
384 }
385
386 /**
387 * Renders the backtrace as HTML.
388 *
389 * @param array $trace
390 * @return string
391 */
392 protected function getBacktraceCode(array $trace): string
393 {
394 $content = '';
395
396 foreach ($trace as $index => $step) {
397 $content .= '<div class="trace-step">';
398 $args = $this->flattenArgs($step['args'] ?? []);
399
400 if (isset($step['function'])) {
401 $content .= '<div class="trace-call">' . sprintf(
402 '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>)',
403 $step['class'] ?? '',
404 $step['type'],
405 $step['function'],
406 $this->formatArgs($args)
407 ) . '</div>';
408 }
409
410 if (isset($step['file']) && isset($step['line'])) {
411 $content .= $this->getCodeSnippet($step['file'], $step['line']);
412 }
413
414 $content .= '</div>';
415 }
416
417 return $content;
418 }
419
420 /**
421 * Returns a code snippet from the specified file.
422 *
423 * @param string $filePathAndName Absolute path and file name of the PHP file
424 * @param int $lineNumber Line number defining the center of the code snippet
425 * @return string The code snippet
426 */
427 protected function getCodeSnippet(string $filePathAndName, int $lineNumber): string
428 {
429 $showLinesAround = 4;
430
431 $content = '<div class="trace-file">';
432 $content .= '<div class="trace-file-head">' . $this->formatPath($filePathAndName, $lineNumber) . '</div>';
433
434 if (@file_exists($filePathAndName)) {
435 $phpFile = @file($filePathAndName);
436 if (is_array($phpFile)) {
437 $startLine = $lineNumber > $showLinesAround ? $lineNumber - $showLinesAround : 1;
438 $phpFileCount = count($phpFile);
439 $endLine = $lineNumber < $phpFileCount - $showLinesAround ? $lineNumber + $showLinesAround + 1 : $phpFileCount + 1;
440 if ($endLine > $startLine) {
441 $content .= '<div class="trace-file-content">';
442 $content .= '<pre>';
443
444 for ($line = $startLine; $line < $endLine; $line++) {
445 $codeLine = str_replace(TAB, ' ', $phpFile[$line - 1]);
446 $spanClass = '';
447 if ($line === $lineNumber) {
448 $spanClass = 'highlight';
449 }
450
451 $content .= '<span class="' . $spanClass . '" data-line="' . $line . '">' . $this->escapeHtml($codeLine) . '</span>';
452 }
453
454 $content .= '</pre>';
455 $content .= '</div>';
456 }
457 }
458 }
459
460 $content .= '</div>';
461
462 return $content;
463 }
464
465 /**
466 * Formats a path adding a line number.
467 *
468 * @param string $path The full path of the file.
469 * @param int $line The line number.
470 * @return string
471 */
472 protected function formatPath(string $path, int $line): string
473 {
474 return sprintf(
475 '<span class="block trace-file-path">in <strong>%s</strong>%s</span>',
476 $this->escapeHtml($path),
477 0 < $line ? ' line ' . $line : ''
478 );
479 }
480
481 /**
482 * Formats the arguments of a method call.
483 *
484 * @param array $args The flattened args of method/function call
485 * @return string
486 */
487 protected function formatArgs(array $args): string
488 {
489 $result = [];
490 foreach ($args as $key => $item) {
491 if ('object' === $item[0]) {
492 $formattedValue = sprintf('<em>object</em>(%s)', $item[1]);
493 } elseif ('array' === $item[0]) {
494 $formattedValue = sprintf('<em>array</em>(%s)', is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]);
495 } elseif ('null' === $item[0]) {
496 $formattedValue = '<em>null</em>';
497 } elseif ('boolean' === $item[0]) {
498 $formattedValue = '<em>' . strtolower(var_export($item[1], true)) . '</em>';
499 } elseif ('resource' === $item[0]) {
500 $formattedValue = '<em>resource</em>';
501 } else {
502 $formattedValue = str_replace("\n", '', $this->escapeHtml(var_export($item[1], true)));
503 }
504
505 $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", $this->escapeHtml($key), $formattedValue);
506 }
507
508 return implode(', ', $result);
509 }
510
511 protected function flattenArgs(array $args, int $level = 0, int &$count = 0): array
512 {
513 $result = [];
514 foreach ($args as $key => $value) {
515 if (++$count > 1e4) {
516 return ['array', '*SKIPPED over 10000 entries*'];
517 }
518 if ($value instanceof \__PHP_Incomplete_Class) {
519 // is_object() returns false on PHP<=7.1
520 $result[$key] = ['incomplete-object', $this->getClassNameFromIncomplete($value)];
521 } elseif (is_object($value)) {
522 $result[$key] = ['object', get_class($value)];
523 } elseif (is_array($value)) {
524 if ($level > 10) {
525 $result[$key] = ['array', '*DEEP NESTED ARRAY*'];
526 } else {
527 $result[$key] = ['array', $this->flattenArgs($value, $level + 1, $count)];
528 }
529 } elseif (null === $value) {
530 $result[$key] = ['null', null];
531 } elseif (is_bool($value)) {
532 $result[$key] = ['boolean', $value];
533 } elseif (is_int($value)) {
534 $result[$key] = ['integer', $value];
535 } elseif (is_float($value)) {
536 $result[$key] = ['float', $value];
537 } elseif (is_resource($value)) {
538 $result[$key] = ['resource', get_resource_type($value)];
539 } else {
540 $result[$key] = ['string', (string)$value];
541 }
542 }
543
544 return $result;
545 }
546
547 protected function getClassNameFromIncomplete(\__PHP_Incomplete_Class $value): string
548 {
549 $array = new \ArrayObject($value);
550
551 return $array['__PHP_Incomplete_Class_Name'];
552 }
553
554 protected function escapeHtml(string $str): string
555 {
556 return htmlspecialchars($str, ENT_COMPAT | ENT_SUBSTITUTE);
557 }
558
559 protected function getTypo3LogoAsSvg(): string
560 {
561 return <<<SVG
562 <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>
563 SVG;
564 }
565
566 protected function getAllThrowables(\Throwable $throwable): array
567 {
568 $all = [$throwable];
569
570 while ($throwable = $throwable->getPrevious()) {
571 $all[] = $throwable;
572 }
573
574 return $all;
575 }
576 }