From ef0585ec6b56f56656930135251a6432c9505d22 Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Thu, 12 Feb 2026 12:04:29 +0100 Subject: [PATCH 1/2] [Improvement] Init: Introduce ErrorController to handle ilCtrl routing errors as HTTP 404 [Improvement] Init: ErroPageResponder Add StatusCodes & remove ilCtrl Message [Improvement] Init: ErroPageResponder FallbackResponse [Improvement] Init: ErroPageResponder ilCtrlPathException [Improvement] Init: ErroPageResponder ilCtrlPathException copyright [Improvement] Init: ErroPageResponder Namespaces [Improvement] Init: ErroPageResponder Documentation, Review & PalinTextFallbackResponder [Improvement] Init: ErroPageResponder remove 'new' parentheses --- .../classes/Http/ErrorPageResponder.php | 93 ++++++++++++++++++ .../classes/PlainTextFallbackResponder.php | 96 +++++++++++++++++++ components/ILIAS/Exceptions/classes/README.md | 33 +++++++ components/ILIAS/Init/resources/error.php | 83 ++++++---------- components/ILIAS/Init/resources/ilias.php | 30 ++++-- .../classes/Path/class.ilCtrlExistingPath.php | 4 +- .../UICore/exceptions/ilCtrlPathException.php | 31 ++++++ lang/ilias_de.lang | 2 + lang/ilias_en.lang | 2 + templates/default/tpl.error.html | 9 +- 10 files changed, 313 insertions(+), 70 deletions(-) create mode 100644 components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php create mode 100644 components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php create mode 100644 components/ILIAS/Exceptions/classes/README.md create mode 100644 components/ILIAS/UICore/exceptions/ilCtrlPathException.php diff --git a/components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php b/components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php new file mode 100644 index 000000000000..71fa65a04097 --- /dev/null +++ b/components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php @@ -0,0 +1,93 @@ +global_screen->tool()->context()->claim()->external(); + $this->language->loadLanguageModule('error'); + + $message_box = $this->ui->factory()->messageBox()->failure($error_message); + + if ($back_target !== null) { + $ui_button = $this->ui->factory()->button()->standard( + $back_target->getLabel(), + ilUtil::secureUrl((string) $back_target->getURL()) + ); + $message_box = $message_box->withButtons([$ui_button]); + } + + $local_tpl = new ilGlobalTemplate('tpl.error.html', true, true); + $local_tpl->setCurrentBlock('msg_box'); + $local_tpl->setVariable( + 'MESSAGE_BOX', + $this->ui->renderer()->render($message_box) + ); + $local_tpl->parseCurrentBlock(); + + $this->http->saveResponse( + $this->http + ->response() + ->withStatus($status_code) + ->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html') + ); + + $this->ui->mainTemplate()->setContent($local_tpl->get()); + $this->ui->mainTemplate()->printToStdout(); + + $this->http->close(); + } +} diff --git a/components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php b/components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php new file mode 100644 index 000000000000..4b491ea87ab7 --- /dev/null +++ b/components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php @@ -0,0 +1,96 @@ +getInt(1, 9999); + $timestamp = (new DateTimeImmutable()) + ->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s\Z'); + + echo "Internal Server Error\n"; + echo "Incident: $incident_id\n"; + echo "Timestamp: $timestamp\n"; + + if ($e instanceof PDOException) { + echo "Message: A database error occurred. Please contact the system administrator with the incident id.\n"; + } else { + echo "Message: {$e->getMessage()}\n"; + } + + error_log(sprintf( + "[%s] INCIDENT %s — Uncaught %s: %s in %s:%d\nStack trace:\n%s\n", + $timestamp, + $incident_id, + get_class($e), + $e->getMessage(), + $e->getFile(), + $e->getLine(), + $e->getTraceAsString() + )); + + exit(1); + } +} diff --git a/components/ILIAS/Exceptions/classes/README.md b/components/ILIAS/Exceptions/classes/README.md new file mode 100644 index 000000000000..f1792f81c078 --- /dev/null +++ b/components/ILIAS/Exceptions/classes/README.md @@ -0,0 +1,33 @@ +# Error Responders + +This package provides responders for rendering HTTP error pages in ILIAS. + +## When to use which responder + +- **ErrorPageResponder** (`Http\ErrorPageResponder`): Use when the DI container and all ILIAS services (UI, language, HTTP, etc.) are available. Renders a full ILIAS page with a UI-Framework MessageBox and optional back button. Use for expected errors (e.g. routing failures, access denied) that should be shown as a proper HTML page. + +- **PlainTextFallbackResponder**: Use when the DI container or other infrastructure is *not* available — for instance in the catch block of `error.php` when the bootstrap itself has failed. Sends a minimal plain-text response with `Content-Type: text/plain; charset=UTF-8` and logs the exception via `error_log`. This responder always works because it uses only PHP built-ins. The HTTP status code defaults to 500; pass a different code (e.g. 502) when the failure context is known. + +## Consumer responsibility + +**The consumer MUST implement a try-catch block.** Both responders must be invoked explicitly: + +1. Wrap the main logic (bootstrap, routing, etc.) in a `try` block. +2. In the `catch` block, call either `ErrorPageResponder::handleError()` (if DIC is available) or `PlainTextFallbackResponder::respond()` (if DIC is not available). + +Example: + +```php +try { + entry_point('ILIAS Legacy Initialisation Adapter'); + global $DIC; + (new ErrorPageResponder( + $DIC->globalScreen(), + $DIC->language(), + $DIC->ui(), + $DIC->http() + ))->handleError($message, 500, $back_target); +} catch (Throwable $e) { + (new PlainTextFallbackResponder())->respond($e); +} +``` diff --git a/components/ILIAS/Init/resources/error.php b/components/ILIAS/Init/resources/error.php index 2f537c794b78..9e7aa7e2fa5a 100644 --- a/components/ILIAS/Init/resources/error.php +++ b/components/ILIAS/Init/resources/error.php @@ -20,73 +20,44 @@ namespace ILIAS\Init; +use Throwable; +use ILIAS\HTTP\StatusCode; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\Init\ErrorHandling\Http\ErrorPageResponder; +use ILIAS\Init\ErrorHandling\PlainTextFallbackResponder; + try { require_once '../vendor/composer/vendor/autoload.php'; require_once __DIR__ . '/../artifacts/bootstrap_default.php'; entry_point('ILIAS Legacy Initialisation Adapter'); - $DIC->globalScreen()->tool()->context()->claim()->external(); - - $lng->loadLanguageModule('error'); - $txt = $lng->txt('error_back_to_repository'); - - $local_tpl = new \ilGlobalTemplate('tpl.error.html', true, true); - $local_tpl->setCurrentBlock('ErrorLink'); - $local_tpl->setVariable('TXT_LINK', $txt); - $local_tpl->setVariable('LINK', \ilUtil::secureUrl(ILIAS_HTTP_PATH . '/ilias.php?baseClass=ilRepositoryGUI')); - $local_tpl->parseCurrentBlock(); + /** @var \ILIAS\DI\Container $DIC */ + global $DIC; \ilSession::clear('referer'); \ilSession::clear('message'); - $DIC->http()->saveResponse( - $DIC->http() - ->response() - ->withStatus(500) - ->withHeader(\ILIAS\HTTP\Response\ResponseHeader::CONTENT_TYPE, 'text/html') - ); - - $tpl->setContent($local_tpl->get()); - $tpl->printToStdout(); + $DIC->language()->loadLanguageModule('error'); - $DIC->http()->close(); -} catch (\Throwable $e) { - if (\defined('DEVMODE') && DEVMODE) { - throw $e; - } + $message = \ilSession::get('failure') ?? $DIC->language()->txt('http_500_internal_server_error'); - /* - * Since we are already in the `error.php` and an unexpected error occurred, we should not rely on the $DIC or any - * other components here and use "Vanilla PHP" instead to handle the error. - */ - if (!headers_sent()) { - http_response_code(500); - header('Content-Type: text/plain; charset=UTF-8'); - } - - $incident_id = session_id() . '_' . (new \Random\Randomizer())->getInt(1, 9999); - $timestamp = (new \DateTimeImmutable())->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'); - - echo "Internal Server Error\n"; - echo "Incident: $incident_id\n"; - echo "Timestamp: $timestamp\n"; - if ($e instanceof \PDOException) { - echo "Message: A database error occurred. Please contact the system administrator with the incident id.\n"; - } else { - echo "Message: {$e->getMessage()}\n"; - } - - error_log(\sprintf( - "[%s] INCIDENT %s — Uncaught %s: %s in %s:%d\nStack trace:\n%s\n", - $timestamp, - $incident_id, - \get_class($e), - $e->getMessage(), - $e->getFile(), - $e->getLine(), - $e->getTraceAsString() - )); + $df = new DataFactory(); + $back_target = $df->link( + $DIC->language()->txt('error_back_to_repository'), + $df->uri(ILIAS_HTTP_PATH . '/ilias.php?baseClass=ilRepositoryGUI') + ); - exit(1); + (new ErrorPageResponder( + $DIC->globalScreen(), + $DIC->language(), + $DIC->ui(), + $DIC->http() + ))->handleError( + $message, + StatusCode::HTTP_INTERNAL_SERVER_ERROR, + $back_target + ); +} catch (Throwable $e) { + (new PlainTextFallbackResponder())->respond($e); } diff --git a/components/ILIAS/Init/resources/ilias.php b/components/ILIAS/Init/resources/ilias.php index b331a6b6e2c1..cee64ddce734 100644 --- a/components/ILIAS/Init/resources/ilias.php +++ b/components/ILIAS/Init/resources/ilias.php @@ -18,6 +18,10 @@ declare(strict_types=1); +use ILIAS\HTTP\StatusCode; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\Init\ErrorHandling\Http\ErrorPageResponder; + if (!file_exists('../ilias.ini.php')) { die('The ILIAS setup is not completed. Please run the setup routine.'); } @@ -26,7 +30,7 @@ require_once __DIR__ . '/../artifacts/bootstrap_default.php'; entry_point('ILIAS Legacy Initialisation Adapter'); -/** @var $DIC \ILIAS\DI\Container */ +/** @var \ILIAS\DI\Container $DIC */ global $DIC; try { @@ -36,14 +40,26 @@ throw $e; } - if (!str_contains($e->getMessage(), 'not given a baseclass') && - !str_contains($e->getMessage(), 'not a baseclass')) { - throw new RuntimeException(sprintf('ilCtrl could not dispatch request: %s', $e->getMessage()), 0, $e); - } - $DIC->logger()->root()->error($e->getMessage()); $DIC->logger()->root()->error($e->getTraceAsString()); - $DIC->ctrl()->redirectToURL(ilUtil::_getHttpPath()); + + $DIC->language()->loadLanguageModule('error'); + $df = new DataFactory(); + $back_target = $df->link( + $DIC->language()->txt('error_back_to_repository'), + $df->uri(ILIAS_HTTP_PATH . '/ilias.php?baseClass=ilRepositoryGUI') + ); + + (new ErrorPageResponder( + $DIC->globalScreen(), + $DIC->language(), + $DIC->ui(), + $DIC->http() + ))->handleError( + $DIC->language()->txt('http_404_not_found'), + StatusCode::HTTP_NOT_FOUND, + $back_target + ); } $DIC['ilBench']->save(); diff --git a/components/ILIAS/UICore/classes/Path/class.ilCtrlExistingPath.php b/components/ILIAS/UICore/classes/Path/class.ilCtrlExistingPath.php index c777bdb1ed4f..f9566e9c18b0 100755 --- a/components/ILIAS/UICore/classes/Path/class.ilCtrlExistingPath.php +++ b/components/ILIAS/UICore/classes/Path/class.ilCtrlExistingPath.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\UICore\Exceptions\ilCtrlPathException; + /** * Class ilCtrlExistingPath * @@ -56,7 +58,7 @@ protected function ensureValidCidPath(): void $child_class = $this->structure->getClassNameByCid($child_cid); $allowed_children = $this->structure->getChildrenByCid($parent_cid) ?? []; if (null === $child_class || !in_array($child_class, $allowed_children, true)) { - throw new RuntimeException('ilCtrl: invalid ' . ilCtrlInterface::PARAM_CID_PATH . ' parameter requested.'); + throw new ilCtrlPathException('ilCtrl: invalid ' . ilCtrlInterface::PARAM_CID_PATH . ' parameter requested.'); } } } diff --git a/components/ILIAS/UICore/exceptions/ilCtrlPathException.php b/components/ILIAS/UICore/exceptions/ilCtrlPathException.php new file mode 100644 index 000000000000..10a5c4d60259 --- /dev/null +++ b/components/ILIAS/UICore/exceptions/ilCtrlPathException.php @@ -0,0 +1,31 @@ + - -

