Completed
Pull Request — master (#479)
by Gregor
01:58
created

PrettyPageHandler::getResourcePaths()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
c 0
b 0
f 0
ccs 2
cts 2
cp 1
rs 10
cc 1
eloc 2
nc 1
nop 0
crap 1
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
    ];
99
100
    /**
101
     * @var TemplateHelper
102
     */
103
    private $templateHelper;
104
105
    /**
106
     * Constructor.
107
     */
108 1
    public function __construct()
109
    {
110 1
        if (ini_get('xdebug.file_link_format') || extension_loaded('xdebug')) {
111
            // Register editor using xdebug's file_link_format option.
112
            $this->editors['xdebug'] = function ($file, $line) {
113 1
                return str_replace(['%f', '%l'], [$file, $line], ini_get('xdebug.file_link_format'));
114
            };
115 1
        }
116
117
        // Add the default, local resource search path:
118 1
        $this->searchPaths[] = __DIR__ . "/../Resources";
119
120
        // blacklist php provided auth based values
121 1
        $this->blacklist('_SERVER', 'PHP_AUTH_PW');
122
123 1
        $this->templateHelper = new TemplateHelper();
124
125 1
        if (class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) {
126 1
            $cloner = new VarCloner();
127
            // Only dump object internals if a custom caster exists.
128
            $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...
129
                $class = $stub->class;
130
                $classes = [$class => $class] + class_parents($class) + class_implements($class);
131
132
                foreach ($classes as $class) {
133
                    if (isset(AbstractCloner::$defaultCasters[$class])) {
134
                        return $a;
135
                    }
136
                }
137
138
                // Remove all internals
139
                return [];
140 1
            }]);
141 1
            $this->templateHelper->setCloner($cloner);
142 1
        }
143 1
    }
144
145
    /**
146
     * @return int|null
147
     */
148 1
    public function handle()
149
    {
150 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...
151
            // Check conditions for outputting HTML:
152
            // @todo: Make this more robust
153 1
            if (php_sapi_name() === 'cli') {
154
                // Help users who have been relying on an internal test value
155
                // fix their code to the proper method
156 1
                if (isset($_ENV['whoops-test'])) {
157
                    throw new \Exception(
158
                        'Use handleUnconditionally instead of whoops-test'
159
                        .' environment variable'
160
                    );
161
                }
162
163 1
                return Handler::DONE;
164
            }
165
        }
166
167
        $templateFile = $this->getResource("views/layout.html.php");
168
        $cssFile      = $this->getResource("css/whoops.base.css");
169
        $zeptoFile    = $this->getResource("js/zepto.min.js");
170
        $clipboard    = $this->getResource("js/clipboard.min.js");
171
        $jsFile       = $this->getResource("js/whoops.base.js");
172
173
        if ($this->customCss) {
174
            $customCssFile = $this->getResource($this->customCss);
175
        }
176
177
        $inspector = $this->getInspector();
178
        $frames    = $inspector->getFrames();
179
180
        $code = $inspector->getException()->getCode();
181
182
        if ($inspector->getException() instanceof \ErrorException) {
183
            // ErrorExceptions wrap the php-error types within the "severity" property
184
            $code = Misc::translateErrorCode($inspector->getException()->getSeverity());
185
        }
186
187
        // Detect frames that belong to the application.
188
        if ($this->applicationPaths) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->applicationPaths of type array[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
189
            /* @var \Whoops\Exception\Frame $frame */
190
            foreach ($frames as $frame) {
191
                foreach ($this->applicationPaths as $path) {
192
                    if (substr($frame->getFile(), 0, strlen($path)) === $path) {
193
                        $frame->setApplication(true);
194
                        break;
195
                    }
196
                }
197
            }
198
        }
199
200
        // List of variables that will be passed to the layout template.
