diff --git a/core/includes/errors.inc b/core/includes/errors.inc index d82ed9bdd1655c4f463c5d3da12bfd72f442bdef..ea27cbef93a5ac87005c102404a355ec5bdb74ff 100644 --- a/core/includes/errors.inc +++ b/core/includes/errors.inc @@ -262,7 +262,7 @@ function _drupal_log_error($error, $fatal = FALSE): void { // We fallback to a maintenance page at this point, because the page // generation itself can generate errors. // Should not translate the string to avoid errors producing more errors. - $message = 'The website encountered an unexpected error. Try again later.' . '<br />' . $message; + $message = 'The website encountered an unexpected error. Try again later.' . '<br /><br />' . $message; if ($is_installer) { // install_display_output() prints the output and ends script execution. @@ -279,6 +279,16 @@ function _drupal_log_error($error, $fatal = FALSE): void { } } + $html = Error::renderFatalError([ + 'title' => 'Service unavailable', + 'content' => $message, + 'displayable' => error_displayable($error), + ]); + if (!empty($html)) { + $message = $html; + $response->headers->set('Content-Type', 'text/html'); + } + $response->setContent($message); $response->setStatusCode(500, '500 Service unavailable (with message)'); diff --git a/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php index 3473a1b5775dc0eedadf6aef349cc0eb49cfbc6d..a75bdac1393b632c02f8a3c489fca5a0120c1454 100644 --- a/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/FinalExceptionSubscriber.php @@ -30,9 +30,10 @@ * handled by a single exception subscriber:: serialization.exception.default. * * This exception subscriber runs after all the above (it has a lower priority), - * which makes it the last-chance exception handler. It always sends a plain - * text response. If it's a displayable error and the error level is configured - * to be verbose, then a helpful backtrace is also printed. + * which makes it the last-chance exception handler. It sends a html response + * if a request format is html and a plain text otherwise. If it's a displayable + * error and the error level is configured to be verbose, then a helpful + * backtrace is also printed. */ class FinalExceptionSubscriber implements EventSubscriberInterface { use StringTranslationTrait; @@ -129,6 +130,18 @@ public function onException(ExceptionEvent $event) { $content_type = $event->getRequest()->getRequestFormat() == 'html' ? 'text/html' : 'text/plain'; $content = $this->t('The website encountered an unexpected error. Try again later.'); $content .= $message ? '<br><br>' . $message : ''; + + if ($content_type == 'text/html') { + $html = Error::renderFatalError([ + 'title' => $this->t('Service unavailable'), + 'content' => $content, + 'displayable' => $this->isErrorDisplayable($error), + ]); + if (!empty($html)) { + $content = $html; + } + } + $response = new Response($content, 500, ['Content-Type' => $content_type]); if ($exception instanceof HttpExceptionInterface) { diff --git a/core/lib/Drupal/Core/Utility/Error.php b/core/lib/Drupal/Core/Utility/Error.php index 459af44d8c517733e57c75b43b365f9534d09bb4..6e7d29ef0f7672ee37856c7dfeb9cfe27e24b3e4 100644 --- a/core/lib/Drupal/Core/Utility/Error.php +++ b/core/lib/Drupal/Core/Utility/Error.php @@ -2,13 +2,18 @@ namespace Drupal\Core\Utility; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\DependencyInjection\ContainerNotInitializedException; +use Drupal\Core\Extension\ExtensionDiscovery; +use Drupal\Core\Site\Settings; use Drupal\Component\Utility\Xss; +use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Database\Connection; use Drupal\Core\Database\Database; use Drupal\Core\Database\DatabaseExceptionWrapper; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; +use Twig\Environment; +use Twig\Loader\ArrayLoader; /** * Drupal error utility class. @@ -120,6 +125,69 @@ public static function renderExceptionSafe($exception) { return new FormattableMarkup(Error::DEFAULT_ERROR_MESSAGE . ' <pre class="backtrace">@backtrace</pre>', $decode); } + /** + * Renders fatal error page from twig template using Symfony twig engine. + * + * @param array $context + * Variables to be passed to twig template. + * + * @return string + * An html code with rendered fatal error page. + */ + public static function renderFatalError(array $context) { + $template_path = '/templates/maintenance-page--offline.html.twig'; + $system_path = 'core/modules/system'; + $theme = ''; + + // Get offline theme from settings.php and check if the template exists. + try { + $theme = Settings::get('maintenance_theme', ''); + if (!$theme) { + $theme_path = $system_path; + } + else { + $theme_path = \Drupal::service('extension.list.theme')->getPath($theme); + } + } + catch (ContainerNotInitializedException $e) { + // The maintenance theme is set but the container doesn't exist + // since the database is inactive. Hence there are no services available + // to retrieve a maintenance theme path. The path can be obtained by using + // ExtensionDiscovery::scan() but the app root should be guessed first + // in the same way as DrupalKernel::guessApplicationRoot() does. + $app_root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2); + $listing = new ExtensionDiscovery($app_root, FALSE, NULL, NULL); + // An empty profile directory prevents ExtensionDiscovery::scan() + // from calling \Drupal::installProfile() that needs working container. + $listing->setProfileDirectories([]); + $themes = $listing->scan('theme'); + $theme_path = isset($themes[$theme]) ? $themes[$theme]->getPath() : $system_path; + if ($context['displayable']) { + $context['content'] = $context['content'] . "<pre>" . $e . "</pre>"; + } + } + catch (\Throwable $error) { + // Handle any other cases. + $theme_path = $system_path; + if ($context['displayable']) { + $context['content'] = $context['content'] . "<pre>" . $error . "</pre>"; + } + } + + $path = $theme_path . $template_path; + if (!file_exists($path)) { + $path = $system_path . $template_path; + } + + // Directly use Symfony twig engine without Drupal wrapper to minimize + // possibility of nested exception. + $template = file_get_contents($path); + $loader = new ArrayLoader(['maintenance_page_offline' => $template]); + $environment = new Environment($loader); + + return $environment->render('maintenance_page_offline', $context); + } + /** * Gets the last caller from a backtrace. * diff --git a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php index 26e92fc14982467c2cf6c2e851653de9007e0758..e7aa0d4fa730089f5d5918f2002fcfa3dabea3aa 100644 --- a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php +++ b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php @@ -212,9 +212,8 @@ public function testBigPipe(): void { // The 'edge_case__html_exception' case throws an exception. $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later'); $this->assertSession()->pageTextContains('You are not allowed to say llamas are not cool!'); - // Check that stop signal and closing body tag are absent. + // Check that stop signal is absent. $this->assertSession()->responseNotContains(BigPipe::STOP_SIGNAL); - $this->assertSession()->responseNotContains('</body>'); // The exception is expected. Do not interpret it as a test failure. unlink($this->root . '/' . $this->siteDirectory . '/error.log'); @@ -294,7 +293,6 @@ public function testBigPipeNoJs(): void { // The 'edge_case__html_exception' case throws an exception. $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later'); $this->assertSession()->pageTextContains('You are not allowed to say llamas are not cool!'); - $this->assertSession()->responseNotContains('</body>'); // The exception is expected. Do not interpret it as a test failure. unlink($this->root . '/' . $this->siteDirectory . '/error.log'); } diff --git a/core/modules/system/templates/maintenance-page--offline.html.twig b/core/modules/system/templates/maintenance-page--offline.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..f7ba55ddecd418a439392f6013d0165d9906f85d --- /dev/null +++ b/core/modules/system/templates/maintenance-page--offline.html.twig @@ -0,0 +1,41 @@ +{# +/** + * @file + * Default theme implementation to display a single Drupal page while offline. + * + * @see template_preprocess_maintenance_page() + * + * @ingroup themeable + */ +#} +<!DOCTYPE html> +<html lang="en"> + <head> + <title> + {% if title %} + {{ title }} + {% else %} + Service Unavailable + {% endif %} + </title> + </head> + <body class="maintenance-page layout-no-sidebars path-frontpage"> + <div id="page-wrapper"> + <div id="page"> + <div id="main-wrapper"> + <div id="main" class="clearfix"> + <main id="content" class="column" role="main"> + <section class="section"> + <a id="main-content"></a> + {% if title %} + <h1 class="title" id="page-title">{{ title }}</h1> + {% endif %} + {{ content|striptags('<p>,<br>,<em>,<pre>')|raw }} + </section> + </main> + </div> + </div> + </div> + </div> + </body> +</html> \ No newline at end of file diff --git a/core/modules/system/tests/src/Functional/System/MaintenancePageOfflineTest.php b/core/modules/system/tests/src/Functional/System/MaintenancePageOfflineTest.php new file mode 100644 index 0000000000000000000000000000000000000000..06e2f5da9a22b6fe9bdfdd2912443e348d408b8a --- /dev/null +++ b/core/modules/system/tests/src/Functional/System/MaintenancePageOfflineTest.php @@ -0,0 +1,212 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\System; + +use Drupal\Core\Database\Database; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests if the Maintenance page is served when the site is offline. + * + * @group system + */ +class MaintenancePageOfflineTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'test_theme'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $settings_filename = $this->siteDirectory . '/settings.php'; + chmod($settings_filename, 0777); + $settings_php = file_get_contents($settings_filename); + // Ensure we can test errors rather than being caught in + // \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware. + $settings_php .= "\ndefine('SIMPLETEST_COLLECT_ERRORS', FALSE);\n"; + file_put_contents($settings_filename, $settings_php); + } + + /** + * Prepare settings values before a test case. + * + * @param string $maintenance_theme + * The name of a maintenance theme. Empty if there is no maintenance theme. + * @param string $error_level + * The name of an error level. + * @param bool $active_database + * TRUE if a database should be active. + * @param bool $valid_hash_salt + * TRUE if a hash_salt should be valid. + */ + protected function prepareCaseSettings($maintenance_theme, $error_level, $active_database = TRUE, $valid_hash_salt = TRUE): void { + $settings = []; + if (!empty($maintenance_theme)) { + $settings['settings']['maintenance_theme'] = (object) [ + 'value' => $maintenance_theme, + 'required' => TRUE, + ]; + } + $settings['config']['system.logging']['error_level'] = (object) [ + 'value' => $error_level, + 'required' => TRUE, + ]; + if (!$active_database) { + // Make a database inactive by setting an invalid password. + $connection_info = Database::getConnectionInfo(); + $settings['databases']['default']['default']['password'] = (object) [ + 'value' => $connection_info['default']['password'] . $this->randomMachineName(), + 'required' => TRUE, + ]; + } + if (!$valid_hash_salt) { + // Set a hash_salt to invalid value. + $settings['settings']['hash_salt'] = (object) [ + 'value' => NULL, + 'required' => TRUE, + ]; + } + $this->writeSettings($settings); + } + + /** + * Tests cases when settings.php contains invalid database settings. + * + * Tests if the maintenance offline page is served when settings.php + * contains invalid database settings. + */ + public function testInvalidDatabaseSettings(): void { + // Open a frontpage without any error. + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Log in'); + + // CASE 1 - a maintenance theme's template is picked up + // and no errors are displayed. + $this->prepareCaseSettings('test_theme', ERROR_REPORTING_HIDE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A maintenance theme's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title test-theme"'); + // A fatal error message and a backtrace should be hidden. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextNotContains('Access denied for user'); + $this->assertSession()->responseNotContains('<pre class="backtrace">'); + + // CASE 2 - a maintenance theme's template is picked up + // and all errors with a backtrace are displayed. + $this->prepareCaseSettings('test_theme', ERROR_REPORTING_DISPLAY_VERBOSE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A maintenance theme's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title test-theme"'); + // A fatal error message and a backtrace should be shown. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextContains('Access denied for user'); + $this->assertSession()->responseContains('<pre class="backtrace">'); + + // CASE 3 - a system's template is picked up + // since a maintenance theme doesn't have the template + // and no errors are displayed. + $this->prepareCaseSettings('test_subtheme', ERROR_REPORTING_HIDE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A system's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title"'); + // A fatal error message and a backtrace should be hidden. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextNotContains('Access denied for user'); + $this->assertSession()->responseNotContains('<pre class="backtrace">'); + + // CASE 4 - a system's template is picked up + // since a maintenance theme is not set + // and all errors with a backtrace are displayed. + $this->prepareCaseSettings('', ERROR_REPORTING_DISPLAY_VERBOSE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A system's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title"'); + // A fatal error message and a backtrace should be shown. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextContains('Access denied for user'); + $this->assertSession()->responseContains('<pre class="backtrace">'); + } + + /** + * Tests cases when settings.php doesn't have a hash_salt defined. + * + * Tests if the maintenance offline page is served when settings.php + * originally did have a hash_salt defined, which is emptied out afterwards. + */ + public function testRemovedHashSalt(): void { + // Open a frontpage without any error. + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('Log in'); + + // CASE 1 - a maintenance theme's template is picked up + // and no errors are displayed. + $this->prepareCaseSettings('test_theme', ERROR_REPORTING_HIDE, TRUE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A maintenance theme's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title test-theme"'); + // A fatal error message and a backtrace should be hidden. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextNotContains('Missing $settings[\'hash_salt\'] in settings.php'); + $this->assertSession()->responseNotContains('<pre class="backtrace">'); + + // CASE 2 - a maintenance theme's template is picked up + // and all errors with a backtrace are displayed. + $this->prepareCaseSettings('test_theme', ERROR_REPORTING_DISPLAY_VERBOSE, TRUE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A maintenance theme's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title test-theme"'); + // A fatal error message and a backtrace should be shown. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextContains('Missing $settings[\'hash_salt\'] in settings.php'); + $this->assertSession()->responseContains('<pre class="backtrace">'); + + // CASE 3 - a system's template is picked up + // since a maintenance theme doesn't have the template + // and no errors are displayed. + $this->prepareCaseSettings('test_subtheme', ERROR_REPORTING_HIDE, TRUE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A system's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title"'); + // A fatal error message and a backtrace should be hidden. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextNotContains('Missing $settings[\'hash_salt\'] in settings.php'); + $this->assertSession()->responseNotContains('<pre class="backtrace">'); + + // CASE 4 - a system's template is picked up + // since a maintenance theme is not set + // and all errors with a backtrace are displayed. + $this->prepareCaseSettings('', ERROR_REPORTING_DISPLAY_VERBOSE, TRUE, FALSE); + $this->drupalGet(''); + $this->assertSession()->statusCodeEquals(500); + // A system's offline template should be picked up. + $this->assertSession()->pageTextContains('Service unavailable'); + $this->assertSession()->responseContains('<h1 class="title"'); + // A fatal error message and a backtrace should be shown. + $this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.'); + $this->assertSession()->pageTextContains('Missing $settings[\'hash_salt\'] in settings.php'); + $this->assertSession()->responseContains('<pre class="backtrace">'); + } + +} diff --git a/core/modules/system/tests/themes/test_theme/templates/maintenance-page--offline.html.twig b/core/modules/system/tests/themes/test_theme/templates/maintenance-page--offline.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..f73983c4eb2dc043aaae5f28c4b67f296437139f --- /dev/null +++ b/core/modules/system/tests/themes/test_theme/templates/maintenance-page--offline.html.twig @@ -0,0 +1,41 @@ +{# +/** + * @file + * Default theme implementation to display a single Drupal page while offline. + * + * @see template_preprocess_maintenance_page() + * + * @ingroup themeable + */ +#} +<!DOCTYPE html> +<html lang="en"> + <head> + <title> + {% if title %} + {{ title }} + {% else %} + Service Unavailable + {% endif %} + </title> + </head> + <body class="maintenance-page layout-no-sidebars path-frontpage"> + <div id="page-wrapper"> + <div id="page"> + <div id="main-wrapper"> + <div id="main" class="clearfix"> + <main id="content" class="column" role="main"> + <section class="section"> + <a id="main-content"></a> + {% if title %} + <h1 class="title test-theme" id="page-title">{{ title }}</h1> + {% endif %} + {{ content|striptags('<p>,<br>,<em>,<pre>')|raw }} + </section> + </main> + </div> + </div> + </div> + </div> + </body> +</html> \ No newline at end of file