Skip to content

Commit 0925817

Browse files
authored
[FEATURE] Init, UICore: add HTTP error-responders. (#11110)
* Add `ErrorPageResponder` for full-features web page responses * Add `PlainTextFallbackResponder` for internal server errors * Add documentation about the usage of the responders above * Implement usage in `ilias.php` endpoint to send appropriate 404 responses * Implement usage in `error.php` endpoint to send appropriate 500 responses
1 parent e4c0043 commit 0925817

10 files changed

Lines changed: 313 additions & 70 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
/**
4+
* This file is part of ILIAS, a powerful learning management system
5+
* published by ILIAS open source e-Learning e.V.
6+
*
7+
* ILIAS is licensed with the GPL-3.0,
8+
* see https://www.gnu.org/licenses/gpl-3.0.en.html
9+
* You should have received a copy of said license along with the
10+
* source code, too.
11+
*
12+
* If this is not the case or you just want to try ILIAS, you'll find
13+
* us at:
14+
* https://www.ilias.de
15+
* https://github.com/ILIAS-eLearning
16+
*
17+
*********************************************************************/
18+
19+
declare(strict_types=1);
20+
21+
namespace ILIAS\Init\ErrorHandling\Http;
22+
23+
use ilUtil;
24+
use ilLanguage;
25+
use ILIAS\Data\Link;
26+
use ilGlobalTemplate;
27+
use ILIAS\DI\UIServices;
28+
use ILIAS\HTTP\Response\ResponseHeader;
29+
use ILIAS\HTTP\Services as HTTPServices;
30+
use ILIAS\GlobalScreen\Services as GlobalScreenServices;
31+
32+
/**
33+
* Responder that renders a full ILIAS error page (UI-Framework MessageBox)
34+
* and sends it with the appropriate HTTP status code.
35+
*
36+
* Use this when the DI container and all ILIAS services are available.
37+
* The consumer MUST wrap the main logic in a try-catch and call
38+
* {@see respond()} in the catch block for expected errors (e.g. routing
39+
* failures). For unexpected errors during bootstrap, use
40+
* {@see \ILIAS\Init\ErrorHandling\Http\PlainTextFallbackResponder} instead.
41+
*
42+
* The error message is rendered via MessageBox::failure(). If a back target
43+
* (Data\Link) is provided, it is embedded into the MessageBox via withButtons().
44+
*/
45+
class ErrorPageResponder
46+
{
47+
public function __construct(
48+
private readonly GlobalScreenServices $global_screen,
49+
private readonly ilLanguage $language,
50+
private readonly UIServices $ui,
51+
private readonly HTTPServices $http
52+
) {
53+
}
54+
55+
public function respond(
56+
string $error_message,
57+
int $status_code,
58+
?Link $back_target = null
59+
): void {
60+
$this->global_screen->tool()->context()->claim()->external();
61+
$this->language->loadLanguageModule('error');
62+
63+
$message_box = $this->ui->factory()->messageBox()->failure($error_message);
64+
65+
if ($back_target !== null) {
66+
$ui_button = $this->ui->factory()->button()->standard(
67+
$back_target->getLabel(),
68+
ilUtil::secureUrl((string) $back_target->getURL())
69+
);
70+
$message_box = $message_box->withButtons([$ui_button]);
71+
}
72+
73+
$local_tpl = new ilGlobalTemplate('tpl.error.html', true, true);
74+
$local_tpl->setCurrentBlock('msg_box');
75+
$local_tpl->setVariable(
76+
'MESSAGE_BOX',
77+
$this->ui->renderer()->render($message_box)
78+
);
79+
$local_tpl->parseCurrentBlock();
80+
81+
$this->http->saveResponse(
82+
$this->http
83+
->response()
84+
->withStatus($status_code)
85+
->withHeader(ResponseHeader::CONTENT_TYPE, 'text/html')
86+
);
87+
88+
$this->ui->mainTemplate()->setContent($local_tpl->get());
89+
$this->ui->mainTemplate()->printToStdout();
90+
91+
$this->http->close();
92+
}
93+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
/**
4+
* This file is part of ILIAS, a powerful learning management system
5+
* published by ILIAS open source e-Learning e.V.
6+
*
7+
* ILIAS is licensed with the GPL-3.0,
8+
* see https://www.gnu.org/licenses/gpl-3.0.en.html
9+
* You should have received a copy of said license along with the
10+
* source code, too.
11+
*
12+
* If this is not the case or you just want to try ILIAS, you'll find
13+
* us at:
14+
* https://www.ilias.de
15+
* https://github.com/ILIAS-eLearning
16+
*
17+
*********************************************************************/
18+
19+
declare(strict_types=1);
20+
21+
namespace ILIAS\Init\ErrorHandling\Http;
22+
23+
use PDOException;
24+
use Throwable;
25+
use DateTimeZone;
26+
use DateTimeImmutable;
27+
use ILIAS\HTTP\StatusCode;
28+
29+
/**
30+
* Responder that sends a minimal plain-text error response without relying on
31+
* any ILIAS service (no DIC, no UI framework, no templates).
32+
*
33+
* Use this as a last-resort fallback when the DI container or other
34+
* infrastructure is not available — for instance in the catch block of
35+
* error.php when the bootstrap itself has failed.
36+
*
37+
* The consumer MUST wrap the bootstrap / main logic in a try-catch and call
38+
* {@see respond()} in the catch block. In DEVMODE the exception is re-thrown
39+
* so that Whoops / the developer can inspect the full stack trace.
40+
*
41+
* This responder always works: it uses only PHP built-ins (headers, echo,
42+
* error_log, exit). Prefer {@see \ILIAS\Init\ErrorHandling\Http\ErrorPageResponder}
43+
* when the DIC is available, as it renders a proper ILIAS page with the
44+
* UI framework.
45+
*/
46+
class PlainTextFallbackResponder
47+
{
48+
/**
49+
* Send a minimal plain-text error response and terminate the process.
50+
*
51+
* The status code defaults to 500 (Internal Server Error). The caller may pass
52+
* a different code when the failure context is known.
53+
*
54+
* @param int $status_code HTTP status code (default: 500).
55+
* @throws Throwable in DEVMODE
56+
*/
57+
public function respond(Throwable $e, int $status_code = StatusCode::HTTP_INTERNAL_SERVER_ERROR): never
58+
{
59+
if (defined('DEVMODE') && DEVMODE) {
60+
throw $e;
61+
}
62+
63+
if (!headers_sent()) {
64+
http_response_code($status_code);
65+
header('Content-Type: text/plain; charset=UTF-8');
66+
}
67+
68+
$incident_id = session_id() . '_' . (new \Random\Randomizer())->getInt(1, 9999);
69+
$timestamp = (new DateTimeImmutable())
70+
->setTimezone(new DateTimeZone('UTC'))
71+
->format('Y-m-d\TH:i:s\Z');
72+
73+
echo "Internal Server Error\n";
74+
echo "Incident: $incident_id\n";
75+
echo "Timestamp: $timestamp\n";
76+
77+
if ($e instanceof PDOException) {
78+
echo "Message: A database error occurred. Please contact the system administrator with the incident id.\n";
79+
} else {
80+
echo "Message: {$e->getMessage()}\n";
81+
}
82+
83+
error_log(sprintf(
84+
"[%s] INCIDENT %s — Uncaught %s: %s in %s:%d\nStack trace:\n%s\n",
85+
$timestamp,
86+
$incident_id,
87+
get_class($e),
88+
$e->getMessage(),
89+
$e->getFile(),
90+
$e->getLine(),
91+
$e->getTraceAsString()
92+
));
93+
94+
exit(1);
95+
}
96+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Error Responders
2+
3+
This package provides responders for rendering HTTP error pages in ILIAS.
4+
5+
## When to use which responder
6+
7+
- **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.
8+
9+
- **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.
10+
11+
## Consumer responsibility
12+
13+
**The consumer MUST implement a try-catch block.** Both responders must be invoked explicitly:
14+
15+
1. Wrap the main logic (bootstrap, routing, etc.) in a `try` block.
16+
2. In the `catch` block, call either `ErrorPageResponder::respond()` (if DIC is available) or `PlainTextFallbackResponder::respond()` (if DIC is not available).
17+
18+
Example:
19+
20+
```php
21+
try {
22+
entry_point('ILIAS Legacy Initialisation Adapter');
23+
global $DIC;
24+
new ErrorPageResponder(
25+
$DIC->globalScreen(),
26+
$DIC->language(),
27+
$DIC->ui(),
28+
$DIC->http()
29+
)->respond($message, 500, $back_target);
30+
} catch (Throwable $e) {
31+
new PlainTextFallbackResponder()->respond($e);
32+
}
33+
```

components/ILIAS/Init/resources/error.php

Lines changed: 27 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,73 +20,44 @@
2020

2121
namespace ILIAS\Init;
2222

23+
use Throwable;
24+
use ILIAS\HTTP\StatusCode;
25+
use ILIAS\Data\Factory as DataFactory;
26+
use ILIAS\Init\ErrorHandling\Http\ErrorPageResponder;
27+
use ILIAS\Init\ErrorHandling\Http\PlainTextFallbackResponder;
28+
2329
try {
2430
require_once '../vendor/composer/vendor/autoload.php';
2531

2632
require_once __DIR__ . '/../artifacts/bootstrap_default.php';
2733
entry_point('ILIAS Legacy Initialisation Adapter');
2834

29-
$DIC->globalScreen()->tool()->context()->claim()->external();
30-
31-
$lng->loadLanguageModule('error');
32-
$txt = $lng->txt('error_back_to_repository');
33-
34-
$local_tpl = new \ilGlobalTemplate('tpl.error.html', true, true);
35-
$local_tpl->setCurrentBlock('ErrorLink');
36-
$local_tpl->setVariable('TXT_LINK', $txt);
37-
$local_tpl->setVariable('LINK', \ilUtil::secureUrl(ILIAS_HTTP_PATH . '/ilias.php?baseClass=ilRepositoryGUI'));
38-
$local_tpl->parseCurrentBlock();
35+
/** @var \ILIAS\DI\Container $DIC */
36+
global $DIC;
3937

4038
\ilSession::clear('referer');
4139
\ilSession::clear('message');
4240

43-
$DIC->http()->saveResponse(
44-
$DIC->http()
45-
->response()
46-
->withStatus(500)
47-
->withHeader(\ILIAS\HTTP\Response\ResponseHeader::CONTENT_TYPE, 'text/html')
48-
);
49-
50-
$tpl->setContent($local_tpl->get());
51-
$tpl->printToStdout();
41+
$DIC->language()->loadLanguageModule('error');
5242

53-
$DIC->http()->close();
54-
} catch (\Throwable $e) {
55-
if (\defined('DEVMODE') && DEVMODE) {
56-
throw $e;
57-
}
43+
$message = \ilSession::get('failure') ?? $DIC->language()->txt('http_500_internal_server_error');
5844

59-
/*
60-
* Since we are already in the `error.php` and an unexpected error occurred, we should not rely on the $DIC or any
61-
* other components here and use "Vanilla PHP" instead to handle the error.
62-
*/
63-
if (!headers_sent()) {
64-
http_response_code(500);
65-
header('Content-Type: text/plain; charset=UTF-8');
66-
}
67-
68-
$incident_id = session_id() . '_' . (new \Random\Randomizer())->getInt(1, 9999);
69-
$timestamp = (new \DateTimeImmutable())->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
70-
71-
echo "Internal Server Error\n";
72-
echo "Incident: $incident_id\n";
73-
echo "Timestamp: $timestamp\n";
74-
if ($e instanceof \PDOException) {
75-
echo "Message: A database error occurred. Please contact the system administrator with the incident id.\n";
76-
} else {
77-
echo "Message: {$e->getMessage()}\n";
78-
}
79-
80-
error_log(\sprintf(
81-
"[%s] INCIDENT %s — Uncaught %s: %s in %s:%d\nStack trace:\n%s\n",
82-
$timestamp,
83-
$incident_id,
84-
\get_class($e),
85-
$e->getMessage(),
86-
$e->getFile(),
87-
$e->getLine(),
88-
$e->getTraceAsString()
89-
));
45+
$df = new DataFactory();
46+
$back_target = $df->link(
47+
$DIC->language()->txt('error_back_to_repository'),
48+
$df->uri(ILIAS_HTTP_PATH . '/ilias.php?baseClass=ilRepositoryGUI')
49+
);
9050

91-
exit(1);
51+
new ErrorPageResponder(
52+
$DIC->globalScreen(),
53+
$DIC->language(),
54+
$DIC->ui(),
55+
$DIC->http()
56+
)->respond(
57+
$message,
58+
StatusCode::HTTP_INTERNAL_SERVER_ERROR,
59+
$back_target
60+
);
61+
} catch (Throwable $e) {
62+
new PlainTextFallbackResponder()->respond($e);
9263
}

components/ILIAS/Init/resources/ilias.php

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818

1919
declare(strict_types=1);
2020

21+
use ILIAS\HTTP\StatusCode;
22+
use ILIAS\Data\Factory as DataFactory;
23+
use ILIAS\Init\ErrorHandling\Http\ErrorPageResponder;
24+
2125
if (!file_exists('../ilias.ini.php')) {
2226
die('The ILIAS setup is not completed. Please run the setup routine.');
2327
}
@@ -26,7 +30,7 @@
2630
require_once __DIR__ . '/../artifacts/bootstrap_default.php';
2731
entry_point('ILIAS Legacy Initialisation Adapter');
2832

29-
/** @var $DIC \ILIAS\DI\Container */
33+
/** @var \ILIAS\DI\Container $DIC */
3034
global $DIC;
3135

3236
try {
@@ -36,14 +40,26 @@
3640
throw $e;
3741
}
3842

39-
if (!str_contains($e->getMessage(), 'not given a baseclass') &&
40-
!str_contains($e->getMessage(), 'not a baseclass')) {
41-
throw new RuntimeException(sprintf('ilCtrl could not dispatch request: %s', $e->getMessage()), 0, $e);
42-
}
43-
4443
$DIC->logger()->root()->error($e->getMessage());
4544
$DIC->logger()->root()->error($e->getTraceAsString());
46-
$DIC->ctrl()->redirectToURL(ilUtil::_getHttpPath());
45+
46+
$DIC->language()->loadLanguageModule('error');
47+
$df = new DataFactory();
48+
$back_target = $df->link(
49+
$DIC->language()->txt('error_back_to_repository'),
50+
$df->uri(ILIAS_HTTP_PATH . '/ilias.php?baseClass=ilRepositoryGUI')
51+
);
52+
53+
new ErrorPageResponder(
54+
$DIC->globalScreen(),
55+
$DIC->language(),
56+
$DIC->ui(),
57+
$DIC->http()
58+
)->respond(
59+
$DIC->language()->txt('http_404_not_found'),
60+
StatusCode::HTTP_NOT_FOUND,
61+
$back_target
62+
);
4763
}
4864

4965
$DIC['ilBench']->save();

components/ILIAS/UICore/classes/Path/class.ilCtrlExistingPath.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
declare(strict_types=1);
2020

21+
use ILIAS\UICore\Exceptions\ilCtrlPathException;
22+
2123
/**
2224
* Class ilCtrlExistingPath
2325
*
@@ -56,7 +58,7 @@ protected function ensureValidCidPath(): void
5658
$child_class = $this->structure->getClassNameByCid($child_cid);
5759
$allowed_children = $this->structure->getChildrenByCid($parent_cid) ?? [];
5860
if (null === $child_class || !in_array($child_class, $allowed_children, true)) {
59-
throw new RuntimeException('ilCtrl: invalid ' . ilCtrlInterface::PARAM_CID_PATH . ' parameter requested.');
61+
throw new ilCtrlPathException('ilCtrl: invalid ' . ilCtrlInterface::PARAM_CID_PATH . ' parameter requested.');
6062
}
6163
}
6264
}

0 commit comments

Comments
 (0)