Passed
Pull Request — master (#38)
by Evgeniy
02:51
created

HtmlRenderer::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 1

Importance

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