Completed
Push — master ( 324905...ffbbd2 )
by Denis
01:30
created

PrettyPageHandler::getExceptionFrames()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 17
ccs 0
cts 12
cp 0
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 9
nc 2
nop 0
crap 30
1
<?php
2
/**
3
 * Whoops - php errors for cool kids
4
 * @author Filipe Dobreira <http://github.com/filp>
5
 */
6
7
namespace Whoops\Handler;
8
9
use InvalidArgumentException;
10
use RuntimeException;
11
use Symfony\Component\VarDumper\Cloner\AbstractCloner;
12
use Symfony\Component\VarDumper\Cloner\VarCloner;
13
use UnexpectedValueException;
14
use Whoops\Exception\Formatter;
15
use Whoops\Util\Misc;
16
use Whoops\Util\TemplateHelper;
17
18
class PrettyPageHandler extends Handler
19
{
20
    /**
21
     * Search paths to be scanned for resources, in the reverse
22
     * order they're declared.
23
     *
24
     * @var array
25
     */
26
    private $searchPaths = [];
27
28
    /**
29
     * Fast lookup cache for known resource locations.
30
     *
31
     * @var array
32
     */
33
    private $resourceCache = [];
34
35
    /**
36
     * The name of the custom css file.
37
     *
38
     * @var string
39
     */
40
    private $customCss = null;
41
42
    /**
43
     * @var array[]
44
     */
45
    private $extraTables = [];
46
47
    /**
48
     * @var bool
49
     */
50
    private $handleUnconditionally = false;
51
52
    /**
53
     * @var string
54
     */
55
    private $pageTitle = "Whoops! There was an error.";
56
57
    /**
58
     * @var array[]
59
     */
60
    private $applicationPaths;
61
62
    /**
63
     * @var array[]
64
     */
65
    private $blacklist = [
66
        '_GET' => [],
67
        '_POST' => [],
68
        '_FILES' => [],
69
        '_COOKIE' => [],
70
        '_SESSION' => [],
71
        '_SERVER' => [],
72
        '_ENV' => [],
73
    ];
74
75
    /**
76
     * A string identifier for a known IDE/text editor, or a closure
77
     * that resolves a string that can be used to open a given file
78
     * in an editor. If the string contains the special substrings
79
     * %file or %line, they will be replaced with the correct data.
80
     *
81
     * @example
82
     *  "txmt://open?url=%file&line=%line"
83
     * @var mixed $editor
84
     */
85
    protected $editor;
86
87
    /**
88
     * A list of known editor strings
89
     * @var array
90
     */
91
    protected $editors = [
92
        "sublime"  => "subl://open?url=file://%file&line=%line",
93
        "textmate" => "txmt://open?url=file://%file&line=%line",
94
        "emacs"    => "emacs://open?url=file://%file&line=%line",
95
        "macvim"   => "mvim://open/?url=file://%file&line=%line",
96
        "phpstorm" => "phpstorm://open?file=%file&line=%line",
97
        "idea"     => "idea://open?file=%file&line=%line",
98
        "vscode"   => "vscode://file/%file:%line",
99
    ];
100
101
    /**
102
     * @var TemplateHelper
103
     */
104
    private $templateHelper;
105
106
    /**
107
     * Constructor.
108
     */
109 1
    public function __construct()
110
    {
111 1
        if (ini_get('xdebug.file_link_format') || extension_loaded('xdebug')) {
112
            // Register editor using xdebug's file_link_format option.
113
            $this->editors['xdebug'] = function ($file, $line) {
114 1
                return str_replace(['%f', '%l'], [$file, $line], ini_get('xdebug.file_link_format'));
115
            };
116 1
        }
117
118
        // Add the default, local resource search path:
119 1
        $this->searchPaths[] = __DIR__ . "/../Resources";
120
121
        // blacklist php provided auth based values
122 1
        $this->blacklist('_SERVER', 'PHP_AUTH_PW');
123
124 1
        $this->templateHelper = new TemplateHelper();
125
126 1
        if (class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) {
127 1
            $cloner = new VarCloner();
128
            // Only dump object internals if a custom caster exists.
129
            $cloner->addCasters(['*' => function ($obj, $a, $stub, $isNested, $filter = 0) {
0 ignored issues
show
Unused Code introduced by
The parameter $isNested is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $filter is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
130
                $class = $stub->class;
131
                $classes = [$class => $class] + class_parents($class) + class_implements($class);
132
133
                foreach ($classes as $class) {
134
                    if (isset(AbstractCloner::$defaultCasters[$class])) {
135
                        return $a;
136
                    }
137
                }
138
139
                // Remove all internals
140
                return [];
141 1
            }]);
142 1
            $this->templateHelper->setCloner($cloner);
143 1
        }
144 1
    }
145
146
    /**
147
     * @return int|null
148
     */
149 1
    public function handle()
150
    {
151 1
        if (!$this->handleUnconditionally()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->handleUnconditionally() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
152
            // Check conditions for outputting HTML:
153
            // @todo: Make this more robust
154 1
            if (PHP_SAPI === 'cli') {
155
                // Help users who have been relying on an internal test value
156
                // fix their code to the proper method
157 1
                if (isset($_ENV['whoops-test'])) {
158
                    throw new \Exception(
159
                        'Use handleUnconditionally instead of whoops-test'
160
                        .' environment variable'
161
                    );
162
                }
163
164 1
                return Handler::DONE;
165
            }
166
        }
167
168
        $templateFile = $this->getResource("views/layout.html.php");
169
        $cssFile      = $this->getResource("css/whoops.base.css");
170
        $zeptoFile    = $this->getResource("js/zepto.min.js");
171
        $clipboard    = $this->getResource("js/clipboard.min.js");
172
        $jsFile       = $this->getResource("js/whoops.base.js");
173
174
        if ($this->customCss) {
175
            $customCssFile = $this->getResource($this->customCss);
176
        }
177
178
        $inspector = $this->getInspector();
179
        $frames = $this->getExceptionFrames();
180
        $code = $this->getExceptionCode();
181
182
        // List of variables that will be passed to the layout template.
183
        $vars = [
184
            "page_title" => $this->getPageTitle(),
185
186
            // @todo: Asset compiler
187
            "stylesheet" => file_get_contents($cssFile),
188
            "zepto"      => file_get_contents($zeptoFile),
189
            "clipboard"  => file_get_contents($clipboard),
190
            "javascript" => file_get_contents($jsFile),
191
192
            // Template paths:
193
            "header"                     => $this->getResource("views/header.html.php"),
194
            "header_outer"               => $this->getResource("views/header_outer.html.php"),
195
            "frame_list"                 => $this->getResource("views/frame_list.html.php"),
196
            "frames_description"         => $this->getResource("views/frames_description.html.php"),
197
            "frames_container"           => $this->getResource("views/frames_container.html.php"),
198
            "panel_details"              => $this->getResource("views/panel_details.html.php"),
199
            "panel_details_outer"        => $this->getResource("views/panel_details_outer.html.php"),
200
            "panel_left"                 => $this->getResource("views/panel_left.html.php"),
201
            "panel_left_outer"           => $this->getResource("views/panel_left_outer.html.php"),
202
            "frame_code"                 => $this->getResource("views/frame_code.html.php"),
203
            "env_details"                => $this->getResource("views/env_details.html.php"),
204
205
            "title"          => $this->getPageTitle(),
206
            "name"           => explode("\\", $inspector->getExceptionName()),
207
            "message"        => $inspector->getExceptionMessage(),
208
            "docref_url"     => $inspector->getExceptionDocrefUrl(),
209
            "code"           => $code,
210
            "plain_exception" => Formatter::formatExceptionPlain($inspector),
211
            "frames"         => $frames,
212
            "has_frames"     => !!count($frames),
213
            "handler"        => $this,
214
            "handlers"       => $this->getRun()->getHandlers(),
215
216
            "active_frames_tab" => count($frames) && $frames->offsetGet(0)->isApplication() ?  'application' : 'all',
0 ignored issues
show
Bug introduced by
The method isApplication cannot be called on $frames->offsetGet(0) (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
217
            "has_frames_tabs"   => $this->getApplicationPaths(),
218
219
            "tables"      => [
220
                "GET Data"              => $this->masked($_GET, '_GET'),
221
                "POST Data"             => $this->masked($_POST, '_POST'),
222
                "Files"                 => isset($_FILES) ? $this->masked($_FILES, '_FILES') : [],
223
                "Cookies"               => $this->masked($_COOKIE, '_COOKIE'),
224
                "Session"               => isset($_SESSION) ? $this->masked($_SESSION, '_SESSION') :  [],
225
                "Server/Request Data"   => $this->masked($_SERVER, '_SERVER'),
226
                "Environment Variables" => $this->masked($_ENV, '_ENV'),
227
            ],
228
        ];
229
230
        if (isset($customCssFile)) {
231
            $vars["stylesheet"] .= file_get_contents($customCssFile);
232
        }
233
234
        // Add extra entries list of data tables:
235
        // @todo: Consolidate addDataTable and addDataTableCallback
236
        $extraTables = array_map(function ($table) use ($inspector) {
237
            return $table instanceof \Closure ? $table($inspector) : $table;
238
        }, $this->getDataTables());
239
        $vars["tables"] = array_merge($extraTables, $vars["tables"]);
240
241
        $plainTextHandler = new PlainTextHandler();
242
        $plainTextHandler->setException($this->getException());
243
        $plainTextHandler->setInspector($this->getInspector());
244
        $vars["preface"] = "<!--\n\n\n" .  $this->templateHelper->escape($plainTextHandler->generateResponse()) . "\n\n\n\n\n\n\n\n\n\n\n-->";
245
246
        $this->templateHelper->setVariables($vars);
247
        $this->templateHelper->render($templateFile);
248
249
        return Handler::QUIT;
250
    }
251
252
    /**
253
     * Get the stack trace frames of the exception that is currently being handled.
254
     *
255
     * @return \Whoops\Exception\FrameCollection;
0 ignored issues
show
Documentation introduced by
The doc-type \Whoops\Exception\FrameCollection; could not be parsed: Expected "|" or "end of type", but got ";" at position 33. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
256
     */
257
    protected function getExceptionFrames()
258
    {
259
        $frames = $this->getInspector()->getFrames();
260
261
        if ($this->getApplicationPaths()) {
262
            foreach ($frames as $frame) {
263
                foreach ($this->getApplicationPaths() as $path) {
264
                    if (strpos($frame->getFile(), $path) === 0) {
265
                        $frame->setApplication(true);
266
                        break;
267
                    }
268
                }
269
            }
270
        }
271
272
        return $frames;
273
    }
274
275
    /**
276
     * Get the code of the exception that is currently being handled.
277
     *
278
     * @return string
279
     */
280
    protected function getExceptionCode()
281
    {
282
        $exception = $this->getException();
283
284
        $code = $exception->getCode();
285
        if ($exception instanceof \ErrorException) {
286
            // ErrorExceptions wrap the php-error types within the 'severity' property
287
            $code = Misc::translateErrorCode($exception->getSeverity());
288
        }
289
290
        return (string) $code;
291
    }
292
293
    /**
294
     * @return string
295
     */
296
    public function contentType()
297
    {
298
        return 'text/html';
299
    }
300
301
    /**
302
     * Adds an entry to the list of tables displayed in the template.
303
     * The expected data is a simple associative array. Any nested arrays
304
     * will be flattened with print_r
305
     * @param string $label
306
     * @param array  $data
307
     */
308 1
    public function addDataTable($label, array $data)
309
    {
310 1
        $this->extraTables[$label] = $data;
311 1
    }
312
313
    /**
314
     * Lazily adds an entry to the list of tables displayed in the table.
315
     * The supplied callback argument will be called when the error is rendered,
316
     * it should produce a simple associative array. Any nested arrays will
317
     * be flattened with print_r.
318
     *
319
     * @throws InvalidArgumentException If $callback is not callable
320
     * @param  string                   $label
321
     * @param  callable                 $callback Callable returning an associative array
322
     */
323 1
    public function addDataTableCallback($label, /* callable */ $callback)
324
    {
325 1
        if (!is_callable($callback)) {
326
            throw new InvalidArgumentException('Expecting callback argument to be callable');
327
        }
328
329 1
        $this->extraTables[$label] = function (\Whoops\Exception\Inspector $inspector = null) use ($callback) {
330
            try {
331 1
                $result = call_user_func($callback, $inspector);
332
333
                // Only return the result if it can be iterated over by foreach().
334 1
                return is_array($result) || $result instanceof \Traversable ? $result : [];
335
            } catch (\Exception $e) {
336
                // Don't allow failure to break the rendering of the original exception.
337
                return [];
338
            }
339
        };
340 1
    }
341
342
    /**
343
     * Returns all the extra data tables registered with this handler.
344
     * Optionally accepts a 'label' parameter, to only return the data
345
     * table under that label.
346
     * @param  string|null      $label
347
     * @return array[]|callable
348
     */
349 2
    public function getDataTables($label = null)
350
    {
351 2
        if ($label !== null) {
352 2
            return isset($this->extraTables[$label]) ?
353 2
                   $this->extraTables[$label] : [];
354
        }
355
356 2
        return $this->extraTables;
357
    }
358
359
    /**
360
     * Allows to disable all attempts to dynamically decide whether to
361
     * handle or return prematurely.
362
     * Set this to ensure that the handler will perform no matter what.
363
     * @param  bool|null $value
364
     * @return bool|null
365
     */
366 1
    public function handleUnconditionally($value = null)
367
    {
368 1
        if (func_num_args() == 0) {
369 1
            return $this->handleUnconditionally;
370
        }
371
372
        $this->handleUnconditionally = (bool) $value;
373
    }
374
375
    /**
376
     * Adds an editor resolver, identified by a string
377
     * name, and that may be a string path, or a callable
378
     * resolver. If the callable returns a string, it will
379
     * be set as the file reference's href attribute.
380
     *
381
     * @example
382
     *  $run->addEditor('macvim', "mvim://open?url=file://%file&line=%line")
383
     * @example
384
     *   $run->addEditor('remove-it', function($file, $line) {
385
     *       unlink($file);
386
     *       return "http://stackoverflow.com";
387
     *   });
388
     * @param string $identifier
389
     * @param string $resolver
390
     */
391 1
    public function addEditor($identifier, $resolver)
392
    {
393 1
        $this->editors[$identifier] = $resolver;
394 1
    }
395
396
    /**
397
     * Set the editor to use to open referenced files, by a string
398
     * identifier, or a callable that will be executed for every
399
     * file reference, with a $file and $line argument, and should
400
     * return a string.
401
     *
402
     * @example
403
     *   $run->setEditor(function($file, $line) { return "file:///{$file}"; });
404
     * @example
405
     *   $run->setEditor('sublime');
406
     *
407
     * @throws InvalidArgumentException If invalid argument identifier provided
408
     * @param  string|callable          $editor
409
     */
410 4
    public function setEditor($editor)
411
    {
412 4
        if (!is_callable($editor) && !isset($this->editors[$editor])) {
413
            throw new InvalidArgumentException(
414
                "Unknown editor identifier: $editor. Known editors:" .
415
                implode(",", array_keys($this->editors))
416
            );
417
        }
418
419 4
        $this->editor = $editor;
420 4
    }
421
422
    /**
423
     * Given a string file path, and an integer file line,
424
     * executes the editor resolver and returns, if available,
425
     * a string that may be used as the href property for that
426
     * file reference.
427
     *
428
     * @throws InvalidArgumentException If editor resolver does not return a string
429
     * @param  string                   $filePath
430
     * @param  int                      $line
431
     * @return string|bool
432
     */
433 4
    public function getEditorHref($filePath, $line)
434
    {
435 4
        $editor = $this->getEditor($filePath, $line);
436
437 4
        if (empty($editor)) {
438
            return false;
439
        }
440
441
        // Check that the editor is a string, and replace the
442
        // %line and %file placeholders:
443 4
        if (!isset($editor['url']) || !is_string($editor['url'])) {
444
            throw new UnexpectedValueException(
445
                __METHOD__ . " should always resolve to a string or a valid editor array; got something else instead."
446
            );
447
        }
448
449 4
        $editor['url'] = str_replace("%line", rawurlencode($line), $editor['url']);
450 4
        $editor['url'] = str_replace("%file", rawurlencode($filePath), $editor['url']);
451
452 4
        return $editor['url'];
453
    }
454
455
    /**
456
     * Given a boolean if the editor link should
457
     * act as an Ajax request. The editor must be a
458
     * valid callable function/closure
459
     *
460
     * @throws UnexpectedValueException  If editor resolver does not return a boolean
461
     * @param  string                   $filePath
462
     * @param  int                      $line
463
     * @return bool
464
     */
465 1
    public function getEditorAjax($filePath, $line)
466
    {
467 1
        $editor = $this->getEditor($filePath, $line);
468
469
        // Check that the ajax is a bool
470 1
        if (!isset($editor['ajax']) || !is_bool($editor['ajax'])) {
471
            throw new UnexpectedValueException(
472
                __METHOD__ . " should always resolve to a bool; got something else instead."
473
            );
474
        }
475 1
        return $editor['ajax'];
476
    }
477
478
    /**
479
     * Given a boolean if the editor link should
480
     * act as an Ajax request. The editor must be a
481
     * valid callable function/closure
482
     *
483
     * @param  string $filePath
484
     * @param  int    $line
485
     * @return array
486
     */
487 1
    protected function getEditor($filePath, $line)
488
    {
489 1
        if (!$this->editor || (!is_string($this->editor) && !is_callable($this->editor))) {
490
            return [];
491
        }
492
493 1
        if (is_string($this->editor) && isset($this->editors[$this->editor]) && !is_callable($this->editors[$this->editor])) {
494
           return [
495
                'ajax' => false,
496
                'url' => $this->editors[$this->editor],
497
            ];
498
        }
499
500 1
        if (is_callable($this->editor) || (isset($this->editors[$this->editor]) && is_callable($this->editors[$this->editor]))) {
501 1
            if (is_callable($this->editor)) {
502
                $callback = call_user_func($this->editor, $filePath, $line);
503
            } else {
504 1
                $callback = call_user_func($this->editors[$this->editor], $filePath, $line);
505
            }
506
507 1
            if (is_string($callback)) {
508
                return [
509 1
                    'ajax' => false,
510 1
                    'url' => $callback,
511 1
                ];
512
            }
513
514
            return [
515
                'ajax' => isset($callback['ajax']) ? $callback['ajax'] : false,
516
                'url' => isset($callback['url']) ? $callback['url'] : $callback,
517
            ];
518
        }
519
520
        return [];
521
    }
522
523
    /**
524
     * @param  string $title
525
     * @return void
526
     */
527 1
    public function setPageTitle($title)
528
    {
529 1
        $this->pageTitle = (string) $title;
530 1
    }
531
532
    /**
533
     * @return string
534
     */
535 1
    public function getPageTitle()
536
    {
537 1
        return $this->pageTitle;
538
    }
539
540
    /**
541
     * Adds a path to the list of paths to be searched for
542
     * resources.
543
     *
544
     * @throws InvalidArgumentException If $path is not a valid directory
545
     *
546
     * @param  string $path
547
     * @return void
548
     */
549 2
    public function addResourcePath($path)
550
    {
551 2
        if (!is_dir($path)) {
552 1
            throw new InvalidArgumentException(
553 1
                "'$path' is not a valid directory"
554 1
            );
555
        }
556
557 1
        array_unshift($this->searchPaths, $path);
558 1
    }
559
560
    /**
561
     * Adds a custom css file to be loaded.
562
     *
563
     * @param  string $name
564
     * @return void
565
     */
566
    public function addCustomCss($name)
567
    {
568
        $this->customCss = $name;
569
    }
570
571
    /**
572
     * @return array
573
     */
574 1
    public function getResourcePaths()
575
    {
576 1
        return $this->searchPaths;
577
    }
578
579
    /**
580
     * Finds a resource, by its relative path, in all available search paths.
581
     * The search is performed starting at the last search path, and all the
582
     * way back to the first, enabling a cascading-type system of overrides
583
     * for all resources.
584
     *
585
     * @throws RuntimeException If resource cannot be found in any of the available paths
586
     *
587
     * @param  string $resource
588
     * @return string
589
     */
590
    protected function getResource($resource)
591
    {
592
        // If the resource was found before, we can speed things up
593
        // by caching its absolute, resolved path:
594
        if (isset($this->resourceCache[$resource])) {
595
            return $this->resourceCache[$resource];
596
        }
597
598
        // Search through available search paths, until we find the
599
        // resource we're after:
600
        foreach ($this->searchPaths as $path) {
601
            $fullPath = $path . "/$resource";
602
603
            if (is_file($fullPath)) {
604
                // Cache the result:
605
                $this->resourceCache[$resource] = $fullPath;
606
                return $fullPath;
607
            }
608
        }
609
610
        // If we got this far, nothing was found.
611
        throw new RuntimeException(
612
            "Could not find resource '$resource' in any resource paths."
613
            . "(searched: " . join(", ", $this->searchPaths). ")"
614
        );
615
    }
616
617
    /**
618
     * @deprecated
619
     *
620
     * @return string
621
     */
622
    public function getResourcesPath()
623
    {
624
        $allPaths = $this->getResourcePaths();
625
626
        // Compat: return only the first path added
627
        return end($allPaths) ?: null;
628
    }
629
630
    /**
631
     * @deprecated
632
     *
633
     * @param  string $resourcesPath
634
     * @return void
635
     */
636
    public function setResourcesPath($resourcesPath)
637
    {
638
        $this->addResourcePath($resourcesPath);
639
    }
640
641
    /**
642
     * Return the application paths.
643
     *
644
     * @return array
645
     */
646
    public function getApplicationPaths()
647
    {
648
        return $this->applicationPaths;
649
    }
650
651
    /**
652
     * Set the application paths.
653
     *
654
     * @param array $applicationPaths
655
     */
656
    public function setApplicationPaths($applicationPaths)
657
    {
658
        $this->applicationPaths = $applicationPaths;
659
    }
660
661
    /**
662
     * Set the application root path.
663
     *
664
     * @param string $applicationRootPath
665
     */
666
    public function setApplicationRootPath($applicationRootPath)
667
    {
668
        $this->templateHelper->setApplicationRootPath($applicationRootPath);
669
    }
670
671
    /**
672
     * blacklist a sensitive value within one of the superglobal arrays.
673
     *
674
     * @param $superGlobalName string the name of the superglobal array, e.g. '_GET'
675
     * @param $key string the key within the superglobal
676
     */
677 1
    public function blacklist($superGlobalName, $key) {
678 1
        $this->blacklist[$superGlobalName][] = $key;
679 1
    }
680
681
    /**
682
     * Checks all values within the given superGlobal array.
683
     * Blacklisted values will be replaced by a equal length string cointaining only '*' characters.
684
     *
685
     * We intentionally dont rely on $GLOBALS as it depends on 'auto_globals_jit' php.ini setting.
686
     *
687
     * @param $superGlobal array One of the superglobal arrays
688
     * @param $superGlobalName string the name of the superglobal array, e.g. '_GET'
689
     * @return array $values without sensitive data
690
     */
691
    private function masked(array $superGlobal, $superGlobalName) {
692
        $blacklisted = $this->blacklist[$superGlobalName];
693
694
        $values = $superGlobal;
695
        foreach($blacklisted as $key) {
696
            if (isset($superGlobal[$key])) {
697
                $values[$key] = str_repeat('*', strlen($superGlobal[$key]));
698
            }
699
        }
700
        return $values;
701
    }
702
}
703