Passed
Push — master ( 3882b7...f8641c )
by Sergei
03:04
created

HtmlRenderer::render()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 2
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ErrorHandler\Renderer;
6
7
use Alexkart\CurlBuilder\Command;
8
use cebe\markdown\GithubMarkdown;
9
use Psr\Http\Message\ServerRequestInterface;
10
use RuntimeException;
11
use Throwable;
12
use Yiisoft\ErrorHandler\CompositeException;
13
use Yiisoft\ErrorHandler\ErrorData;
14
use Yiisoft\ErrorHandler\Exception\ErrorException;
15
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
16
use Yiisoft\FriendlyException\FriendlyExceptionInterface;
17
18
use function array_values;
19
use function dirname;
20
use function extract;
21
use function file;
22
use function file_exists;
23
use function func_get_arg;
24
use function glob;
25
use function htmlspecialchars;
26
use function implode;
27
use function is_array;
28
use function is_bool;
29
use function is_file;
30
use function is_object;
31
use function is_resource;
32
use function is_string;
33
use function ksort;
34
use function mb_strlen;
35
use function mb_substr;
36
use function ob_clean;
37
use function ob_end_clean;
38
use function ob_get_clean;
39
use function ob_get_level;
40
use function ob_implicit_flush;
41
use function ob_start;
42
use function realpath;
43
use function rtrim;
44
use function str_replace;
45
use function stripos;
46
use function strlen;
47
48
/**
49
 * Formats throwable into HTML string.
50
 *
51
 * @psalm-import-type DebugBacktraceType from ErrorException
52
 */