201
        $vars = [
202
            "page_title" => $this->getPageTitle(),
203
204
            // @todo: Asset compiler
205
            "stylesheet" => file_get_contents($cssFile),
206
            "zepto"      => file_get_contents($zeptoFile),
207
            "clipboard"  => file_get_contents($clipboard),
208
            "javascript" => file_get_contents($jsFile),
209
210
            // Template paths:
211
            "header"                     => $this->getResource("views/header.html.php"),
212
            "header_outer"               => $this->getResource("views/header_outer.html.php"),
213
            "frame_list"                 => $this->getResource("views/frame_list.html.php"),
214
            "frames_description"         => $this->getResource("views/frames_description.html.php"),
215
            "frames_container"           => $this->getResource("views/frames_container.html.php"),
216
            "panel_details"              => $this->getResource("views/panel_details.html.php"),
217
            "panel_details_outer"        => $this->getResource("views/panel_details_outer.html.php"),
218
            "panel_left"                 => $this->getResource("views/panel_left.html.php"),
219
            "panel_left_outer"           => $this->getResource("views/panel_left_outer.html.php"),
220
            "frame_code"                 => $this->getResource("views/frame_code.html.php"),
221
            "env_details"                => $this->getResource("views/env_details.html.php"),
222
223
            "title"          => $this->getPageTitle(),
224
            "name"           => explode("\\", $inspector->getExceptionName()),
225
            "message"        => $inspector->getException()->getMessage(),
226
            "code"           => $code,
227
            "plain_exception" => Formatter::formatExceptionPlain($inspector),
228
            "frames"         => $frames,
229
            "has_frames"     => !!count($frames),
230
            "handler"        => $this,
231
            "handlers"       => $this->getRun()->getHandlers(),
232
233
            "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...
234
            "has_frames_tabs"   => $this->getApplicationPaths(),
235
236
            "tables"      => [
237
                "GET Data"              => $this->masked('_GET'),
238
                "POST Data"             => $this->masked('_POST'),
239
                "Files"                 => $this->masked('_FILES'),
240
                "Cookies"               => $this->masked('_COOKIE'),
241
                "Session"               => isset($_SESSION) ? $this->masked('_SESSION') :  [],
242
                "Server/Request Data"   => $this->masked('_SERVER'),
243
                "Environment Variables" => $this->masked('_ENV'),
244
            ],
245
        ];
246
247
        if (isset($customCssFile)) {
248
            $vars["stylesheet"] .= file_get_contents($customCssFile);
249
        }
250
251
        // Add extra entries list of data tables:
252
        // @todo: Consolidate addDataTable and addDataTableCallback
253
        $extraTables = array_map(function ($table) use ($inspector) {
254
            return $table instanceof \Closure ? $table($inspector) : $table;
255
        }, $this->getDataTables());
256
        $vars["tables"] = array_merge($extraTables, $vars["tables"]);
257
258
        $plainTextHandler = new PlainTextHandler();
259
        $plainTextHandler->setException($this->getException());
260
        $plainTextHandler->setInspector($this->getInspector());
261
        $vars["preface"] = "<!--\n\n\n" . $plainTextHandler->generateResponse() . "\n\n\n\n\n\n\n\n\n\n\n-->";
262
263
        $this->templateHelper->setVariables($vars);
264
        $this->templateHelper->render($templateFile);
265
266
        return Handler::QUIT;
267
    }
268
269
    /**
270
     * @return string
271
     */
272
    public function contentType()
273
    {
274
        return 'text/html';
275
    }
276
277
    /**
278
     * Adds an entry to the list of tables displayed in the template.
279
     * The expected data is a simple associative array. Any nested arrays
280
     * will be flattened with print_r
281
     * @param string $label
282
     * @param array  $data
283
     */
284 1
    public function addDataTable($label, array $data)
285
    {
286 1
        $this->extraTables[$label] = $data;
287 1
    }
288
289
    /**
290
     * Lazily adds an entry to the list of tables displayed in the table.
291
     * The supplied callback argument will be called when the error is rendered,
292
     * it should produce a simple associative array. Any nested arrays will
293
     * be flattened with print_r.
294
     *
295
     * @throws InvalidArgumentException If $callback is not callable
296
     * @param  string                   $label
297
     * @param  callable                 $callback Callable returning an associative array
298
     */
