From 54cc18b21dcc47252e9a534c448642fce3cb4e90 Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 15:35:25 +0900 Subject: [PATCH 01/17] feat: add Debug\ExceptionHandler --- app/Config/Exceptions.php | 26 +++ system/Debug/BaseExceptionHandler.php | 246 ++++++++++++++++++++ system/Debug/ExceptionHandler.php | 136 +++++++++++ tests/system/Debug/ExceptionHandlerTest.php | 141 +++++++++++ 4 files changed, 549 insertions(+) create mode 100644 system/Debug/BaseExceptionHandler.php create mode 100644 system/Debug/ExceptionHandler.php create mode 100644 tests/system/Debug/ExceptionHandlerTest.php diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index ca0713a33711..95be071501d1 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -3,7 +3,9 @@ namespace Config; use CodeIgniter\Config\BaseConfig; +use CodeIgniter\Debug\BaseExceptionHandler; use Psr\Log\LogLevel; +use Throwable; /** * Setup how the exception handler works. @@ -74,4 +76,28 @@ class Exceptions extends BaseConfig * to capture logging the deprecations. */ public string $deprecationLogLevel = LogLevel::WARNING; + + /* + * DEFINE THE HANDLERS USED + * -------------------------------------------------------------------------- + * Given the HTTP status code, returns exception handler that + * should be used to deal with this error. By default, it will run CodeIgniter's + * default handler and display the error information in the expected format + * for CLI, HTTP, or AJAX requests, as determined by is_cli() and the expected + * response format. + * + * Custom handlers can be returned if you want to handle one or more specific + * error codes yourself like: + * + * if (in_array($statusCode, [400, 404, 500])) { + * return new \App\Libraries\MyExceptionHandler(); + * } + * if ($exception instanceOf PageNotFoundException) { + * return new \App\Libraries\MyExceptionHandler(); + * } + */ + public function handler(int $statusCode, Throwable $exception): BaseExceptionHandler + { + return new \CodeIgniter\Debug\ExceptionHandler($this); + } } diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php new file mode 100644 index 000000000000..0bc9072a1298 --- /dev/null +++ b/system/Debug/BaseExceptionHandler.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Exceptions as ExceptionsConfig; +use Throwable; + +/** + * Provides common functions for exception handlers, + * especially around displaying the output. + */ +abstract class BaseExceptionHandler +{ + use ResponseTrait; + + /** + * ResponseTrait needs this. + */ + protected ?RequestInterface $request; + + /** + * ResponseTrait needs this. + */ + protected ?ResponseInterface $response; + + /** + * Config for debug exceptions. + */ + protected ExceptionsConfig $config; + + /** + * Nesting level of the output buffering mechanism + */ + protected int $obLevel; + + /** + * The path to the directory containing the + * cli and html error view directories. + */ + protected string $viewPath; + + public function __construct(ExceptionsConfig $config) + { + $this->config = $config; + + $this->obLevel = ob_get_level(); + $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + } + + /** + * The main entry point into the handler. + * + * @return void + */ + abstract public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ); + + /** + * Gathers the variables that will be made available to the view. + */ + protected function collectVars(Throwable $exception, int $statusCode): array + { + $trace = $exception->getTrace(); + + if ($this->config->sensitiveDataInTrace !== []) { + $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); + } + + return [ + 'title' => get_class($exception), + 'type' => get_class($exception), + 'code' => $statusCode, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $trace, + ]; + } + + /** + * Mask sensitive data in the trace. + * + * @param array|object $trace + */ + protected function maskSensitiveData(&$trace, array $keysToMask, string $path = '') + { + foreach ($keysToMask as $keyToMask) { + $explode = explode('/', $keyToMask); + $index = end($explode); + + if (strpos(strrev($path . '/' . $index), strrev($keyToMask)) === 0) { + if (is_array($trace) && array_key_exists($index, $trace)) { + $trace[$index] = '******************'; + } elseif (is_object($trace) && property_exists($trace, $index) && isset($trace->{$index})) { + $trace->{$index} = '******************'; + } + } + } + + if (is_object($trace)) { + $trace = get_object_vars($trace); + } + + if (is_array($trace)) { + foreach ($trace as $pathKey => $subarray) { + $this->maskSensitiveData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } + } + + /** + * Describes memory usage in real-world units. Intended for use + * with memory_get_usage, etc. + */ + protected static function describeMemory(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . 'B'; + } + + if ($bytes < 1_048_576) { + return round($bytes / 1024, 2) . 'KB'; + } + + return round($bytes / 1_048_576, 2) . 'MB'; + } + + /** + * Creates a syntax-highlighted version of a PHP file. + * + * @return bool|string + */ + protected static function highlightFile(string $file, int $lineNumber, int $lines = 15) + { + if (empty($file) || ! is_readable($file)) { + return false; + } + + // Set our highlight colors: + if (function_exists('ini_set')) { + ini_set('highlight.comment', '#767a7e; font-style: italic'); + ini_set('highlight.default', '#c7c7c7'); + ini_set('highlight.html', '#06B'); + ini_set('highlight.keyword', '#f1ce61;'); + ini_set('highlight.string', '#869d6a'); + } + + try { + $source = file_get_contents($file); + } catch (Throwable $e) { + return false; + } + + $source = str_replace(["\r\n", "\r"], "\n", $source); + $source = explode("\n", highlight_string($source, true)); + $source = str_replace('
', "\n", $source[1]); + $source = explode("\n", str_replace("\r\n", "\n", $source)); + + // Get just the part to show + $start = max($lineNumber - (int) round($lines / 2), 0); + + // Get just the lines we need to display, while keeping line numbers... + $source = array_splice($source, $start, $lines, true); + + // Used to format the line number in the source + $format = '% ' . strlen((string) ($start + $lines)) . 'd'; + + $out = ''; + // Because the highlighting may have an uneven number + // of open and close span tags on one line, we need + // to ensure we can close them all to get the lines + // showing correctly. + $spans = 1; + + foreach ($source as $n => $row) { + $spans += substr_count($row, ']+>#', $row, $tags); + + $out .= sprintf( + "{$format} %s\n%s", + $n + $start + 1, + strip_tags($row), + implode('', $tags[0]) + ); + } else { + $out .= sprintf('' . $format . ' %s', $n + $start + 1, $row) . "\n"; + } + } + + if ($spans > 0) { + $out .= str_repeat('', $spans); + } + + return '
' . $out . '
'; + } + + /** + * Given an exception and status code will display the error to the client. + * + * @param string|null $viewFile + */ + protected function render(Throwable $exception, int $statusCode, $viewFile = null): void + { + if (empty($viewFile) || ! is_file($viewFile)) { + echo 'The error view files were not found. Cannot render exception trace.'; + + exit(1); + } + + if (ob_get_level() > $this->obLevel + 1) { + ob_end_clean(); + } + + echo(function () use ($exception, $statusCode, $viewFile): string { + $vars = $this->collectVars($exception, $statusCode); + extract($vars, EXTR_SKIP); + + // CLI error views output to STDERR/STDOUT, so ob_start() does not work. + ob_start(); + include $viewFile; + $output = ob_get_clean(); + + return $output; + })(); + } +} diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php new file mode 100644 index 000000000000..8f08fb3797ee --- /dev/null +++ b/system/Debug/ExceptionHandler.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Paths; +use Throwable; + +class ExceptionHandler extends BaseExceptionHandler +{ + /** + * Determines the correct way to display the error. + * + * @return void + */ + public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ) { + // ResponseTrait needs these properties. + $this->request = $request; + $this->response = $response; + + if ($request instanceof IncomingRequest) { + try { + $response->setStatusCode($statusCode); + } catch (HTTPException $e) { + // Workaround for invalid HTTP status code. + $statusCode = 500; + $response->setStatusCode($statusCode); + } + + if (! headers_sent()) { + header( + sprintf( + 'HTTP/%s %s %s', + $request->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase() + ), + true, + $statusCode + ); + } + + if (strpos($request->getHeaderLine('accept'), 'text/html') === false) { + $data = (ENVIRONMENT === 'development' || ENVIRONMENT === 'testing') + ? $this->collectVars($exception, $statusCode) + : ''; + + $this->respond($data, $statusCode)->send(); + + if (ENVIRONMENT !== 'testing') { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + } + + return; + } + } + + // Determine possible directories of error views + $addPath = ($request instanceof IncomingRequest ? 'html' : 'cli') . DIRECTORY_SEPARATOR; + $path = $this->viewPath . $addPath; + $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') + . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR . $addPath; + + // Determine the views + $view = $this->determineView($exception, $path); + $altView = $this->determineView($exception, $altPath); + + // Check if the view exists + $viewFile = null; + if (is_file($path . $view)) { + $viewFile = $path . $view; + } elseif (is_file($altPath . $altView)) { + $viewFile = $altPath . $altView; + } + + // Displays the HTML or CLI error code. + $this->render($exception, $statusCode, $viewFile); + + if (ENVIRONMENT !== 'testing') { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + } + } + + /** + * Determines the view to display based on the exception thrown, + * whether an HTTP or CLI request, etc. + * + * @return string The filename of the view file to use + */ + protected function determineView(Throwable $exception, string $templatePath): string + { + // Production environments should have a custom exception file. + $view = 'production.php'; + + if (str_ireplace(['off', 'none', 'no', 'false', 'null'], '', ini_get('display_errors'))) { + $view = 'error_exception.php'; + } + + // 404 Errors + if ($exception instanceof PageNotFoundException) { + return 'error_404.php'; + } + + $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR; + + // Allow for custom views based upon the status code + if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) { + return 'error_' . $exception->getCode() . '.php'; + } + + return $view; + } +} diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php new file mode 100644 index 000000000000..b21ad86038b6 --- /dev/null +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use Config\Exceptions as ExceptionsConfig; +use Config\Services; +use RuntimeException; + +/** + * @internal + */ +final class ExceptionHandlerTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private ExceptionHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + + $this->handler = new ExceptionHandler(new ExceptionsConfig()); + } + + public function testDetermineViewsPageNotFoundException(): void + { + $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView'); + + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + $templatePath = APPPATH . 'Views/errors/html'; + $viewFile = $determineView($exception, $templatePath); + + $this->assertSame('error_404.php', $viewFile); + } + + public function testDetermineViewsRuntimeException(): void + { + $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView'); + + $exception = new RuntimeException('Exception'); + $templatePath = APPPATH . 'Views/errors/html'; + $viewFile = $determineView($exception, $templatePath); + + $this->assertSame('error_exception.php', $viewFile); + } + + public function testDetermineViewsRuntimeExceptionCode404(): void + { + $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView'); + + $exception = new RuntimeException('foo', 404); + $templatePath = APPPATH . 'Views/errors/html'; + $viewFile = $determineView($exception, $templatePath); + + $this->assertSame('error_404.php', $viewFile); + } + + public function testCollectVars(): void + { + $collectVars = $this->getPrivateMethodInvoker($this->handler, 'collectVars'); + + $vars = $collectVars(new RuntimeException('This.'), 404); + + $this->assertIsArray($vars); + $this->assertCount(7, $vars); + + foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) { + $this->assertArrayHasKey($key, $vars); + } + } + + public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void + { + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + + $request = Services::incomingrequest(null, false); + $response = Services::response(null, false); + $response->pretend(); + + ob_start(); + $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR); + $output = ob_get_clean(); + + $json = json_decode($output); + $this->assertSame('CodeIgniter\Exceptions\PageNotFoundException', $json->title); + $this->assertSame('CodeIgniter\Exceptions\PageNotFoundException', $json->type); + $this->assertSame(404, $json->code); + $this->assertSame('Controller or its method is not found: Foo::bar', $json->message); + } + + public function testHandleWebPageNotFoundExceptionAcceptHTML(): void + { + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + + $request = Services::incomingrequest(null, false); + $request->setHeader('accept', 'text/html'); + $response = Services::response(null, false); + $response->pretend(); + + ob_start(); + $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR); + $output = ob_get_clean(); + + $this->assertStringContainsString('404 - Page Not Found', $output); + } + + public function testHandleCLIPageNotFoundException(): void + { + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + + $request = Services::clirequest(null, false); + $request->setHeader('accept', 'text/html'); + $response = Services::response(null, false); + $response->pretend(); + + $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR); + + $this->assertStringContainsString( + 'ERROR: 404', + $this->getStreamFilterBuffer() + ); + $this->assertStringContainsString( + 'Controller or its method is not found: Foo::bar', + $this->getStreamFilterBuffer() + ); + + $this->resetStreamFilterBuffer(); + } +} From ebf59e8f8ce53381b5df4d183c36ca2d90652b64 Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 15:37:41 +0900 Subject: [PATCH 02/17] refactor: use ExceptionHandler --- system/Debug/Exceptions.php | 46 ++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index a4671a86e8d5..1c55f0f7d50c 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -15,9 +15,8 @@ use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\HTTPExceptionInterface; use CodeIgniter\Exceptions\PageNotFoundException; -use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Exceptions\HTTPException; -use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Config\Exceptions as ExceptionsConfig; use Config\Paths; @@ -36,6 +35,8 @@ class Exceptions * Nesting level of the output buffering mechanism * * @var int + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ public $ob_level; @@ -44,6 +45,8 @@ class Exceptions * cli and html error view directories. * * @var string + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ protected $viewPath; @@ -57,7 +60,7 @@ class Exceptions /** * The request. * - * @var CLIRequest|IncomingRequest + * @var RequestInterface */ protected $request; @@ -69,12 +72,10 @@ class Exceptions protected $response; /** - * @param CLIRequest|IncomingRequest $request + * @param RequestInterface $request */ public function __construct(ExceptionsConfig $config, $request, ResponseInterface $response) { - $this->ob_level = ob_get_level(); - $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; $this->config = $config; $this->request = $request; $this->response = $response; @@ -106,8 +107,6 @@ public function initialize() * Catches any uncaught errors and exceptions, including most Fatal errors * (Yay PHP7!). Will log the error, display it if display_errors is on, * and fire an event that allows custom actions to be taken at this point. - * - * @codeCoverageIgnore */ public function exceptionHandler(Throwable $exception) { @@ -122,6 +121,27 @@ public function exceptionHandler(Throwable $exception) ]); } + // For upgraded users. + if (! method_exists($this->config, 'handler')) { + $this->defaultExceptionHandler($exception, $statusCode, $exitCode); + + return; + } + + $handler = $this->config->handler($statusCode, $exception); + + if (! $handler instanceof BaseExceptionHandler) { + exit('Exception Handler not found.'); + } + + $handler->handle($exception, $this->request, $this->response, $statusCode, $exitCode); + } + + /** + * @deprecated This method is only for backward compatibility. + */ + private function defaultExceptionHandler(Throwable $exception, int $statusCode, int $exitCode) + { if (! is_cli()) { try { $this->response->setStatusCode($statusCode); @@ -139,6 +159,8 @@ public function exceptionHandler(Throwable $exception) $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); exit($exitCode); + + return; // @phpstan-ignore-line } } @@ -195,6 +217,8 @@ public function shutdownHandler() * whether an HTTP or CLI request, etc. * * @return string The path and filename of the view file to use + * + * @deprecated No longer used. Moved to ExceptionHandler. */ protected function determineView(Throwable $exception, string $templatePath): string { @@ -221,6 +245,8 @@ protected function determineView(Throwable $exception, string $templatePath): st /** * Given an exception and status code will display the error to the client. + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ protected function render(Throwable $exception, int $statusCode) { @@ -265,6 +291,8 @@ protected function render(Throwable $exception, int $statusCode) /** * Gathers the variables that will be made available to the view. + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ protected function collectVars(Throwable $exception, int $statusCode): array { @@ -289,6 +317,8 @@ protected function collectVars(Throwable $exception, int $statusCode): array * Mask sensitive data in the trace. * * @param array|object $trace + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ protected function maskSensitiveData(&$trace, array $keysToMask, string $path = '') { From a7fb79180931c1e74730c60f978ce17ecf4a759f Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 15:38:40 +0900 Subject: [PATCH 03/17] docs: add user guide --- user_guide_src/source/changelogs/v4.3.0.rst | 1 + user_guide_src/source/general/errors.rst | 36 ++++++++++++++++++++ user_guide_src/source/general/errors/015.php | 26 ++++++++++++++ user_guide_src/source/general/errors/016.php | 17 +++++++++ user_guide_src/source/general/errors/017.php | 25 ++++++++++++++ 5 files changed, 105 insertions(+) create mode 100644 user_guide_src/source/general/errors/015.php create mode 100644 user_guide_src/source/general/errors/016.php create mode 100644 user_guide_src/source/general/errors/017.php diff --git a/user_guide_src/source/changelogs/v4.3.0.rst b/user_guide_src/source/changelogs/v4.3.0.rst index 683dff2c8c90..88ff704d64db 100644 --- a/user_guide_src/source/changelogs/v4.3.0.rst +++ b/user_guide_src/source/changelogs/v4.3.0.rst @@ -209,6 +209,7 @@ Error Handling Others ====== +- Now you can use :ref:`custom-exception-handlers`. - Added ``$routes->useSupportedLocalesOnly(true)`` so that the Router returns 404 Not Found if the locale in the URL is not supported in ``Config\App::$supportedLocales``. See :ref:`Localization ` - Added new ``$routes->view()`` method to return a the view directly. See :ref:`View Routes `. - View Cells are now first-class citizens and can located in the **app/Cells** directory. See :ref:`View Cells `. diff --git a/user_guide_src/source/general/errors.rst b/user_guide_src/source/general/errors.rst index 152d6f74a7bc..da74a6214ea2 100644 --- a/user_guide_src/source/general/errors.rst +++ b/user_guide_src/source/general/errors.rst @@ -161,3 +161,39 @@ After that, subsequent deprecations will be logged instead of thrown. This feature also works with user deprecations: .. literalinclude:: errors/014.php + +.. _custom-exception-handlers: + +Custom Exception Handlers +========================= + +.. versionadded:: 4.3.0 + +If you need more control over how exceptions are displayed you can now define your own handlers and +specify when they apply. + +Defining the New Handler +------------------------ + +The first step is to create a new class which must extend ``CodeIgniter\Debug\BaseExceptionHandler``. +This class includes a number of utility methods that are used by the default exception handler. +The new handler must implement a single method: ``handle()``: + +.. literalinclude:: errors/015.php + +This example defines the minimum amount of code typically needed - display a view and exit with the proper +exit code. However, the ``BaseExceptionHandler`` provides a number of other helper functions and objects. + +Configuring the New Handler +--------------------------- + +Telling CodeIgniter to use your new exception handler class is done in the **app/Config/Exceptions.php** +configuration file's ``handler()`` method: + +.. literalinclude:: errors/016.php + +You can use any logic your application needs to determine whether it should handle the exception, but the +two most common are checking on the HTTP status code or the type of exception. If your class should handle +it then return a new instance of that class: + +.. literalinclude:: errors/017.php diff --git a/user_guide_src/source/general/errors/015.php b/user_guide_src/source/general/errors/015.php new file mode 100644 index 000000000000..c41a94237375 --- /dev/null +++ b/user_guide_src/source/general/errors/015.php @@ -0,0 +1,26 @@ +render($exception, $statusCode, $this->viewPath . "error_{$statusCode}.php"); + + exit($exitCode); + } +} diff --git a/user_guide_src/source/general/errors/016.php b/user_guide_src/source/general/errors/016.php new file mode 100644 index 000000000000..85dc8f99d627 --- /dev/null +++ b/user_guide_src/source/general/errors/016.php @@ -0,0 +1,17 @@ + Date: Tue, 18 Oct 2022 15:50:23 +0900 Subject: [PATCH 04/17] fix: cannot override $viewPath --- system/Debug/BaseExceptionHandler.php | 9 ++++++--- user_guide_src/source/general/errors/015.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index 0bc9072a1298..6039234a9fd9 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -49,14 +49,17 @@ abstract class BaseExceptionHandler * The path to the directory containing the * cli and html error view directories. */ - protected string $viewPath; + protected ?string $viewPath = null; public function __construct(ExceptionsConfig $config) { $this->config = $config; - $this->obLevel = ob_get_level(); - $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + $this->obLevel = ob_get_level(); + + if ($this->viewPath === null) { + $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + } } /** diff --git a/user_guide_src/source/general/errors/015.php b/user_guide_src/source/general/errors/015.php index c41a94237375..633e78f4bde4 100644 --- a/user_guide_src/source/general/errors/015.php +++ b/user_guide_src/source/general/errors/015.php @@ -10,7 +10,7 @@ class MyExceptionHandler extends BaseExceptionHandler { // You can override the view path. - protected string $viewPath = APPPATH . 'Views/exception/'; + protected ?string $viewPath = APPPATH . 'Views/exception/'; public function handle( Throwable $exception, From fa31541ebe9d457fc6971028f9768a292ba64dce Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 16:36:04 +0900 Subject: [PATCH 05/17] refactor: run rector & cs-fixer --- app/Config/Exceptions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index 95be071501d1..1c35bb8b997b 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -4,6 +4,7 @@ use CodeIgniter\Config\BaseConfig; use CodeIgniter\Debug\BaseExceptionHandler; +use CodeIgniter\Debug\ExceptionHandler; use Psr\Log\LogLevel; use Throwable; @@ -98,6 +99,6 @@ class Exceptions extends BaseConfig */ public function handler(int $statusCode, Throwable $exception): BaseExceptionHandler { - return new \CodeIgniter\Debug\ExceptionHandler($this); + return new ExceptionHandler($this); } } From 9d724dcc33ef1b954518a016ca6d1d513218a55c Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 16:36:44 +0900 Subject: [PATCH 06/17] chore: update test-phpcpd.yml --- .github/workflows/test-phpcpd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-phpcpd.yml b/.github/workflows/test-phpcpd.yml index 6d586b40e29b..a8a48e235733 100644 --- a/.github/workflows/test-phpcpd.yml +++ b/.github/workflows/test-phpcpd.yml @@ -43,4 +43,4 @@ jobs: extensions: dom, mbstring - name: Detect code duplication - run: phpcpd --exclude system/Test --exclude system/ThirdParty --exclude system/Database/SQLSRV/Builder.php --exclude system/Database/SQLSRV/Forge.php --exclude system/Database/MySQLi/Builder.php --exclude system/Database/OCI8/Builder.php -- app/ public/ system/ + run: phpcpd --exclude system/Test --exclude system/ThirdParty --exclude system/Database/SQLSRV/Builder.php --exclude system/Database/SQLSRV/Forge.php --exclude system/Database/MySQLi/Builder.php --exclude system/Database/OCI8/Builder.php --exclude system/Debug/Exceptions.php -- app/ public/ system/ From 550e62e0a4f5330be9542a7c512fc6fafd013a1a Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 17:39:52 +0900 Subject: [PATCH 07/17] refactor: run rector --- system/Debug/BaseExceptionHandler.php | 7 +++---- system/Debug/Exceptions.php | 6 ------ tests/system/Debug/ExceptionHandlerTest.php | 4 ++-- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index 6039234a9fd9..3c7b115fa143 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -28,12 +28,12 @@ abstract class BaseExceptionHandler /** * ResponseTrait needs this. */ - protected ?RequestInterface $request; + protected ?RequestInterface $request = null; /** * ResponseTrait needs this. */ - protected ?ResponseInterface $response; + protected ?ResponseInterface $response = null; /** * Config for debug exceptions. @@ -241,9 +241,8 @@ protected function render(Throwable $exception, int $statusCode, $viewFile = nul // CLI error views output to STDERR/STDOUT, so ob_start() does not work. ob_start(); include $viewFile; - $output = ob_get_clean(); - return $output; + return ob_get_clean(); })(); } } diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 1c55f0f7d50c..16bc294a312d 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -130,10 +130,6 @@ public function exceptionHandler(Throwable $exception) $handler = $this->config->handler($statusCode, $exception); - if (! $handler instanceof BaseExceptionHandler) { - exit('Exception Handler not found.'); - } - $handler->handle($exception, $this->request, $this->response, $statusCode, $exitCode); } @@ -159,8 +155,6 @@ private function defaultExceptionHandler(Throwable $exception, int $statusCode, $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); exit($exitCode); - - return; // @phpstan-ignore-line } } diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php index b21ad86038b6..fd7efdd5f686 100644 --- a/tests/system/Debug/ExceptionHandlerTest.php +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -94,8 +94,8 @@ public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void $output = ob_get_clean(); $json = json_decode($output); - $this->assertSame('CodeIgniter\Exceptions\PageNotFoundException', $json->title); - $this->assertSame('CodeIgniter\Exceptions\PageNotFoundException', $json->type); + $this->assertSame(PageNotFoundException::class, $json->title); + $this->assertSame(PageNotFoundException::class, $json->type); $this->assertSame(404, $json->code); $this->assertSame('Controller or its method is not found: Foo::bar', $json->message); } From 777777277a9ff60c6b2c7bb1082ac94bc0e5455f Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 17:49:44 +0900 Subject: [PATCH 08/17] fix: set deprecated properties for backward compatibility --- system/Debug/Exceptions.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 16bc294a312d..a2c19fa49477 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -76,6 +76,10 @@ class Exceptions */ public function __construct(ExceptionsConfig $config, $request, ResponseInterface $response) { + // For backward compatibility + $this->ob_level = ob_get_level(); + $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + $this->config = $config; $this->request = $request; $this->response = $response; From 038717aa1f50cd62e09a9edadfe0281c38b01898 Mon Sep 17 00:00:00 2001 From: kenjis Date: Tue, 18 Oct 2022 17:57:33 +0900 Subject: [PATCH 09/17] docs: add Deprecations --- user_guide_src/source/changelogs/v4.3.0.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/user_guide_src/source/changelogs/v4.3.0.rst b/user_guide_src/source/changelogs/v4.3.0.rst index 88ff704d64db..761e5893d40c 100644 --- a/user_guide_src/source/changelogs/v4.3.0.rst +++ b/user_guide_src/source/changelogs/v4.3.0.rst @@ -249,6 +249,7 @@ Deprecations - ``CodeIgniter::$path`` and ``CodeIgniter::setPath()`` are deprecated. No longer used. - The public property ``IncomingRequest::$uri`` is deprecated. It will be protected. Use ``IncomingRequest::getUri()`` instead. - The public property ``IncomingRequest::$config`` is deprecated. It will be protected. +- Many methods and properties in ``CodeIgniter\Debug\Exceptions`` are deprecated. Because these methods have been moved to ``BaseExceptionHandler`` or ``ExceptionHandler``. Bugs Fixed ********** From 4a8bc0dfa42785e95f4d6af99506735edc6d2ce6 Mon Sep 17 00:00:00 2001 From: kenjis Date: Sun, 23 Oct 2022 16:44:04 +0900 Subject: [PATCH 10/17] refactor: move ResponseTrait to ExceptionHandler --- system/Debug/BaseExceptionHandler.php | 13 ------------- system/Debug/ExceptionHandler.php | 13 +++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index 3c7b115fa143..82b074d208df 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -11,7 +11,6 @@ namespace CodeIgniter\Debug; -use CodeIgniter\API\ResponseTrait; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Config\Exceptions as ExceptionsConfig; @@ -23,18 +22,6 @@ */ abstract class BaseExceptionHandler { - use ResponseTrait; - - /** - * ResponseTrait needs this. - */ - protected ?RequestInterface $request = null; - - /** - * ResponseTrait needs this. - */ - protected ?ResponseInterface $response = null; - /** * Config for debug exceptions. */ diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index 8f08fb3797ee..d1cc24060d55 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Debug; +use CodeIgniter\API\ResponseTrait; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\IncomingRequest; @@ -21,6 +22,18 @@ class ExceptionHandler extends BaseExceptionHandler { + use ResponseTrait; + + /** + * ResponseTrait needs this. + */ + protected ?RequestInterface $request = null; + + /** + * ResponseTrait needs this. + */ + protected ?ResponseInterface $response = null; + /** * Determines the correct way to display the error. * From 30133af23a07a43efd9e6b4cb758bc842d84b1f0 Mon Sep 17 00:00:00 2001 From: kenjis Date: Sun, 23 Oct 2022 17:12:57 +0900 Subject: [PATCH 11/17] docs: add @deprecated --- system/Debug/Exceptions.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index a2c19fa49477..67809779d62e 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -429,6 +429,8 @@ public static function cleanPath(string $file): string /** * Describes memory usage in real-world units. Intended for use * with memory_get_usage, etc. + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ public static function describeMemory(int $bytes): string { @@ -447,6 +449,8 @@ public static function describeMemory(int $bytes): string * Creates a syntax-highlighted version of a PHP file. * * @return bool|string + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ public static function highlightFile(string $file, int $lineNumber, int $lines = 15) { From d1e76782cbf9db4efcee4f6a4d48b9ef30f2b459 Mon Sep 17 00:00:00 2001 From: kenjis Date: Sun, 23 Oct 2022 17:15:56 +0900 Subject: [PATCH 12/17] docs: fix changelog The section "Error Handling" has been added. --- user_guide_src/source/changelogs/v4.3.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_guide_src/source/changelogs/v4.3.0.rst b/user_guide_src/source/changelogs/v4.3.0.rst index 761e5893d40c..d5022186c9f5 100644 --- a/user_guide_src/source/changelogs/v4.3.0.rst +++ b/user_guide_src/source/changelogs/v4.3.0.rst @@ -204,12 +204,12 @@ Helpers and Functions Error Handling ============== +- Now you can use :ref:`custom-exception-handlers`. - You can now log deprecation errors instead of throwing them. See :ref:`logging_deprecation_errors` for details. Others ====== -- Now you can use :ref:`custom-exception-handlers`. - Added ``$routes->useSupportedLocalesOnly(true)`` so that the Router returns 404 Not Found if the locale in the URL is not supported in ``Config\App::$supportedLocales``. See :ref:`Localization ` - Added new ``$routes->view()`` method to return a the view directly. See :ref:`View Routes `. - View Cells are now first-class citizens and can located in the **app/Cells** directory. See :ref:`View Cells `. From f41f2ef4d0b9fe7b0f3669f150b02c250289212a Mon Sep 17 00:00:00 2001 From: kenjis Date: Sun, 23 Oct 2022 17:19:48 +0900 Subject: [PATCH 13/17] docs: add @used-by --- system/Debug/BaseExceptionHandler.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index 82b074d208df..c6d6c5698e30 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -118,6 +118,8 @@ protected function maskSensitiveData(&$trace, array $keysToMask, string $path = /** * Describes memory usage in real-world units. Intended for use * with memory_get_usage, etc. + * + * @used-by app/Views/errors/html/error_exception.php */ protected static function describeMemory(int $bytes): string { @@ -135,6 +137,8 @@ protected static function describeMemory(int $bytes): string /** * Creates a syntax-highlighted version of a PHP file. * + * @used-by app/Views/errors/html/error_exception.php + * * @return bool|string */ protected static function highlightFile(string $file, int $lineNumber, int $lines = 15) From a610d48fa23450fd82ebb1628d7e97de20a5d1ad Mon Sep 17 00:00:00 2001 From: kenjis Date: Wed, 26 Oct 2022 18:42:59 +0900 Subject: [PATCH 14/17] refactor: use number helper --- system/Debug/BaseExceptionHandler.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php index c6d6c5698e30..a4a4c72946b1 100644 --- a/system/Debug/BaseExceptionHandler.php +++ b/system/Debug/BaseExceptionHandler.php @@ -123,15 +123,9 @@ protected function maskSensitiveData(&$trace, array $keysToMask, string $path = */ protected static function describeMemory(int $bytes): string { - if ($bytes < 1024) { - return $bytes . 'B'; - } - - if ($bytes < 1_048_576) { - return round($bytes / 1024, 2) . 'KB'; - } + helper('number'); - return round($bytes / 1_048_576, 2) . 'MB'; + return number_to_size($bytes, 2); } /** From 50b02c8980043bd2d30c50bdc3066cf721290461 Mon Sep 17 00:00:00 2001 From: kenjis Date: Wed, 26 Oct 2022 18:44:48 +0900 Subject: [PATCH 15/17] refactor: make final class --- system/Debug/ExceptionHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index d1cc24060d55..b4aabab27e8d 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -20,7 +20,7 @@ use Config\Paths; use Throwable; -class ExceptionHandler extends BaseExceptionHandler +final class ExceptionHandler extends BaseExceptionHandler { use ResponseTrait; From 9d5d44f336912e1d4e548cc966f3617ab86ce400 Mon Sep 17 00:00:00 2001 From: kenjis Date: Thu, 27 Oct 2022 17:18:52 +0900 Subject: [PATCH 16/17] refactor: remove defaultExceptionHandler() and use ExceptionHandler --- system/Debug/Exceptions.php | 41 ++++--------------------------------- 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 67809779d62e..0479e6415436 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -15,7 +15,6 @@ use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\HTTPExceptionInterface; use CodeIgniter\Exceptions\PageNotFoundException; -use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Config\Exceptions as ExceptionsConfig; @@ -125,48 +124,16 @@ public function exceptionHandler(Throwable $exception) ]); } - // For upgraded users. + // For upgraded users who did not update the config file. if (! method_exists($this->config, 'handler')) { - $this->defaultExceptionHandler($exception, $statusCode, $exitCode); - - return; + $handler = new ExceptionHandler($this->config); + } else { + $handler = $this->config->handler($statusCode, $exception); } - $handler = $this->config->handler($statusCode, $exception); - $handler->handle($exception, $this->request, $this->response, $statusCode, $exitCode); } - /** - * @deprecated This method is only for backward compatibility. - */ - private function defaultExceptionHandler(Throwable $exception, int $statusCode, int $exitCode) - { - if (! is_cli()) { - try { - $this->response->setStatusCode($statusCode); - } catch (HTTPException $e) { - // Workaround for invalid HTTP status code. - $statusCode = 500; - $this->response->setStatusCode($statusCode); - } - - if (! headers_sent()) { - header(sprintf('HTTP/%s %s %s', $this->request->getProtocolVersion(), $this->response->getStatusCode(), $this->response->getReasonPhrase()), true, $statusCode); - } - - if (strpos($this->request->getHeaderLine('accept'), 'text/html') === false) { - $this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send(); - - exit($exitCode); - } - } - - $this->render($exception, $statusCode); - - exit($exitCode); - } - /** * The callback to be registered to `set_error_handler()`. * From dc9fc53e6c501f34d9324f71563e8726e89dd06e Mon Sep 17 00:00:00 2001 From: kenjis Date: Thu, 27 Oct 2022 17:43:47 +0900 Subject: [PATCH 17/17] refactor: run rector --- system/Debug/ExceptionHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index b4aabab27e8d..5428f38994e6 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -27,12 +27,12 @@ final class ExceptionHandler extends BaseExceptionHandler /** * ResponseTrait needs this. */ - protected ?RequestInterface $request = null; + private ?RequestInterface $request = null; /** * ResponseTrait needs this. */ - protected ?ResponseInterface $response = null; + private ?ResponseInterface $response = null; /** * Determines the correct way to display the error.