WebExceptionRenderer.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * For full copyright and license information, please see the LICENSE.txt
  9. * Redistributions of files must retain the above copyright notice.
  10. *
  11. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  12. * @link https://cakephp.org CakePHP Project
  13. * @since 4.4.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Error\Renderer;
  17. use Cake\Controller\Controller;
  18. use Cake\Controller\ControllerFactory;
  19. use Cake\Controller\Exception\InvalidParameterException;
  20. use Cake\Controller\Exception\MissingActionException;
  21. use Cake\Core\App;
  22. use Cake\Core\Configure;
  23. use Cake\Core\Container;
  24. use Cake\Core\Exception\CakeException;
  25. use Cake\Core\Exception\MissingPluginException;
  26. use Cake\Datasource\Exception\RecordNotFoundException;
  27. use Cake\Datasource\Paging\Exception\PageOutOfBoundsException;
  28. use Cake\Error\Debugger;
  29. use Cake\Error\ExceptionRendererInterface;
  30. use Cake\Http\Exception\HttpException;
  31. use Cake\Http\Exception\MissingControllerException;
  32. use Cake\Http\Response;
  33. use Cake\Http\ResponseEmitter;
  34. use Cake\Http\ServerRequest;
  35. use Cake\Http\ServerRequestFactory;
  36. use Cake\Log\Log;
  37. use Cake\Routing\Exception\MissingRouteException;
  38. use Cake\Routing\Router;
  39. use Cake\Utility\Inflector;
  40. use Cake\View\Exception\MissingLayoutException;
  41. use Cake\View\Exception\MissingTemplateException;
  42. use PDOException;
  43. use Psr\Http\Message\ResponseInterface;
  44. use Throwable;
  45. use function Cake\Core\h;
  46. use function Cake\Core\namespaceSplit;
  47. use function Cake\I18n\__d;
  48. /**
  49. * Web Exception Renderer.
  50. *
  51. * Captures and handles all unhandled exceptions. Displays helpful framework errors when debug is true.
  52. * When debug is false, WebExceptionRenderer will render 404 or 500 errors. If an uncaught exception is thrown
  53. * and it is a type that WebExceptionHandler does not know about it will be treated as a 500 error.
  54. *
  55. * ### Implementing application specific exception rendering
  56. *
  57. * You can implement application specific exception handling by creating a subclass of
  58. * WebExceptionRenderer and configure it to be the `exceptionRenderer` in config/error.php
  59. *
  60. * #### Using a subclass of WebExceptionRenderer
  61. *
  62. * Using a subclass of WebExceptionRenderer gives you full control over how Exceptions are rendered, you
  63. * can configure your class in your config/app.php.
  64. */
  65. class WebExceptionRenderer implements ExceptionRendererInterface
  66. {
  67. /**
  68. * The exception being handled.
  69. *
  70. * @var \Throwable
  71. */
  72. protected Throwable $error;
  73. /**
  74. * Controller instance.
  75. *
  76. * @var \Cake\Controller\Controller
  77. */
  78. protected Controller $controller;
  79. /**
  80. * Template to render for {@link \Cake\Core\Exception\CakeException}
  81. *
  82. * @var string
  83. */
  84. protected string $template = '';
  85. /**
  86. * The method corresponding to the Exception this object is for.
  87. *
  88. * @var string
  89. */
  90. protected string $method = '';
  91. /**
  92. * If set, this will be request used to create the controller that will render
  93. * the error.
  94. *
  95. * @var \Cake\Http\ServerRequest|null
  96. */
  97. protected ?ServerRequest $request;
  98. /**
  99. * Map of exceptions to http status codes.
  100. *
  101. * This can be customized for users that don't want specific exceptions to throw 404 errors
  102. * or want their application exceptions to be automatically converted.
  103. *
  104. * @var array<string, int>
  105. * @psalm-var array<class-string<\Throwable>, int>
  106. */
  107. protected array $exceptionHttpCodes = [
  108. // Controller exceptions
  109. InvalidParameterException::class => 404,
  110. MissingActionException::class => 404,
  111. // Datasource exceptions
  112. PageOutOfBoundsException::class => 404,
  113. RecordNotFoundException::class => 404,
  114. // Http exceptions
  115. MissingControllerException::class => 404,
  116. // Routing exceptions
  117. MissingRouteException::class => 404,
  118. ];
  119. /**
  120. * Creates the controller to perform rendering on the error response.
  121. *
  122. * @param \Throwable $exception Exception.
  123. * @param \Cake\Http\ServerRequest|null $request The request if this is set it will be used
  124. * instead of creating a new one.
  125. */
  126. public function __construct(Throwable $exception, ?ServerRequest $request = null)
  127. {
  128. $this->error = $exception;
  129. $this->request = $request;
  130. $this->controller = $this->_getController();
  131. }
  132. /**
  133. * Get the controller instance to handle the exception.
  134. * Override this method in subclasses to customize the controller used.
  135. * This method returns the built in `ErrorController` normally, or if an error is repeated
  136. * a bare controller will be used.
  137. *
  138. * @return \Cake\Controller\Controller
  139. * @triggers Controller.startup $controller
  140. */
  141. protected function _getController(): Controller
  142. {
  143. $request = $this->request;
  144. $routerRequest = Router::getRequest();
  145. // Fallback to the request in the router or make a new one from
  146. // $_SERVER
  147. $request ??= $routerRequest ?: ServerRequestFactory::fromGlobals();
  148. // If the current request doesn't have routing data, but we
  149. // found a request in the router context copy the params over
  150. if ($request->getParam('controller') === null && $routerRequest !== null) {
  151. $request = $request->withAttribute('params', $routerRequest->getAttribute('params'));
  152. }
  153. $class = '';
  154. try {
  155. $params = $request->getAttribute('params');
  156. $params['controller'] = 'Error';
  157. $factory = new ControllerFactory(new Container());
  158. // Check including plugin + prefix
  159. $class = $factory->getControllerClass($request->withAttribute('params', $params));
  160. if (!$class && !empty($params['prefix']) && !empty($params['plugin'])) {
  161. unset($params['prefix']);
  162. // Fallback to only plugin
  163. $class = $factory->getControllerClass($request->withAttribute('params', $params));
  164. }
  165. if (!$class) {
  166. // Fallback to app/core provided controller.
  167. /** @var string $class */
  168. $class = App::className('Error', 'Controller', 'Controller');
  169. }
  170. assert(is_subclass_of($class, Controller::class));
  171. $controller = new $class($request);
  172. $controller->startupProcess();
  173. } catch (Throwable $e) {
  174. Log::warning(
  175. "Failed to construct or call startup() on the resolved controller class of `$class`. " .
  176. "Using Fallback Controller instead. Error {$e->getMessage()}" .
  177. "\nStack Trace\n: {$e->getTraceAsString()}",
  178. 'cake.error'
  179. );
  180. $controller = null;
  181. }
  182. if ($controller === null) {
  183. return new Controller($request);
  184. }
  185. return $controller;
  186. }
  187. /**
  188. * Clear output buffers so error pages display properly.
  189. *
  190. * @return void
  191. */
  192. protected function clearOutput(): void
  193. {
  194. if (in_array(PHP_SAPI, ['cli', 'phpdbg'])) {
  195. return;
  196. }
  197. while (ob_get_level()) {
  198. ob_end_clean();
  199. }
  200. }
  201. /**
  202. * Renders the response for the exception.
  203. *
  204. * @return \Cake\Http\Response The response to be sent.
  205. */
  206. public function render(): ResponseInterface
  207. {
  208. $exception = $this->error;
  209. $code = $this->getHttpCode($exception);
  210. $method = $this->_method($exception);
  211. $template = $this->_template($exception, $method, $code);
  212. $this->clearOutput();
  213. if (method_exists($this, $method)) {
  214. return $this->_customMethod($method, $exception);
  215. }
  216. $message = $this->_message($exception, $code);
  217. $url = $this->controller->getRequest()->getRequestTarget();
  218. $response = $this->controller->getResponse();
  219. if ($exception instanceof HttpException) {
  220. foreach ($exception->getHeaders() as $name => $value) {
  221. $response = $response->withHeader($name, $value);
  222. }
  223. }
  224. $response = $response->withStatus($code);
  225. $exceptions = [$exception];
  226. $previous = $exception->getPrevious();
  227. while ($previous != null) {
  228. $exceptions[] = $previous;
  229. $previous = $previous->getPrevious();
  230. }
  231. $viewVars = [
  232. 'message' => $message,
  233. 'url' => h($url),
  234. 'error' => $exception,
  235. 'exceptions' => $exceptions,
  236. 'code' => $code,
  237. ];
  238. $serialize = ['message', 'url', 'code'];
  239. $isDebug = Configure::read('debug');
  240. if ($isDebug) {
  241. $trace = (array)Debugger::formatTrace($exception->getTrace(), [
  242. 'format' => 'array',
  243. 'args' => true,
  244. ]);
  245. $origin = [
  246. 'file' => $exception->getFile() ?: 'null',
  247. 'line' => $exception->getLine() ?: 'null',
  248. ];
  249. // Traces don't include the origin file/line.
  250. array_unshift($trace, $origin);
  251. $viewVars['trace'] = $trace;
  252. $viewVars += $origin;
  253. $serialize[] = 'file';
  254. $serialize[] = 'line';
  255. }
  256. $this->controller->set($viewVars);
  257. $this->controller->viewBuilder()->setOption('serialize', $serialize);
  258. if ($exception instanceof CakeException && $isDebug) {
  259. $this->controller->set($exception->getAttributes());
  260. }
  261. $this->controller->setResponse($response);
  262. return $this->_outputMessage($template);
  263. }
  264. /**
  265. * Emit the response content
  266. *
  267. * @param \Psr\Http\Message\ResponseInterface|string $output The response to output.
  268. * @return void
  269. */
  270. public function write(ResponseInterface|string $output): void
  271. {
  272. if (is_string($output)) {
  273. echo $output;
  274. return;
  275. }
  276. $emitter = new ResponseEmitter();
  277. $emitter->emit($output);
  278. }
  279. /**
  280. * Render a custom error method/template.
  281. *
  282. * @param string $method The method name to invoke.
  283. * @param \Throwable $exception The exception to render.
  284. * @return \Cake\Http\Response The response to send.
  285. */
  286. protected function _customMethod(string $method, Throwable $exception): Response
  287. {
  288. $result = $this->{$method}($exception);
  289. $this->_shutdown();
  290. if (is_string($result)) {
  291. $result = $this->controller->getResponse()->withStringBody($result);
  292. }
  293. return $result;
  294. }
  295. /**
  296. * Get method name
  297. *
  298. * @param \Throwable $exception Exception instance.
  299. * @return string
  300. */
  301. protected function _method(Throwable $exception): string
  302. {
  303. [, $baseClass] = namespaceSplit($exception::class);
  304. if (str_ends_with($baseClass, 'Exception')) {
  305. $baseClass = substr($baseClass, 0, -9);
  306. }
  307. // $baseClass would be an empty string if the exception class is \Exception.
  308. $method = $baseClass === '' ? 'error500' : Inflector::variable($baseClass);
  309. return $this->method = $method;
  310. }
  311. /**
  312. * Get error message.
  313. *
  314. * @param \Throwable $exception Exception.
  315. * @param int $code Error code.
  316. * @return string Error message
  317. */
  318. protected function _message(Throwable $exception, int $code): string
  319. {
  320. $message = $exception->getMessage();
  321. if (
  322. !Configure::read('debug') &&
  323. !($exception instanceof HttpException)
  324. ) {
  325. if ($code < 500) {
  326. $message = __d('cake', 'Not Found');
  327. } else {
  328. $message = __d('cake', 'An Internal Error Has Occurred.');
  329. }
  330. }
  331. return $message;
  332. }
  333. /**
  334. * Get template for rendering exception info.
  335. *
  336. * @param \Throwable $exception Exception instance.
  337. * @param string $method Method name.
  338. * @param int $code Error code.
  339. * @return string Template name
  340. */
  341. protected function _template(Throwable $exception, string $method, int $code): string
  342. {
  343. if ($exception instanceof HttpException || !Configure::read('debug')) {
  344. return $this->template = $code < 500 ? 'error400' : 'error500';
  345. }
  346. if ($exception instanceof PDOException) {
  347. return $this->template = 'pdo_error';
  348. }
  349. return $this->template = $method;
  350. }
  351. /**
  352. * Gets the appropriate http status code for exception.
  353. *
  354. * @param \Throwable $exception Exception.
  355. * @return int A valid HTTP status code.
  356. */
  357. protected function getHttpCode(Throwable $exception): int
  358. {
  359. if ($exception instanceof HttpException) {
  360. return $exception->getCode();
  361. }
  362. return $this->exceptionHttpCodes[$exception::class] ?? 500;
  363. }
  364. /**
  365. * Generate the response using the controller object.
  366. *
  367. * @param string $template The template to render.
  368. * @return \Cake\Http\Response A response object that can be sent.
  369. */
  370. protected function _outputMessage(string $template): Response
  371. {
  372. try {
  373. $this->controller->render($template);
  374. return $this->_shutdown();
  375. } catch (MissingTemplateException $e) {
  376. Log::warning(
  377. "MissingTemplateException - Failed to render error template `{$template}` . Error: {$e->getMessage()}" .
  378. "\nStack Trace\n: {$e->getTraceAsString()}",
  379. 'cake.error'
  380. );
  381. $attributes = $e->getAttributes();
  382. if (
  383. $e instanceof MissingLayoutException ||
  384. str_contains($attributes['file'], 'error500')
  385. ) {
  386. return $this->_outputMessageSafe('error500');
  387. }
  388. return $this->_outputMessage('error500');
  389. } catch (MissingPluginException $e) {
  390. Log::warning(
  391. "MissingPluginException - Failed to render error template `{$template}`. Error: {$e->getMessage()}" .
  392. "\nStack Trace\n: {$e->getTraceAsString()}",
  393. 'cake.error'
  394. );
  395. $attributes = $e->getAttributes();
  396. if (isset($attributes['plugin']) && $attributes['plugin'] === $this->controller->getPlugin()) {
  397. $this->controller->setPlugin(null);
  398. }
  399. return $this->_outputMessageSafe('error500');
  400. } catch (Throwable $outer) {
  401. Log::warning(
  402. "Throwable - Failed to render error template `{$template}`. Error: {$outer->getMessage()}" .
  403. "\nStack Trace\n: {$outer->getTraceAsString()}",
  404. 'cake.error'
  405. );
  406. try {
  407. return $this->_outputMessageSafe('error500');
  408. } catch (Throwable $inner) {
  409. throw $outer;
  410. }
  411. }
  412. }
  413. /**
  414. * A safer way to render error messages, replaces all helpers, with basics
  415. * and doesn't call component methods.
  416. *
  417. * @param string $template The template to render.
  418. * @return \Cake\Http\Response A response object that can be sent.
  419. */
  420. protected function _outputMessageSafe(string $template): Response
  421. {
  422. $builder = $this->controller->viewBuilder();
  423. $builder
  424. ->setHelpers([])
  425. ->setLayoutPath('')
  426. ->setTemplatePath('Error');
  427. $view = $this->controller->createView('View');
  428. $response = $this->controller->getResponse()
  429. ->withType('html')
  430. ->withStringBody($view->render($template, 'error'));
  431. $this->controller->setResponse($response);
  432. return $response;
  433. }
  434. /**
  435. * Run the shutdown events.
  436. *
  437. * Triggers the afterFilter and afterDispatch events.
  438. *
  439. * @return \Cake\Http\Response The response to serve.
  440. */
  441. protected function _shutdown(): Response
  442. {
  443. $this->controller->dispatchEvent('Controller.shutdown');
  444. return $this->controller->getResponse();
  445. }
  446. /**
  447. * Returns an array that can be used to describe the internal state of this
  448. * object.
  449. *
  450. * @return array<string, mixed>
  451. */
  452. public function __debugInfo(): array
  453. {
  454. return [
  455. 'error' => $this->error,
  456. 'request' => $this->request,
  457. 'controller' => $this->controller,
  458. 'template' => $this->template,
  459. 'method' => $this->method,
  460. ];
  461. }
  462. }