- {TXT_LINK} -

- + +{MESSAGE_BOX} + \ No newline at end of file From 816c9b63a7939830971b7473d04f487a043c71be Mon Sep 17 00:00:00 2001 From: Fabian Helfer Date: Mon, 9 Mar 2026 11:41:29 +0100 Subject: [PATCH 2/2] [Improvement] Init: move FallbackResponder to Http namespace --- .../classes/ErrorHandling}/Http/ErrorPageResponder.php | 6 +++--- .../ErrorHandling/Http}/PlainTextFallbackResponder.php | 2 +- .../classes => Init/classes/ErrorHandling}/README.md | 6 +++--- components/ILIAS/Init/resources/error.php | 4 ++-- components/ILIAS/Init/resources/ilias.php | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) rename components/ILIAS/{Exceptions/classes => Init/classes/ErrorHandling}/Http/ErrorPageResponder.php (94%) rename components/ILIAS/{Exceptions/classes => Init/classes/ErrorHandling/Http}/PlainTextFallbackResponder.php (98%) rename components/ILIAS/{Exceptions/classes => Init/classes/ErrorHandling}/README.md (56%) diff --git a/components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php b/components/ILIAS/Init/classes/ErrorHandling/Http/ErrorPageResponder.php similarity index 94% rename from components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php rename to components/ILIAS/Init/classes/ErrorHandling/Http/ErrorPageResponder.php index 71fa65a04097..4d5cc9ebc380 100644 --- a/components/ILIAS/Exceptions/classes/Http/ErrorPageResponder.php +++ b/components/ILIAS/Init/classes/ErrorHandling/Http/ErrorPageResponder.php @@ -35,9 +35,9 @@ * * Use this when the DI container and all ILIAS services are available. * The consumer MUST wrap the main logic in a try-catch and call - * {@see handleError()} in the catch block for expected errors (e.g. routing + * {@see respond()} in the catch block for expected errors (e.g. routing * failures). For unexpected errors during bootstrap, use - * {@see \ILIAS\Init\ErrorHandling\PlainTextFallbackResponder} instead. + * {@see \ILIAS\Init\ErrorHandling\Http\PlainTextFallbackResponder} instead. * * The error message is rendered via MessageBox::failure(). If a back target * (Data\Link) is provided, it is embedded into the MessageBox via withButtons(). @@ -52,7 +52,7 @@ public function __construct( ) { } - public function handleError( + public function respond( string $error_message, int $status_code, ?Link $back_target = null diff --git a/components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php b/components/ILIAS/Init/classes/ErrorHandling/Http/PlainTextFallbackResponder.php similarity index 98% rename from components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php rename to components/ILIAS/Init/classes/ErrorHandling/Http/PlainTextFallbackResponder.php index 4b491ea87ab7..06a100015ed3 100644 --- a/components/ILIAS/Exceptions/classes/PlainTextFallbackResponder.php +++ b/components/ILIAS/Init/classes/ErrorHandling/Http/PlainTextFallbackResponder.php @@ -18,7 +18,7 @@ declare(strict_types=1); -namespace ILIAS\Init\ErrorHandling; +namespace ILIAS\Init\ErrorHandling\Http; use PDOException; use Throwable; diff --git a/components/ILIAS/Exceptions/classes/README.md b/components/ILIAS/Init/classes/ErrorHandling/README.md similarity index 56% rename from components/ILIAS/Exceptions/classes/README.md rename to components/ILIAS/Init/classes/ErrorHandling/README.md index f1792f81c078..607adbdb3f9a 100644 --- a/components/ILIAS/Exceptions/classes/README.md +++ b/components/ILIAS/Init/classes/ErrorHandling/README.md @@ -6,14 +6,14 @@ This package provides responders for rendering HTTP error pages in ILIAS. - **ErrorPageResponder** (`Http\ErrorPageResponder`): Use when the DI container and all ILIAS services (UI, language, HTTP, etc.) are available. Renders a full ILIAS page with a UI-Framework MessageBox and optional back button. Use for expected errors (e.g. routing failures, access denied) that should be shown as a proper HTML page. -- **PlainTextFallbackResponder**: Use when the DI container or other infrastructure is *not* available — for instance in the catch block of `error.php` when the bootstrap itself has failed. Sends a minimal plain-text response with `Content-Type: text/plain; charset=UTF-8` and logs the exception via `error_log`. This responder always works because it uses only PHP built-ins. The HTTP status code defaults to 500; pass a different code (e.g. 502) when the failure context is known. +- **PlainTextFallbackResponder** (`Http\PlainTextFallbackResponder`): Use when the DI container or other infrastructure is *not* available — for instance in the catch block of `error.php` when the bootstrap itself has failed. Sends a minimal plain-text response with `Content-Type: text/plain; charset=UTF-8` and logs the exception via `error_log`. This responder always works because it uses only PHP built-ins. The HTTP status code defaults to 500; pass a different code (e.g. 502) when the failure context is known. ## Consumer responsibility **The consumer MUST implement a try-catch block.** Both responders must be invoked explicitly: 1. Wrap the main logic (bootstrap, routing, etc.) in a `try` block. -2. In the `catch` block, call either `ErrorPageResponder::handleError()` (if DIC is available) or `PlainTextFallbackResponder::respond()` (if DIC is not available). +2. In the `catch` block, call either `ErrorPageResponder::respond()` (if DIC is available) or `PlainTextFallbackResponder::respond()` (if DIC is not available). Example: @@ -26,7 +26,7 @@ try { $DIC->language(), $DIC->ui(), $DIC->http() - ))->handleError($message, 500, $back_target); + ))->respond($message, 500, $back_target); } catch (Throwable $e) { (new PlainTextFallbackResponder())->respond($e); } diff --git a/components/ILIAS/Init/resources/error.php b/components/ILIAS/Init/resources/error.php index 9e7aa7e2fa5a..8b3e3b74ffca 100644 --- a/components/ILIAS/Init/resources/error.php +++ b/components/ILIAS/Init/resources/error.php @@ -24,7 +24,7 @@ use ILIAS\HTTP\StatusCode; use ILIAS\Data\Factory as DataFactory; use ILIAS\Init\ErrorHandling\Http\ErrorPageResponder; -use ILIAS\Init\ErrorHandling\PlainTextFallbackResponder; +use ILIAS\Init\ErrorHandling\Http\PlainTextFallbackResponder; try { require_once '../vendor/composer/vendor/autoload.php'; @@ -53,7 +53,7 @@ $DIC->language(), $DIC->ui(), $DIC->http() - ))->handleError( + ))->respond( $message, StatusCode::HTTP_INTERNAL_SERVER_ERROR, $back_target diff --git a/components/ILIAS/Init/resources/ilias.php b/components/ILIAS/Init/resources/ilias.php index cee64ddce734..fffa01d1ad73 100644 --- a/components/ILIAS/Init/resources/ilias.php +++ b/components/ILIAS/Init/resources/ilias.php @@ -55,7 +55,7 @@ $DIC->language(), $DIC->ui(), $DIC->http() - ))->handleError( + ))->respond( $DIC->language()->txt('http_404_not_found'), StatusCode::HTTP_NOT_FOUND, $back_target