53
final class HtmlRenderer implements ThrowableRendererInterface
54
{
55
    private GithubMarkdown $markdownParser;
56
57
    /**
58
     * @var string The full path to the default template directory.
59
     */
60
    private string $defaultTemplatePath;
61
62
    /**
63
     * @var string The full path of the template file for rendering exceptions without call stack information.
64
     *
65
     * This template should be used in production.
66
     */
67
    private string $template;
68
69
    /**
70
     * @var string The full path of the template file for rendering exceptions with call stack information.
71
     *
72
     * This template should be used in development.
73
     */
74
    private string $verboseTemplate;
75
76
    /**
77
     * @var int The maximum number of source code lines to be displayed. Defaults to 19.
78
     */
79
    private int $maxSourceLines;
80
81
    /**
82
     * @var int The maximum number of trace source code lines to be displayed. Defaults to 13.
83
     */
84
    private int $maxTraceLines;
85
86
    /**
87
     * @var string|null The trace header line with placeholders to be be substituted. Defaults to null.
88
     *
89
     * The placeholders are {file}, {line} and {icon}. A typical use case is the creation of IDE-specific links,
90
     * since when you click on a trace header link, it opens directly in the IDE. You can also insert custom content.
91
     *
92
     * Example IDE link:
93
     *
94
     * ```
95
     * <a href="ide://open?file={file}&line={line}">{icon}</a>
96
     * ```
97
     */
98
    private ?string $traceHeaderLine;
99
100
    /**
101
     * @var string[]|null The list of vendor paths is determined automatically.
102
     *
103
     * One path if the error handler is installed as a vendor package, or a list of package vendor paths
104
     * if the error handler is installed for development in {@link https://github.com/yiisoft/yii-dev-tool}.
105
     */
106
    private ?array $vendorPaths = null;
107
108
    /**
109
     * @param array $settings Settings can have the following keys:
110
     * - template: string, full path of the template file for rendering exceptions without call stack information.
111
     * - verboseTemplate: string, full path of the template file for rendering exceptions with call stack information.
112
     * - maxSourceLines: int, maximum number of source code lines to be displayed. Defaults to 19.
113
     * - maxTraceLines: int, maximum number of trace source code lines to be displayed. Defaults to 13.
114
     * - traceHeaderLine: string, trace header line with placeholders to be be substituted. Defaults to null.
115
     *
116
     * @psalm-param array{
117
     *   template?: string,
118
     *   verboseTemplate?: string,
119
     *   maxSourceLines?: int,
120
     *   maxTraceLines?: int,
121
     *   traceHeaderLine?: string,
122
     * } $settings
123
     */
124 39
    public function __construct(array $settings = [])
125
    {
126 39
        $this->markdownParser = new GithubMarkdown();
127 39
        $this->markdownParser->html5 = true;
128
129 39
        $this->defaultTemplatePath = dirname(__DIR__, 2) . '/templates';
130 39
        $this->template = $settings['template'] ?? $this->defaultTemplatePath . '/production.php';
131 39
        $this->verboseTemplate = $settings['verboseTemplate'] ?? $this->defaultTemplatePath . '/development.php';
132 39
        $this->maxSourceLines = $settings['maxSourceLines'] ?? 19;
133 39
        $this->maxTraceLines = $settings['maxTraceLines'] ?? 13;
134 39
        $this->traceHeaderLine = $settings['traceHeaderLine'] ?? null;
135
    }
136
137 5
    public function render(Throwable $t, ServerRequestInterface $request = null): ErrorData
138
    {
139 5
        return new ErrorData($this->renderTemplate($this->template, [
140 5
            'request' => $request,
141 5
            'throwable' => $t,
142 5
        ]));
143
    }
144
145 4
    public function renderVerbose(Throwable $t, ServerRequestInterface $request = null): ErrorData
146
    {
147 4
        return new ErrorData($this->renderTemplate($this->verboseTemplate, [
148 4
            'request' => $request,
149 4
            'throwable' => $t,
150 4
        ]));
151
    }
152
153
    /**
154
     * Encodes special characters into HTML entities for use as a content.
155
     *
156
     * @param string $content The content to be encoded.
157
     *
158
     * @return string Encoded content.
159
     */
160 20
    public function htmlEncode(string $content): string
161
    {
162 20
        return htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
163
    }
164
165
    public function parseMarkdown(string $content): string
166
    {
167
        $html = $this->markdownParser->parse($content);
168
        /**
169
         * @psalm-suppress InvalidArgument
170
         *
171
         * @link https://github.com/vimeo/psalm/issues/4317
172
         */
173
        return strip_tags($html, [
174
            'h1',
175
            'h2',
176
            'h3',
177
            'h4',
178
            'h5',
179
            'h6',
180
            'hr',
181
            'pre',
182
            'code',
183
            'blockquote',
184
            'table',
185
            'tr',
186
            'td',
187
            'th',
188
            'thead',
189
            'tbody',
190
            'strong',
191
            'em',
192
            'b',
193
            'i',
194
            'u',
195
            's',
196
            'span',
197
            'a',
198
            'p',
199
            'br',
200
            'nobr',
201
            'ul',
202
            'ol',
203
            'li',
204
            'img',
205
        ]);
206
    }
207
208
    /**
209
     * Renders the previous exception stack for a given Exception.
210
     *
211
     * @param Throwable $t The exception whose precursors should be rendered.
212
     *
213
     * @throws Throwable
214
     *
215
     * @return string HTML content of the rendered previous exceptions. Empty string if there are none.
216
     */
217 2
    public function renderPreviousExceptions(Throwable $t): string
218
    {
219 2
        $templatePath = $this->defaultTemplatePath . '/_previous-exception.php';
220
221 2
        if ($t instanceof CompositeException) {
222
            $result = [];
223
            foreach ($t->getPreviousExceptions() as $exception) {
224
                $result[] = $this->renderTemplate($templatePath, ['throwable' => $exception]);
225
            }
226
            return implode('', $result);
227
        }
228 2
        if ($t->getPrevious() !== null) {
229 1
            return $this->renderTemplate($templatePath, ['throwable' => $t->getPrevious()]);
230
        }
231
232 2
        return '';
233
    }
234
235
    /**
236
     * Renders call stack.
237
     *
238
     * @param Throwable $t The exception to get call stack from.
239
     *
240
     * @throws Throwable
241
     *
242
     * @return string HTML content of the rendered call stack.
243
     *
244
     * @psalm-param DebugBacktraceType $trace
245
     */
246 2
    public function renderCallStack(Throwable $t, array $trace = []): string
247
    {
248 2
        $application = $vendor = [];
249 2
        $application[1] = $this->renderCallStackItem($t->getFile(), $t->getLine(), null, null, [], 1, false);
250
251 2
        $length = count($trace);
252 2
        for ($i = 0; $i < $length; ++$i) {
253 1
            $file = !empty($trace[$i]['file']) ? $trace[$i]['file'] : null;
254 1
            $line = !empty($trace[$i]['line']) ? $trace[$i]['line'] : null;
255 1
            $class = !empty($trace[$i]['class']) ? $trace[$i]['class'] : null;
256 1
            $args = !empty($trace[$i]['args']) ? $trace[$i]['args'] : [];
257
258 1
            $function = null;
259 1
            if (!empty($trace[$i]['function']) && $trace[$i]['function'] !== 'unknown') {
260 1
                $function = $trace[$i]['function'];
261
            }
262 1
            $index = $i + 2;
263
264 1
            if ($isVendor = $this->isVendorFile($file)) {
265
                $vendor[$index] = $this->renderCallStackItem($file, $line, $class, $function, $args, $index, $isVendor);
266
                continue;
267
            }
268
269 1
            $application[$index] = $this->renderCallStackItem($file, $line, $class, $function, $args, $index, $isVendor);
270
        }
271
272 2
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-items.php', [
273 2
            'applicationItems' => $application,
274 2
            'vendorItemGroups' => $this->groupVendorCallStackItems($vendor),
275 2
        ]);