299 1
    public function addDataTableCallback($label, /* callable */ $callback)
300
    {
301 1
        if (!is_callable($callback)) {
302
            throw new InvalidArgumentException('Expecting callback argument to be callable');
303
        }
304
305 1
        $this->extraTables[$label] = function (\Whoops\Exception\Inspector $inspector = null) use ($callback) {
306
            try {
307 1
                $result = call_user_func($callback, $inspector);
308
309
                // Only return the result if it can be iterated over by foreach().
310 1
                return is_array($result) || $result instanceof \Traversable ? $result : [];
311
            } catch (\Exception $e) {
312
                // Don't allow failure to break the rendering of the original exception.
313
                return [];
314
            }
315
        };
316 1
    }
317
318
    /**
319
     * Returns all the extra data tables registered with this handler.
320
     * Optionally accepts a 'label' parameter, to only return the data
321
     * table under that label.
322
     * @param  string|null      $label
323
     * @return array[]|callable
324
     */
325 2
    public function getDataTables($label = null)
326
    {
327 2
        if ($label !== null) {
328 2
            return isset($this->extraTables[$label]) ?
329 2
                   $this->extraTables[$label] : [];
330
        }
331
332 2
        return $this->extraTables;
333
    }
334
335
    /**
336
     * Allows to disable all attempts to dynamically decide whether to
337
     * handle or return prematurely.
338
     * Set this to ensure that the handler will perform no matter what.
339
     * @param  bool|null $value
340
     * @return bool|null
341
     */
342 1
    public function handleUnconditionally($value = null)
343
    {
344 1
        if (func_num_args() == 0) {
345 1
            return $this->handleUnconditionally;
346
        }
347
348
        $this->handleUnconditionally = (bool) $value;
349
    }
350
351
    /**
352
     * Adds an editor resolver, identified by a string
353
     * name, and that may be a string path, or a callable
354
     * resolver. If the callable returns a string, it will
355
     * be set as the file reference's href attribute.
356
     *
357
     * @example
358
     *  $run->addEditor('macvim', "mvim://open?url=file://%file&line=%line")
359
     * @example
360
     *   $run->addEditor('remove-it', function($file, $line) {
361
     *       unlink($file);
362
     *       return "http://stackoverflow.com";
363
     *   });
364
     * @param string $identifier
365
     * @param string $resolver
366
     */
367 1
    public function addEditor($identifier, $resolver)
368
    {
369 1
        $this->editors[$identifier] = $resolver;
370 1
    }
371
372
    /**
373
     * Set the editor to use to open referenced files, by a string
374
     * identifier, or a callable that will be executed for every
375
     * file reference, with a $file and $line argument, and should
376
     * return a string.
377
     *
378
     * @example
379
     *   $run->setEditor(function($file, $line) { return "file:///{$file}"; });
380
     * @example
381
     *   $run->setEditor('sublime');
382
     *
383
     * @throws InvalidArgumentException If invalid argument identifier provided
384
     * @param  string|callable          $editor
385
     */
386 4
    public function setEditor($editor)
387
    {
388 4
        if (!is_callable($editor) && !isset($this->editors[$editor])) {
389
            throw new InvalidArgumentException(
390
                "Unknown editor identifier: $editor. Known editors:" .
391
                implode(",", array_keys($this->editors))
392
            );
393
        }
394
395 4
        $this->editor = $editor;
396 4
    }
397
398
    /**
399
     * Given a string file path, and an integer file line,
400
     * executes the editor resolver and returns, if available,
401
     * a string that may be used as the href property for that
402
     * file reference.
403
     *
404
     * @throws InvalidArgumentException If editor resolver does not return a string
405
     * @param  string                   $filePath
406
     * @param  int                      $line
407
     * @return string|bool
408
     */
409 4
    public function getEditorHref($filePath, $line)
