Passed
Pull Request — master (#36)
by Evgeniy
02:24
created

HtmlRenderer::isVendorFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

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