276
    }
277
278
    /**
279
     * Converts arguments array to its string representation.
280
     *
281
     * @param array $args arguments array to be converted
282
     *
283
     * @return string The string representation of the arguments array.
284
     */
285 12
    public function argumentsToString(array $args): string
286
    {
287 12
        $count = 0;
288 12
        $isAssoc = $args !== array_values($args);
289
290
        /**
291
         * @var mixed $value
292
         */
293 12
        foreach ($args as $key => $value) {
294 12
            $count++;
295
296 12
            if ($count >= 5) {
297 1
                if ($count > 5) {
298 1
                    unset($args[$key]);
299
                } else {
300 1
                    $args[$key] = '...';
301
                }
302 1
                continue;
303
            }
304
305 12
            if (is_object($value)) {
306 2
                $args[$key] = '<span class="title">' . $this->htmlEncode($value::class) . '</span>';
307 11
            } elseif (is_bool($value)) {
308 2
                $args[$key] = '<span class="keyword">' . ($value ? 'true' : 'false') . '</span>';
309 10
            } elseif (is_string($value)) {
310 6
                $fullValue = $this->htmlEncode($value);
311 6
                if (mb_strlen($value, 'UTF-8') > 32) {
312 2
                    $displayValue = $this->htmlEncode(mb_substr($value, 0, 32, 'UTF-8')) . '...';
313 2
                    $args[$key] = "<span class=\"string\" title=\"$fullValue\">'$displayValue'</span>";
314
                } else {
315 6
                    $args[$key] = "<span class=\"string\">'$fullValue'</span>";
316
                }
317 6
            } elseif (is_array($value)) {
318 2
                unset($args[$key]);
319 2
                $args[$key] = '[' . $this->argumentsToString($value) . ']';
320 4
            } elseif ($value === null) {
321 1
                $args[$key] = '<span class="keyword">null</span>';
322 3
            } elseif (is_resource($value)) {
323 1
                $args[$key] = '<span class="keyword">resource</span>';
324
            } else {
325 2
                $args[$key] = '<span class="number">' . (string) $value . '</span>';
326
            }
327
328 12
            if (is_string($key)) {
329 2
                $args[$key] = '<span class="string">\'' . $this->htmlEncode($key) . "'</span> => $args[$key]";
330 11
            } elseif ($isAssoc) {
331 1
                $args[$key] = "<span class=\"number\">$key</span> => $args[$key]";
332
            }
333
        }
334
335
        /** @var string[] $args */
336
337 12
        ksort($args);
338 12
        return implode(', ', $args);
339
    }
340
341
    /**
342
     * Renders the information about request.
343
     *
344
     * @return string The rendering result.
345
     */
346 2
    public function renderRequest(ServerRequestInterface $request): string
347
    {
348 2
        $output = $request->getMethod() . ' ' . $request->getUri() . "\n";
349
350 2
        foreach ($request->getHeaders() as $name => $values) {
351 2
            if ($name === 'Host') {
352 2
                continue;
353
            }
354
355 2
            foreach ($values as $value) {
356 2
                $output .= "$name: $value\n";
357
            }
358
        }
359
360 2
        $output .= "\n" . $request->getBody() . "\n\n";
361
362 2
        return '<pre class="codeBlock language-text">' . $this->htmlEncode(rtrim($output, "\n")) . '</pre>';
363
    }
364
365
    /**
366
     * Renders the information about curl request.
367
     *
368
     * @return string The rendering result.
369
     */
370 2
    public function renderCurl(ServerRequestInterface $request): string