410
    {
411 4
        $editor = $this->getEditor($filePath, $line);
412
413 4
        if (empty($editor)) {
414
            return false;
415
        }
416
417
        // Check that the editor is a string, and replace the
418
        // %line and %file placeholders:
419 4
        if (!isset($editor['url']) || !is_string($editor['url'])) {
420
            throw new UnexpectedValueException(
421
                __METHOD__ . " should always resolve to a string or a valid editor array; got something else instead."
422
            );
423
        }
424
425 4
        $editor['url'] = str_replace("%line", rawurlencode($line), $editor['url']);
426 4
        $editor['url'] = str_replace("%file", rawurlencode($filePath), $editor['url']);
427
428 4
        return $editor['url'];
429
    }
430
431
    /**
432
     * Given a boolean if the editor link should
433
     * act as an Ajax request. The editor must be a
434
     * valid callable function/closure
435
     *
436
     * @throws UnexpectedValueException  If editor resolver does not return a boolean
437
     * @param  string                   $filePath
438
     * @param  int                      $line
439
     * @return bool
440
     */
441 1
    public function getEditorAjax($filePath, $line)
442
    {
443 1
        $editor = $this->getEditor($filePath, $line);
444
445
        // Check that the ajax is a bool
446 1
        if (!isset($editor['ajax']) || !is_bool($editor['ajax'])) {
447
            throw new UnexpectedValueException(
448
                __METHOD__ . " should always resolve to a bool; got something else instead."
449
            );
450
        }
451 1
        return $editor['ajax'];
452
    }
453
454
    /**
455
     * Given a boolean if the editor link should
456
     * act as an Ajax request. The editor must be a
457
     * valid callable function/closure
458
     *
459
     * @param  string $filePath
460
     * @param  int    $line
461
     * @return array
462
     */
463 1
    protected function getEditor($filePath, $line)
464
    {
465 1
        if (!$this->editor || (!is_string($this->editor) && !is_callable($this->editor))) {
466
            return [];
467
        }
468
469 1
        if (is_string($this->editor) && isset($this->editors[$this->editor]) && !is_callable($this->editors[$this->editor])) {
470
           return [
471
                'ajax' => false,
472
                'url' => $this->editors[$this->editor],
473
            ];
474
        }
475
476 1
        if (is_callable($this->editor) || (isset($this->editors[$this->editor]) && is_callable($this->editors[$this->editor]))) {
477 1
            if (is_callable($this->editor)) {
478
                $callback = call_user_func($this->editor, $filePath, $line);
479
            } else {
480 1
                $callback = call_user_func($this->editors[$this->editor], $filePath, $line);
481
            }
482
483 1
            if (is_string($callback)) {
484
                return [
485 1
                    'ajax' => false,
486 1
                    'url' => $callback,
487 1
                ];
488
            }
489
490
            return [
491
                'ajax' => isset($callback['ajax']) ? $callback['ajax'] : false,
492
                'url' => isset($callback['url']) ? $callback['url'] : $callback,
493
            ];
494
        }
495
496
        return [];
497
    }
498
499
    /**
500
     * @param  string $title
501
     * @return void
502
     */
503 1
    public function setPageTitle($title)
504
    {
505 1
        $this->pageTitle = (string) $title;
506 1
    }
507
508
    /**
509
     * @return string
510
     */
511 1
    public function getPageTitle()
512
    {
513 1
        return $this->pageTitle;
514
    }
515
516
    /**
517
     * Adds a path to the list of paths to be searched for
518
     * resources.
519
     *
520
     * @throws InvalidArgumentException If $path is not a valid directory
521
     *
522
     * @param  string $path
523
     * @return void
524
     */
525 2
    public function addResourcePath($path)
526
    {
527 2
        if (!is_dir($path)) {
528 1
            throw new InvalidArgumentException(
529 1
                "'$path' is not a valid directory"
530 1
            );
531
        }
532
533 1
        array_unshift($this->searchPaths, $path);
534 1
    }
