diff --git a/core/core.services.yml b/core/core.services.yml index 0b18c3b..d1223e9 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1197,6 +1197,11 @@ services: tags: - { name: event_subscriber } arguments: ['@config.manager', '@config.storage', '@config.storage.snapshot'] + exception.needs_installer: + class: Drupal\Core\EventSubscriber\ExceptionDetectNeedsInstallSubscriber + arguments: ['@database'] + tags: + - { name: event_subscriber } exception.default_json: class: Drupal\Core\EventSubscriber\ExceptionJsonSubscriber tags: diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index 5acd236..38342f9 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -1445,6 +1445,26 @@ public function quote($string, $parameter_type = \PDO::PARAM_STR) { } /** + * Extracts the SQLSTATE error from the PDOException. + * + * @param \Exception $e + * The exception + * + * @return string + * The five character error code. + */ + protected static function getSQLState(\Exception $e) { + // The PDOException code is not always reliable, try to see whether the + // message has something usable. + if (preg_match('/^SQLSTATE\[(\w{5})\]/', $e->getMessage(), $matches)) { + return $matches[1]; + } + else { + return $e->getCode(); + } + } + + /** * Prevents the database connection from being serialized. */ public function __sleep() { diff --git a/core/lib/Drupal/Core/Database/DatabaseAccessDeniedException.php b/core/lib/Drupal/Core/Database/DatabaseAccessDeniedException.php new file mode 100644 index 0000000..85bfea9 --- /dev/null +++ b/core/lib/Drupal/Core/Database/DatabaseAccessDeniedException.php @@ -0,0 +1,8 @@ + FALSE]; } - $pdo = new \PDO($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']); + try { + $pdo = new \PDO($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']); + } + catch (\PDOException $e) { + if ($e->getCode() == static::DATABASE_NOT_FOUND) { + throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e); + } + if ($e->getCode() == static::ACCESS_DENIED) { + throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e); + } + throw $e; + } // Force MySQL to use the UTF-8 character set. Also set the collation, if a // certain one has been set; otherwise, MySQL defaults to diff --git a/core/lib/Drupal/Core/Database/Driver/pgsql/Connection.php b/core/lib/Drupal/Core/Database/Driver/pgsql/Connection.php index 8049796..570faee 100644 --- a/core/lib/Drupal/Core/Database/Driver/pgsql/Connection.php +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Connection.php @@ -4,6 +4,7 @@ use Drupal\Core\Database\Database; use Drupal\Core\Database\Connection as DatabaseConnection; +use Drupal\Core\Database\DatabaseAccessDeniedException; use Drupal\Core\Database\DatabaseNotFoundException; /** @@ -27,6 +28,14 @@ class Connection extends DatabaseConnection { const DATABASE_NOT_FOUND = 7; /** + * Error code for "Connection failure" errors. + * + * Technically this is an internal error code that will only be shown in the + * PDOException message. It will need to get extracted. + */ + const CONNECTION_FAILURE = '08006'; + + /** * The list of PostgreSQL reserved key words. * * @see http://www.postgresql.org/docs/9.4/static/sql-keywords-appendix.html @@ -113,7 +122,21 @@ public static function open(array &$connection_options = array()) { // Convert numeric values to strings when fetching. \PDO::ATTR_STRINGIFY_FETCHES => TRUE, ); - $pdo = new \PDO($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']); + + try { + $pdo = new \PDO($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']); + } + catch (\PDOException $e) { + if (static::getSQLState($e) == static::CONNECTION_FAILURE) { + if (strpos($e->getMessage(), 'password authentication failed for user') !== FALSE) { + throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e); + } + elseif (strpos($e->getMessage(), 'database') !== FALSE && strpos($e->getMessage(), 'does not exist') !== FALSE) { + throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e); + } + } + throw $e; + } return $pdo; } diff --git a/core/lib/Drupal/Core/Database/Driver/pgsql/Install/Tasks.php b/core/lib/Drupal/Core/Database/Driver/pgsql/Install/Tasks.php old mode 100644 new mode 100755 index 7e78b14..7c85eaf --- a/core/lib/Drupal/Core/Database/Driver/pgsql/Install/Tasks.php +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Install/Tasks.php @@ -66,7 +66,7 @@ protected function connect() { } catch (\Exception $e) { // Attempt to create the database if it is not found. - if ($e->getCode() == Connection::DATABASE_NOT_FOUND) { + if ($e instanceof DatabaseNotFoundException) { // Remove the database string from connection info. $connection_info = Database::getConnectionInfo(); $database = $connection_info['default']['database']; diff --git a/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php b/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php index 23dd261..f43f04d 100644 --- a/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php +++ b/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php @@ -99,7 +99,19 @@ public static function open(array &$connection_options = array()) { // Convert numeric values to strings when fetching. \PDO::ATTR_STRINGIFY_FETCHES => TRUE, ); - $pdo = new \PDO('sqlite:' . $connection_options['database'], '', '', $connection_options['pdo']); + + try { + $pdo = new \PDO('sqlite:' . $connection_options['database'], '', '', $connection_options['pdo']); + } + catch (\PDOException $e) { + if ($e->getCode() == static::DATABASE_NOT_FOUND) { + throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e); + } + // SQLite doesn't have a distinct error code for access denied, so don't + // deal with that case. + throw $e; + } + // Create functions needed by SQLite. $pdo->sqliteCreateFunction('if', array(__CLASS__, 'sqlFunctionIf')); diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php old mode 100644 new mode 100755 index a132ff1..ab2f645 --- a/core/lib/Drupal/Core/DrupalKernel.php +++ b/core/lib/Drupal/Core/DrupalKernel.php @@ -16,6 +16,7 @@ use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\File\MimeType\MimeTypeGuesser; use Drupal\Core\Http\TrustedHostsRequestFactory; +use Drupal\Core\Installer\InstallerRedirectTrait; use Drupal\Core\Language\Language; use Drupal\Core\Site\Settings; use Drupal\Core\Test\TestDatabase; @@ -45,6 +46,7 @@ * container, or modify existing services. */ class DrupalKernel implements DrupalKernelInterface, TerminableInterface { + use InstallerRedirectTrait; /** * Holds the class used for dumping the container to a PHP array. @@ -644,7 +646,7 @@ public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = // installed yet (i.e., if no $databases array has been defined in the // settings.php file) and we are not already installing. if (!Database::getConnectionInfo() && !drupal_installation_attempted() && PHP_SAPI !== 'cli') { - $response = new RedirectResponse($request->getBasePath() . '/core/install.php'); + $response = new RedirectResponse($request->getBasePath() . '/core/install.php', 302, ['Cache-Control' => 'no-cache']); } else { $this->boot(); @@ -683,14 +685,17 @@ public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = * If the passed in exception cannot be turned into a response. */ protected function handleException(\Exception $e, $request, $type) { + if ($this->shouldRedirectToInstaller($e, $this->container ? $this->container->get('database') : NULL)) { + return new RedirectResponse($request->getBasePath() . '/core/install.php', 302, ['Cache-Control' => 'no-cache']); + } + if ($e instanceof HttpExceptionInterface) { $response = new Response($e->getMessage(), $e->getStatusCode()); $response->headers->add($e->getHeaders()); return $response; } - else { - throw $e; - } + + throw $e; } /** diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionDetectNeedsInstallSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionDetectNeedsInstallSubscriber.php new file mode 100644 index 0000000..9b974f2 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionDetectNeedsInstallSubscriber.php @@ -0,0 +1,65 @@ +connection = $connection; + } + + /** + * Handles errors for this subscriber. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The event to process. + */ + public function onException(GetResponseForExceptionEvent $event) { + $exception = $event->getException(); + if ($this->shouldRedirectToInstaller($exception, $this->connection)) { + // Only redirect if this is an HTML response (i.e., a user trying to view + // the site in a web browser before installing it). + $request = $event->getRequest(); + $format = $request->query->get(MainContentViewSubscriber::WRAPPER_FORMAT, $request->getRequestFormat()); + if ($format == 'html') { + $event->setResponse(new RedirectResponse($request->getBasePath() . '/core/install.php', 302, ['Cache-Control' => 'no-cache'])); + } + } + } + + /** + * Registers the methods in this class that should be listeners. + * + * @return array + * An array of event listener definitions. + */ + public static function getSubscribedEvents() { + $events[KernelEvents::EXCEPTION][] = ['onException', 100]; + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php b/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php new file mode 100644 index 0000000..b060b1e --- /dev/null +++ b/core/lib/Drupal/Core/Installer/InstallerRedirectTrait.php @@ -0,0 +1,91 @@ +isCli()) { + return FALSE; + } + + // Never redirect if we're already in the installer. + if (drupal_installation_attempted()) { + return FALSE; + } + + // If the database wasn't found, assume the user hasn't entered it properly + // and redirect to the installer. This check needs to come first because a + // DatabaseNotFoundException is also an instance of DatabaseException. + if ($exception instanceof DatabaseNotFoundException) { + return TRUE; + } + + // To avoid unnecessary queries, only act if the exception is one that is + // expected to occur when Drupal has not yet been installed. This includes + // NotFoundHttpException because an uninstalled site won't have route + // information available yet and therefore can return 404 errors. + if (!($exception instanceof \PDOException || $exception instanceof DatabaseException || $exception instanceof NotFoundHttpException)) { + return FALSE; + } + + // Redirect if there isn't even any database connection information in + // settings.php yet, since that means Drupal is not installed. + if (!Database::getConnectionInfo()) { + return TRUE; + } + + // Redirect if the database is empty. + if ($connection) { + try { + return !$connection->schema()->tableExists('sessions'); + } + catch (\Exception $e) { + // If we still have an exception at this point, we need to be careful + // since we should not redirect if the exception represents an error on + // an already-installed site (for example, if the database server went + // down). Assume we shouldn't redirect, just in case. + return FALSE; + } + } + + // When in doubt, don't redirect. + return FALSE; + } + +} diff --git a/core/modules/system/src/Tests/System/UncaughtExceptionTest.php b/core/modules/system/src/Tests/System/UncaughtExceptionTest.php index c155781..059d6ad 100644 --- a/core/modules/system/src/Tests/System/UncaughtExceptionTest.php +++ b/core/modules/system/src/Tests/System/UncaughtExceptionTest.php @@ -223,7 +223,7 @@ public function testLostDatabaseConnection() { 'value' => $incorrect_username, 'required' => TRUE, ); - $settings['databases']['default']['default']['passowrd'] = (object) array( + $settings['databases']['default']['default']['password'] = (object) array( 'value' => $this->randomMachineName(16), 'required' => TRUE, ); @@ -232,7 +232,7 @@ public function testLostDatabaseConnection() { $this->drupalGet(''); $this->assertResponse(500); - $this->assertRaw('PDOException'); + $this->assertRaw('DatabaseAccessDeniedException'); $this->assertErrorLogged($this->expectedExceptionMessage); } diff --git a/core/tests/Drupal/Tests/Core/Installer/InstallerRedirectTraitTest.php b/core/tests/Drupal/Tests/Core/Installer/InstallerRedirectTraitTest.php new file mode 100644 index 0000000..d88a738 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Installer/InstallerRedirectTraitTest.php @@ -0,0 +1,132 @@ +getMockBuilder(InstallerRedirectTrait::class) + ->setMethods(array('isCli')) + ->getMockForTrait(); + + // Make sure that the method thinks we are not using the cli. + $trait->expects($this->any()) + ->method('isCli') + ->willReturn(FALSE); + + // Un-protect the method using reflection. + $method_ref = new \ReflectionMethod($trait, 'shouldRedirectToInstaller'); + $method_ref->setAccessible(TRUE); + + // Mock the database connection info. + $db = $this->getMockForAbstractClass(Database::class); + $property_ref = new \ReflectionProperty($db, 'databaseInfo'); + $property_ref->setAccessible(true); + $property_ref->setValue($db, ['default' => $connection_info]); + + if ($connection) { + // Mock the database connection. + $connection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods(array('schema')) + ->getMockForAbstractClass(); + + if ($connection_info) { + // Mock the database schema class. + $schema = $this->getMockBuilder(Schema::class) + ->disableOriginalConstructor() + ->setMethods(array('tableExists')) + ->getMockForAbstractClass(); + + $schema->expects($this->any()) + ->method('tableExists') + ->with('sessions') + ->willReturn($session_table_exists); + + $connection->expects($this->any()) + ->method('schema') + ->willReturn($schema); + } + } + else { + // Set the database connection if there is none. + $connection = NULL; + } + + // Call shouldRedirectToInstaller. + $this->assertSame($expected, $method_ref->invoke($trait, $e, $connection)); + } + } + +}