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