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/ diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index ca0713a33711..1c35bb8b997b 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -3,7 +3,10 @@ namespace Config; use CodeIgniter\Config\BaseConfig; +use CodeIgniter\Debug\BaseExceptionHandler; +use CodeIgniter\Debug\ExceptionHandler; use Psr\Log\LogLevel; +use Throwable; /** * Setup how the exception handler works. @@ -74,4 +77,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 ExceptionHandler($this); + } } diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php new file mode 100644 index 000000000000..a4a4c72946b1 --- /dev/null +++ b/system/Debug/BaseExceptionHandler.php @@ -0,0 +1,233 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +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 +{ + /** + * 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 = null; + + public function __construct(ExceptionsConfig $config) + { + $this->config = $config; + + $this->obLevel = ob_get_level(); + + if ($this->viewPath === null) { + $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. + * + * @used-by app/Views/errors/html/error_exception.php + */ + protected static function describeMemory(int $bytes): string + { + helper('number'); + + return number_to_size($bytes, 2); + } + + /** + * 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) + { + 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; + + return ob_get_clean(); + })(); + } +} diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php new file mode 100644 index 000000000000..5428f38994e6 --- /dev/null +++ b/system/Debug/ExceptionHandler.php @@ -0,0 +1,149 @@ + + * + * 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\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Paths; +use Throwable; + +final class ExceptionHandler extends BaseExceptionHandler +{ + use ResponseTrait; + + /** + * ResponseTrait needs this. + */ + private ?RequestInterface $request = null; + + /** + * ResponseTrait needs this. + */ + private ?ResponseInterface $response = null; + + /** + * 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/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index a4671a86e8d5..0479e6415436 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -15,9 +15,7 @@ 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 +34,8 @@ class Exceptions * Nesting level of the output buffering mechanism * * @var int + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ public $ob_level; @@ -44,6 +44,8 @@ class Exceptions * cli and html error view directories. * * @var string + * + * @deprecated No longer used. Moved to BaseExceptionHandler. */ protected $viewPath; @@ -57,7 +59,7 @@ class Exceptions /** * The request. * - * @var CLIRequest|IncomingRequest + * @var RequestInterface */ protected $request; @@ -69,12 +71,14 @@ class Exceptions protected $response; /** - * @param CLIRequest|IncomingRequest $request + * @param RequestInterface $request */ 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; @@ -106,8 +110,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,29 +124,14 @@ public function exceptionHandler(Throwable $exception) ]); } - 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); - } + // For upgraded users who did not update the config file. + if (! method_exists($this->config, 'handler')) { + $handler = new ExceptionHandler($this->config); + } else { + $handler = $this->config->handler($statusCode, $exception); } - $this->render($exception, $statusCode); - - exit($exitCode); + $handler->handle($exception, $this->request, $this->response, $statusCode, $exitCode); } /** @@ -195,6 +182,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 +210,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 +256,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 +282,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 = '') { @@ -401,6 +396,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 { @@ -419,6 +416,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) { diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php new file mode 100644 index 000000000000..fd7efdd5f686 --- /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(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); + } + + 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(); + } +} diff --git a/user_guide_src/source/changelogs/v4.3.0.rst b/user_guide_src/source/changelogs/v4.3.0.rst index 683dff2c8c90..d5022186c9f5 100644 --- a/user_guide_src/source/changelogs/v4.3.0.rst +++ b/user_guide_src/source/changelogs/v4.3.0.rst @@ -204,6 +204,7 @@ 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 @@ -248,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 ********** 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..633e78f4bde4 --- /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 @@ +