1<?php
2/**
3 * Whoops - php errors for cool kids
4 * @author Filipe Dobreira <http://github.com/filp>
5 */
6
7namespace Whoops\Handler;
8
9use InvalidArgumentException;
10use RuntimeException;
11use UnexpectedValueException;
12use Whoops\Exception\Formatter;
13use Whoops\Util\Misc;
14use Whoops\Util\TemplateHelper;
15
16class PrettyPageHandler extends Handler
17{
18    /**
19     * Search paths to be scanned for resources, in the reverse
20     * order they're declared.
21     *
22     * @var array
23     */
24    private $searchPaths = array();
25
26    /**
27     * Fast lookup cache for known resource locations.
28     *
29     * @var array
30     */
31    private $resourceCache = array();
32
33    /**
34     * The name of the custom css file.
35     *
36     * @var string
37     */
38    private $customCss = null;
39
40    /**
41     * @var array[]
42     */
43    private $extraTables = array();
44
45    /**
46     * @var bool
47     */
48    private $handleUnconditionally = false;
49
50    /**
51     * @var string
52     */
53    private $pageTitle = "Whoops! There was an error.";
54
55    /**
56     * A string identifier for a known IDE/text editor, or a closure
57     * that resolves a string that can be used to open a given file
58     * in an editor. If the string contains the special substrings
59     * %file or %line, they will be replaced with the correct data.
60     *
61     * @example
62     *  "txmt://open?url=%file&line=%line"
63     * @var mixed $editor
64     */
65    protected $editor;
66
67    /**
68     * A list of known editor strings
69     * @var array
70     */
71    protected $editors = array(
72        "sublime"  => "subl://open?url=file://%file&line=%line",
73        "textmate" => "txmt://open?url=file://%file&line=%line",
74        "emacs"    => "emacs://open?url=file://%file&line=%line",
75        "macvim"   => "mvim://open/?url=file://%file&line=%line",
76        "phpstorm" => "phpstorm://open?file=%file&line=%line",
77    );
78
79    /**
80     * Constructor.
81     */
82    public function __construct()
83    {
84        if (ini_get('xdebug.file_link_format') || extension_loaded('xdebug')) {
85            // Register editor using xdebug's file_link_format option.
86            $this->editors['xdebug'] = function ($file, $line) {
87                return str_replace(array('%f', '%l'), array($file, $line), ini_get('xdebug.file_link_format'));
88            };
89        }
90
91        // Add the default, local resource search path:
92        $this->searchPaths[] = __DIR__ . "/../Resources";
93    }
94
95    /**
96     * @return int|null
97     */
98    public function handle()
99    {
100        if (!$this->handleUnconditionally()) {
101            // Check conditions for outputting HTML:
102            // @todo: Make this more robust
103            if (php_sapi_name() === 'cli') {
104                // Help users who have been relying on an internal test value
105                // fix their code to the proper method
106                if (isset($_ENV['whoops-test'])) {
107                    throw new \Exception(
108                        'Use handleUnconditionally instead of whoops-test'
109                        .' environment variable'
110                    );
111                }
112
113                return Handler::DONE;
114            }
115        }
116
117        // @todo: Make this more dynamic
118        $helper = new TemplateHelper();
119
120        $templateFile = $this->getResource("views/layout.html.php");
121        $cssFile      = $this->getResource("css/whoops.base.css");
122        $zeptoFile    = $this->getResource("js/zepto.min.js");
123        $jsFile       = $this->getResource("js/whoops.base.js");
124
125        if ($this->customCss) {
126            $customCssFile = $this->getResource($this->customCss);
127        }
128
129        $inspector = $this->getInspector();
130        $frames    = $inspector->getFrames();
131
132        $code = $inspector->getException()->getCode();
133
134        if ($inspector->getException() instanceof \ErrorException) {
135            // ErrorExceptions wrap the php-error types within the "severity" property
136            $code = Misc::translateErrorCode($inspector->getException()->getSeverity());
137        }
138
139        // List of variables that will be passed to the layout template.
140        $vars = array(
141            "page_title" => $this->getPageTitle(),
142
143            // @todo: Asset compiler
144            "stylesheet" => file_get_contents($cssFile),
145            "zepto"      => file_get_contents($zeptoFile),
146            "javascript" => file_get_contents($jsFile),
147
148            // Template paths:
149            "header"      => $this->getResource("views/header.html.php"),
150            "frame_list"  => $this->getResource("views/frame_list.html.php"),
151            "frame_code"  => $this->getResource("views/frame_code.html.php"),
152            "env_details" => $this->getResource("views/env_details.html.php"),
153
154            "title"          => $this->getPageTitle(),
155            "name"           => explode("\\", $inspector->getExceptionName()),
156            "message"        => $inspector->getException()->getMessage(),
157            "code"           => $code,
158            "plain_exception" => Formatter::formatExceptionPlain($inspector),
159            "frames"         => $frames,
160            "has_frames"     => !!count($frames),
161            "handler"        => $this,
162            "handlers"       => $this->getRun()->getHandlers(),
163
164            "tables"      => array(
165                "GET Data"              => $_GET,
166                "POST Data"             => $_POST,
167                "Files"                 => $_FILES,
168                "Cookies"               => $_COOKIE,
169                "Session"               => isset($_SESSION) ? $_SESSION :  array(),
170                "Server/Request Data"   => $_SERVER,
171                "Environment Variables" => $_ENV,
172            ),
173        );
174
175        if (isset($customCssFile)) {
176            $vars["stylesheet"] .= file_get_contents($customCssFile);
177        }
178
179        // Add extra entries list of data tables:
180        // @todo: Consolidate addDataTable and addDataTableCallback
181        $extraTables = array_map(function ($table) {
182            return $table instanceof \Closure ? $table() : $table;
183        }, $this->getDataTables());
184        $vars["tables"] = array_merge($extraTables, $vars["tables"]);
185
186        $helper->setVariables($vars);
187        $helper->render($templateFile);
188
189        return Handler::QUIT;
190    }
191
192    /**
193     * Adds an entry to the list of tables displayed in the template.
194     * The expected data is a simple associative array. Any nested arrays
195     * will be flattened with print_r
196     * @param string $label
197     * @param array  $data
198     */
199    public function addDataTable($label, array $data)
200    {
201        $this->extraTables[$label] = $data;
202    }
203
204    /**
205     * Lazily adds an entry to the list of tables displayed in the table.
206     * The supplied callback argument will be called when the error is rendered,
207     * it should produce a simple associative array. Any nested arrays will
208     * be flattened with print_r.
209     *
210     * @throws InvalidArgumentException If $callback is not callable
211     * @param  string                   $label
212     * @param  callable                 $callback Callable returning an associative array
213     */
214    public function addDataTableCallback($label, /* callable */ $callback)
215    {
216        if (!is_callable($callback)) {
217            throw new InvalidArgumentException('Expecting callback argument to be callable');
218        }
219
220        $this->extraTables[$label] = function () use ($callback) {
221            try {
222                $result = call_user_func($callback);
223
224                // Only return the result if it can be iterated over by foreach().
225                return is_array($result) || $result instanceof \Traversable ? $result : array();
226            } catch (\Exception $e) {
227                // Don't allow failure to break the rendering of the original exception.
228                return array();
229            }
230        };
231    }
232
233    /**
234     * Returns all the extra data tables registered with this handler.
235     * Optionally accepts a 'label' parameter, to only return the data
236     * table under that label.
237     * @param  string|null      $label
238     * @return array[]|callable
239     */
240    public function getDataTables($label = null)
241    {
242        if ($label !== null) {
243            return isset($this->extraTables[$label]) ?
244                   $this->extraTables[$label] : array();
245        }
246
247        return $this->extraTables;
248    }
249
250    /**
251     * Allows to disable all attempts to dynamically decide whether to
252     * handle or return prematurely.
253     * Set this to ensure that the handler will perform no matter what.
254     * @param  bool|null $value
255     * @return bool|null
256     */
257    public function handleUnconditionally($value = null)
258    {
259        if (func_num_args() == 0) {
260            return $this->handleUnconditionally;
261        }
262
263        $this->handleUnconditionally = (bool) $value;
264    }
265
266    /**
267     * Adds an editor resolver, identified by a string
268     * name, and that may be a string path, or a callable
269     * resolver. If the callable returns a string, it will
270     * be set as the file reference's href attribute.
271     *
272     * @example
273     *  $run->addEditor('macvim', "mvim://open?url=file://%file&line=%line")
274     * @example
275     *   $run->addEditor('remove-it', function($file, $line) {
276     *       unlink($file);
277     *       return "http://stackoverflow.com";
278     *   });
279     * @param string $identifier
280     * @param string $resolver
281     */
282    public function addEditor($identifier, $resolver)
283    {
284        $this->editors[$identifier] = $resolver;
285    }
286
287    /**
288     * Set the editor to use to open referenced files, by a string
289     * identifier, or a callable that will be executed for every
290     * file reference, with a $file and $line argument, and should
291     * return a string.
292     *
293     * @example
294     *   $run->setEditor(function($file, $line) { return "file:///{$file}"; });
295     * @example
296     *   $run->setEditor('sublime');
297     *
298     * @throws InvalidArgumentException If invalid argument identifier provided
299     * @param  string|callable          $editor
300     */
301    public function setEditor($editor)
302    {
303        if (!is_callable($editor) && !isset($this->editors[$editor])) {
304            throw new InvalidArgumentException(
305                "Unknown editor identifier: $editor. Known editors:" .
306                implode(",", array_keys($this->editors))
307            );
308        }
309
310        $this->editor = $editor;
311    }
312
313    /**
314     * Given a string file path, and an integer file line,
315     * executes the editor resolver and returns, if available,
316     * a string that may be used as the href property for that
317     * file reference.
318     *
319     * @throws InvalidArgumentException If editor resolver does not return a string
320     * @param  string                   $filePath
321     * @param  int                      $line
322     * @return string|bool
323     */
324    public function getEditorHref($filePath, $line)
325    {
326        $editor = $this->getEditor($filePath, $line);
327
328        if (!$editor) {
329            return false;
330        }
331
332        // Check that the editor is a string, and replace the
333        // %line and %file placeholders:
334        if (!isset($editor['url']) || !is_string($editor['url'])) {
335            throw new UnexpectedValueException(
336                __METHOD__ . " should always resolve to a string or a valid editor array; got something else instead."
337            );
338        }
339
340        $editor['url'] = str_replace("%line", rawurlencode($line), $editor['url']);
341        $editor['url'] = str_replace("%file", rawurlencode($filePath), $editor['url']);
342
343        return $editor['url'];
344    }
345
346    /**
347     * Given a boolean if the editor link should
348     * act as an Ajax request. The editor must be a
349     * valid callable function/closure
350     *
351     * @throws UnexpectedValueException  If editor resolver does not return a boolean
352     * @param  string                   $filePath
353     * @param  int                      $line
354     * @return bool
355     */
356    public function getEditorAjax($filePath, $line)
357    {
358        $editor = $this->getEditor($filePath, $line);
359
360        // Check that the ajax is a bool
361        if (!isset($editor['ajax']) || !is_bool($editor['ajax'])) {
362            throw new UnexpectedValueException(
363                __METHOD__ . " should always resolve to a bool; got something else instead."
364            );
365        }
366        return $editor['ajax'];
367    }
368
369    /**
370     * Given a boolean if the editor link should
371     * act as an Ajax request. The editor must be a
372     * valid callable function/closure
373     *
374     * @throws UnexpectedValueException  If editor resolver does not return a boolean
375     * @param  string                   $filePath
376     * @param  int                      $line
377     * @return mixed
378     */
379    protected function getEditor($filePath, $line)
380    {
381        if ($this->editor === null && !is_string($this->editor) && !is_callable($this->editor))
382        {
383            return false;
384        }
385        else if(is_string($this->editor) && isset($this->editors[$this->editor]) && !is_callable($this->editors[$this->editor]))
386        {
387           return array(
388                'ajax' => false,
389                'url' => $this->editors[$this->editor],
390            );
391        }
392        else if(is_callable($this->editor) || (isset($this->editors[$this->editor]) && is_callable($this->editors[$this->editor])))
393        {
394            if(is_callable($this->editor))
395            {
396                $callback = call_user_func($this->editor, $filePath, $line);
397            }
398            else
399            {
400                $callback = call_user_func($this->editors[$this->editor], $filePath, $line);
401            }
402
403            return array(
404                'ajax' => isset($callback['ajax']) ? $callback['ajax'] : false,
405                'url' => (is_array($callback) ? $callback['url'] : $callback),
406            );
407        }
408
409        return false;
410    }
411
412    /**
413     * @param  string $title
414     * @return void
415     */
416    public function setPageTitle($title)
417    {
418        $this->pageTitle = (string) $title;
419    }
420
421    /**
422     * @return string
423     */
424    public function getPageTitle()
425    {
426        return $this->pageTitle;
427    }
428
429    /**
430     * Adds a path to the list of paths to be searched for
431     * resources.
432     *
433     * @throws InvalidArgumnetException If $path is not a valid directory
434     *
435     * @param  string $path
436     * @return void
437     */
438    public function addResourcePath($path)
439    {
440        if (!is_dir($path)) {
441            throw new InvalidArgumentException(
442                "'$path' is not a valid directory"
443            );
444        }
445
446        array_unshift($this->searchPaths, $path);
447    }
448
449    /**
450     * Adds a custom css file to be loaded.
451     *
452     * @param  string $name
453     * @return void
454     */
455    public function addCustomCss($name)
456    {
457        $this->customCss = $name;
458    }
459
460    /**
461     * @return array
462     */
463    public function getResourcePaths()
464    {
465        return $this->searchPaths;
466    }
467
468    /**
469     * Finds a resource, by its relative path, in all available search paths.
470     * The search is performed starting at the last search path, and all the
471     * way back to the first, enabling a cascading-type system of overrides
472     * for all resources.
473     *
474     * @throws RuntimeException If resource cannot be found in any of the available paths
475     *
476     * @param  string $resource
477     * @return string
478     */
479    protected function getResource($resource)
480    {
481        // If the resource was found before, we can speed things up
482        // by caching its absolute, resolved path:
483        if (isset($this->resourceCache[$resource])) {
484            return $this->resourceCache[$resource];
485        }
486
487        // Search through available search paths, until we find the
488        // resource we're after:
489        foreach ($this->searchPaths as $path) {
490            $fullPath = $path . "/$resource";
491
492            if (is_file($fullPath)) {
493                // Cache the result:
494                $this->resourceCache[$resource] = $fullPath;
495                return $fullPath;
496            }
497        }
498
499        // If we got this far, nothing was found.
500        throw new RuntimeException(
501            "Could not find resource '$resource' in any resource paths."
502            . "(searched: " . join(", ", $this->searchPaths). ")"
503        );
504    }
505
506    /**
507     * @deprecated
508     *
509     * @return string
510     */
511    public function getResourcesPath()
512    {
513        $allPaths = $this->getResourcePaths();
514
515        // Compat: return only the first path added
516        return end($allPaths) ?: null;
517    }
518
519    /**
520     * @deprecated
521     *
522     * @param  string $resourcesPath
523     * @return void
524     */
525    public function setResourcesPath($resourcesPath)
526    {
527        $this->addResourcePath($resourcesPath);
528    }
529}
530