535
536
    /**
537
     * Adds a custom css file to be loaded.
538
     *
539
     * @param  string $name
540
     * @return void
541
     */
542
    public function addCustomCss($name)
543
    {
544
        $this->customCss = $name;
545
    }
546
547
    /**
548
     * @return array
549
     */
550 1
    public function getResourcePaths()
551
    {
552 1
        return $this->searchPaths;
553
    }
554
555
    /**
556
     * Finds a resource, by its relative path, in all available search paths.
557
     * The search is performed starting at the last search path, and all the
558
     * way back to the first, enabling a cascading-type system of overrides
559
     * for all resources.
560
     *
561
     * @throws RuntimeException If resource cannot be found in any of the available paths
562
     *
563
     * @param  string $resource
564
     * @return string
565
     */
566
    protected function getResource($resource)
567
    {
568
        // If the resource was found before, we can speed things up
569
        // by caching its absolute, resolved path:
570
        if (isset($this->resourceCache[$resource])) {
571
            return $this->resourceCache[$resource];
572
        }
573
574
        // Search through available search paths, until we find the
575
        // resource we're after:
576
        foreach ($this->searchPaths as $path) {
577
            $fullPath = $path . "/$resource";
578
579
            if (is_file($fullPath)) {
580
                // Cache the result:
581
                $this->resourceCache[$resource] = $fullPath;
582
                return $fullPath;
583
            }
584
        }
585
586
        // If we got this far, nothing was found.
587
        throw new RuntimeException(
588
            "Could not find resource '$resource' in any resource paths."
589
            . "(searched: " . join(", ", $this->searchPaths). ")"
590
        );
591
    }
592
593
    /**
594
     * @deprecated
595
     *
596
     * @return string
597
     */
598
    public function getResourcesPath()
599
    {
600
        $allPaths = $this->getResourcePaths();
601
602
        // Compat: return only the first path added
603
        return end($allPaths) ?: null;
604
    }
605
606
    /**
607
     * @deprecated
608
     *
609
     * @param  string $resourcesPath
610
     * @return void
611
     */
612
    public function setResourcesPath($resourcesPath)
613
    {
614
        $this->addResourcePath($resourcesPath);
615
    }
616
617
    /**
618
     * Return the application paths.
619
     *
620
     * @return array
621
     */
622
    public function getApplicationPaths()
623
    {
624
        return $this->applicationPaths;
625
    }
626
627
    /**
628
     * Set the application paths.
629
     *
630
     * @param array $applicationPaths
631
     */
632
    public function setApplicationPaths($applicationPaths)
633
    {
634
        $this->applicationPaths = $applicationPaths;
635
    }
636
637
    /**
638
     * Set the application root path.
639
     *
640
     * @param string $applicationRootPath
641
     */
642
    public function setApplicationRootPath($applicationRootPath)
643
    {
644
        $this->templateHelper->setApplicationRootPath($applicationRootPath);
645
    }
646
647
    /**
648
     * blacklist a sensitive value within one of the superglobal arrays.
649
     *
650
     * @param $superGlobalName string the name of the superglobal array, e.g. '_GET'
651
     * @param $key string the key within the superglobal
652
     */
653 1
    public function blacklist($superGlobalName, $key) {
654 1
        $this->blacklist[$superGlobalName][] = $key;
655 1
    }
656
657
    /**
658
     * Checks all values identified by the given superGlobalName within GLOBALS.
659
     * Blacklisted values will be replaced by a equal length string cointaining only '*' characters.
660
     *
661
     * @param $superGlobalName string the name of the superglobal array, e.g. '_GET'
662
     * @return array $values without sensitive data
663
     */
664
    private function masked($superGlobalName) {
665
        $blacklisted = $this->blacklist[$superGlobalName];
666
        $values = $GLOBALS[$superGlobalName];
667
668
        foreach($blacklisted as $key) {
669
            if (isset($values[$key])) {
670
                $values[$key] = str_repeat('*', strlen($values[$key]));
671
            }
672
        }
673
        return $values;
674
    }
675
}
676