371
    {
372
        try {
373 2
            $output = (new Command())
374 2
                ->setRequest($request)
375 2
                ->build();
376 2
        } catch (Throwable $e) {
377 2
            return $this->htmlEncode('Error generating curl command: ' . $e->getMessage());
378
        }
379
380
        return '<div class="codeBlock language-sh">' . $this->htmlEncode($output) . '</div>';
381
    }
382
383
    /**
384
     * Creates string containing HTML link which refers to the home page
385
     * of determined web-server software and its full name.
386
     *
387
     * @return string The server software information hyperlink.
388
     */
389 9
    public function createServerInformationLink(ServerRequestInterface $request): string
390
    {
391 9
        $serverSoftware = (string) ($request->getServerParams()['SERVER_SOFTWARE'] ?? '');
392
393 9
        if ($serverSoftware === '') {
394 2
            return '';
395
        }
396
397 7
        $serverUrls = [
398 7
            'https://httpd.apache.org/' => ['apache'],
399 7
            'https://nginx.org/' => ['nginx'],
400 7
            'https://lighttpd.net/' => ['lighttpd'],
401 7
            'https://iis.net/' => ['iis', 'services'],
402 7
            'https://www.php.net/manual/en/features.commandline.webserver.php' => ['development'],
403 7
        ];
404
405 7
        foreach ($serverUrls as $url => $keywords) {
406 7
            foreach ($keywords as $keyword) {
407 7
                if (stripos($serverSoftware, $keyword) !== false) {
408 6
                    return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">'
409 6
                        . $this->htmlEncode($serverSoftware) . '</a>';
410
                }
411
            }
412
        }
413
414 1
        return '';
415
    }
416
417
    /**
418
     * Returns the name of the throwable instance.
419
     *
420
     * @return string The name of the throwable instance.
421
     */
422 2
    public function getThrowableName(Throwable $throwable): string
423
    {
424 2
        $name = $throwable::class;
425
426 2
        if ($throwable instanceof FriendlyExceptionInterface) {
427 1
            $name = $throwable->getName() . ' (' . $name . ')';
428
        }
429
430 2
        return $name;
431
    }
432
433
    /**
434
     * Renders a template.
435
     *
436
     * @param string $path The full path of the template file for rendering.
437
     * @param array $parameters The name-value pairs that will be extracted and made available in the template file.
438
     *
439
     * @throws Throwable
440
     *
441
     * @return string The rendering result.
442
     *
443
     * @psalm-suppress PossiblyInvalidFunctionCall
444
     * @psalm-suppress PossiblyFalseArgument
445
     * @psalm-suppress UnresolvableInclude
446
     */
447 10
    private function renderTemplate(string $path, array $parameters): string
448
    {
449 10
        if (!file_exists($path)) {
450 1
            throw new RuntimeException("Template not found at $path");
451
        }
452
453 9
        $renderer = function (): void {
454
            /** @psalm-suppress MixedArgument */
455 9
            extract(func_get_arg(1), EXTR_OVERWRITE);
456 9
            require func_get_arg(0);
457 9
        };
458
459 9
        $obInitialLevel = ob_get_level();
460 9
        ob_start();
461 9
        ob_implicit_flush(false);
462
463
        try {
464
            /** @psalm-suppress PossiblyNullFunctionCall */
465 9
            $renderer->bindTo($this)($path, $parameters);
466 8
            return ob_get_clean();
467 1
        } catch (Throwable $e) {
468 1
            while (ob_get_level() > $obInitialLevel) {
469 1
                if (!@ob_end_clean()) {
470
                    ob_clean();
471
                }
472
            }
473 1
            throw $e;
474
        }
475
    }
476
477
    /**
478
     * Renders a single call stack element.
479
     *
480
     * @param string|null $file The name where call has happened.
481
     * @param int|null $line The number on which call has happened.
482
     * @param string|null $class The called class name.
483
     * @param string|null $function The called function/method name.
484
     * @param array $args The array of method arguments.
485
     * @param int $index The number of the call stack element.
486
     * @param bool $isVendorFile Whether given name of the file belongs to the vendor package.
487
     *
488
     * @throws Throwable
489
     *
490
     * @return string HTML content of the rendered call stack element.
491
     */
