Passed
Push — master ( 0b616a...1a71a8 )
by Sergei
02:12
created

HtmlRenderer::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 1

Importance

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