492 3
    private function renderCallStackItem(
493
        ?string $file,
494
        ?int $line,
495
        ?string $class,
496
        ?string $function,
497
        array $args,
498
        int $index,
499
        bool $isVendorFile
500
    ): string {
501 3
        $lines = [];
502 3
        $begin = $end = 0;
503
504 3
        if ($file !== null && $line !== null) {
505 3
            $line--; // adjust line number from one-based to zero-based
506 3
            $lines = @file($file);
507 3
            if ($line < 0 || $lines === false || ($lineCount = count($lines)) < $line) {
508 1
                return '';
509
            }
510 2
            $half = (int) (($index === 1 ? $this->maxSourceLines : $this->maxTraceLines) / 2);
511 2
            $begin = $line - $half > 0 ? $line - $half : 0;
512 2
            $end = $line + $half < $lineCount ? $line + $half : $lineCount - 1;
513
        }
514
515 2
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-item.php', [
516 2
            'file' => $file,
517 2
            'line' => $line,
518 2
            'class' => $class,
519 2
            'function' => $function,
520 2
            'index' => $index,
521 2
            'lines' => $lines,
522 2
            'begin' => $begin,
523 2
            'end' => $end,
524 2
            'args' => $args,
525 2
            'isVendorFile' => $isVendorFile,
526 2
        ]);
527
    }
528
529
    /**
530
     * Groups a vendor call stack items to render.
531
     *
532
     * @param array<int, string> $items The list of the vendor call stack items.
533
     *
534
     * @return array<int, array<int, string>> The grouped items of the vendor call stack.
535
     */
536 3
    private function groupVendorCallStackItems(array $items): array
537
    {
538 3
        $groupIndex = null;
539 3
        $groupedItems = [];
540
541 3
        foreach ($items as $index => $item) {
542 1
            if ($groupIndex === null) {
543 1
                $groupIndex = $index;
544 1
                $groupedItems[$groupIndex][$index] = $item;
545 1
                continue;
546
            }
547
548 1
            if (isset($items[$index - 1])) {
549 1
                $groupedItems[$groupIndex][$index] = $item;
550 1
                continue;
551
            }
552
553 1
            $groupIndex = $index;
554 1
            $groupedItems[$groupIndex][$index] = $item;
555
        }
556
557
        /** @psalm-var array<int, array<int, string>> $groupedItems It's need for Psalm <=4.30 only. */
558
559 3
        return $groupedItems;
560
    }
561
562
    /**
563
     * Determines whether given name of the file belongs to the vendor package.
564
     *
565
     * @param string|null $file The name to be checked.
566
     *
567
     * @return bool Whether given name of the file belongs to the vendor package.
568
     */
569 5
    private function isVendorFile(?string $file): bool
570
    {
571 5
        if ($file === null) {
572 1
            return false;
573
        }
574
575 4
        $file = realpath($file);
576
577 4
        if ($file === false) {
578 1
            return false;
579
        }
580
581 3
        foreach ($this->getVendorPaths() as $vendorPath) {
582 1
            if (str_starts_with($file, $vendorPath)) {
583 1
                return true;
584
            }
585
        }
586
587 3
        return false;
588
    }
589
590
    /**
591
     * Returns a list of vendor paths.
592
     *
593
     * @return string[] The list of vendor paths.
594
     *
595
     * @see $vendorPaths
596
     */
597 3
    private function getVendorPaths(): array
598
    {
599 3
        if ($this->vendorPaths !== null) {
600 2
            return $this->vendorPaths;
601
        }
602
603 2
        $rootPath = dirname(__DIR__, 4);
604
605
        // If the error handler is installed as a vendor package.
606
        /** @psalm-suppress InvalidLiteralArgument It is Psalm bug, {@see https://github.com/vimeo/psalm/issues/9196} */
607 2
        if (strlen($rootPath) > 6 && str_contains($rootPath, 'vendor')) {
608
            $this->vendorPaths = [$rootPath];
609
            return $this->vendorPaths;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->vendorPaths returns the type null which is incompatible with the type-hinted return array.
Loading history...
610
        }
611
612
        // If the error handler is installed for development in `yiisoft/yii-dev-tool`.
613 2
        if (is_file("{$rootPath}/yii-dev") || is_file("{$rootPath}/yii-dev.bat")) {
614
            $vendorPaths = glob("{$rootPath}/dev/*/vendor");
615
            /** @var string[] */
616
            $this->vendorPaths = empty($vendorPaths) ? [] : str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $vendorPaths);
617
            return $this->vendorPaths;
618
        }
619
620 2
        $this->vendorPaths = [];
621 2
        return $this->vendorPaths;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->vendorPaths returns the type null which is incompatible with the type-hinted return array.
Loading history...
622
    }
623
}
624