Issue #728702 by Crell, daffie, pillarsdotnet, pjcdawkins, Damien Tournoud, David_Rothstein, reglogge, amateescu, tim.plunkett, tstoeckler, xjm, hussainweb, alexpott, cweagans, Tor Arne Thune, catch: Visiting index.php should redirect to install.php if settings.php already has database credentials but database is empty

8.3.x
Alex Pott 2016-09-23 10:34:06 +01:00
parent fbf3e5cedf
commit 29ce04e5d0
13 changed files with 388 additions and 12 deletions

View File

@ -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:

View File

@ -1444,6 +1444,26 @@ abstract class Connection {
return $this->connection->quote($string, $parameter_type);
}
/**
* 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.
*/

View File

@ -0,0 +1,8 @@
<?php
namespace Drupal\Core\Database;
/**
* Exception thrown if access credentials fail.
*/
class DatabaseAccessDeniedException extends \RuntimeException implements DatabaseException {}

View File

@ -5,4 +5,4 @@ namespace Drupal\Core\Database;
/**
* Exception thrown if specified database is not found.
*/
class DatabaseNotFoundException extends \RuntimeException {}
class DatabaseNotFoundException extends \RuntimeException implements DatabaseException {}

View File

@ -2,6 +2,7 @@
namespace Drupal\Core\Database\Driver\mysql;
use Drupal\Core\Database\DatabaseAccessDeniedException;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\Database;
@ -26,6 +27,11 @@ class Connection extends DatabaseConnection {
*/
const DATABASE_NOT_FOUND = 1049;
/**
* Error code for "Access denied" error.
*/
const ACCESS_DENIED = 1045;
/**
* Error code for "Can't initialize character set" error.
*/
@ -139,7 +145,18 @@ class Connection extends DatabaseConnection {
$connection_options['pdo'] += [\PDO::MYSQL_ATTR_MULTI_STATEMENTS => 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

View File

@ -4,6 +4,7 @@ namespace Drupal\Core\Database\Driver\pgsql;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\Connection as DatabaseConnection;
use Drupal\Core\Database\DatabaseAccessDeniedException;
use Drupal\Core\Database\DatabaseNotFoundException;
/**
@ -26,6 +27,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.
*
@ -113,7 +122,21 @@ class Connection extends DatabaseConnection {
// 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;
}

View File

@ -4,7 +4,6 @@ namespace Drupal\Core\Database\Driver\pgsql\Install;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\Install\Tasks as InstallTasks;
use Drupal\Core\Database\Driver\pgsql\Connection;
use Drupal\Core\Database\DatabaseNotFoundException;
/**
@ -66,7 +65,7 @@ class Tasks extends InstallTasks {
}
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'];

View File

@ -99,7 +99,19 @@ class Connection extends DatabaseConnection {
// 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'));

View File

@ -16,6 +16,7 @@ use Drupal\Core\DependencyInjection\YamlFileLoader;
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 @@ use Symfony\Component\Routing\Route;
* 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 @@ class DrupalKernel implements DrupalKernelInterface, TerminableInterface {
// 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 @@ class DrupalKernel implements DrupalKernelInterface, TerminableInterface {
* 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;
}
/**

View File

@ -0,0 +1,65 @@
<?php
namespace Drupal\Core\EventSubscriber;
use Drupal\Core\Database\Connection;
use Drupal\Core\Installer\InstallerRedirectTrait;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Exception handler to determine if an exception indicates an uninstalled site.
*/
class ExceptionDetectNeedsInstallSubscriber implements EventSubscriberInterface {
use InstallerRedirectTrait;
/**
* The default database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Constructs a new ExceptionDetectNeedsInstallSubscriber.
*
* @param \Drupal\Core\Database\Connection $connection
* The default database connection.
*/
public function __construct(Connection $connection) {
$this->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;
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace Drupal\Core\Installer;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\DatabaseException;
use Drupal\Core\Database\DatabaseNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provides methods for checking if Drupal is already installed.
*/
trait InstallerRedirectTrait {
/**
* Returns whether the current PHP process runs on CLI.
*
* @return bool
*/
protected function isCli() {
return PHP_SAPI === 'cli';
}
/**
* Determines if an exception handler should redirect to the installer.
*
* @param \Exception $exception
* The exception to check.
* @param \Drupal\Core\Database\Connection|null $connection
* (optional) The default database connection. If not provided, a less
* comprehensive check will be performed. This can be the case if the
* exception occurs early enough that a database connection object isn't
* available from the container yet.
*
* @return bool
* TRUE if the exception handler should redirect to the installer because
* Drupal is not installed yet, or FALSE otherwise.
*/
protected function shouldRedirectToInstaller(\Exception $exception, Connection $connection = NULL) {
// Never redirect on the command line.
if ($this->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;
}
}

View File

@ -223,7 +223,7 @@ class UncaughtExceptionTest extends WebTestBase {
'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 @@ class UncaughtExceptionTest extends WebTestBase {
$this->drupalGet('');
$this->assertResponse(500);
$this->assertRaw('PDOException');
$this->assertRaw('DatabaseAccessDeniedException');
$this->assertErrorLogged($this->expectedExceptionMessage);
}

View File

@ -0,0 +1,132 @@
<?php
namespace Drupal\Tests\Core\Installer;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\DatabaseNotFoundException;
use Drupal\Core\Database\Schema;
use Drupal\Core\Installer\InstallerRedirectTrait;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
// The function drupal_installation_attempted() has to be included.
$root = dirname(dirname(dirname(dirname(dirname(dirname(__DIR__))))));
require_once $root . '/core/includes/bootstrap.inc';
/**
* @coversDefaultClass \Drupal\Core\Installer\InstallerRedirectTrait
*
* @group Installer
*/
class InstallerRedirectTraitTest extends UnitTestCase {
/**
* Data provider for testShouldRedirectToInstaller().
*
* @return array
* - Expected result from shouldRedirectToInstaller().
* - Exceptions to be handled by shouldRedirectToInstaller()
* - Whether or not there is a database connection.
* - Whether or not there is database connection info.
* - Whether or not there exists a sessions table in the database.
*/
public function providerShouldRedirectToInstaller() {
return array(
[TRUE, DatabaseNotFoundException::class, FALSE, FALSE],
[TRUE, DatabaseNotFoundException::class, TRUE, FALSE],
[TRUE, DatabaseNotFoundException::class, FALSE, TRUE],
[TRUE, DatabaseNotFoundException::class, TRUE, TRUE],
[TRUE, DatabaseNotFoundException::class, TRUE, TRUE, FALSE],
[TRUE, \PDOException::class, FALSE, FALSE],
[TRUE, \PDOException::class, TRUE, FALSE],
[FALSE, \PDOException::class, FALSE, TRUE],
[FALSE, \PDOException::class, TRUE, TRUE],
[TRUE, \PDOException::class, TRUE, TRUE, FALSE],
[TRUE, DatabaseExceptionWrapper::class, FALSE, FALSE],
[TRUE, DatabaseExceptionWrapper::class, TRUE, FALSE],
[FALSE, DatabaseExceptionWrapper::class, FALSE, TRUE],
[FALSE, DatabaseExceptionWrapper::class, TRUE, TRUE],
[TRUE, DatabaseExceptionWrapper::class, TRUE, TRUE, FALSE],
[TRUE, NotFoundHttpException::class, FALSE, FALSE],
[TRUE, NotFoundHttpException::class, TRUE, FALSE],
[FALSE, NotFoundHttpException::class, FALSE, TRUE],
[FALSE, NotFoundHttpException::class, TRUE, TRUE],
[TRUE, NotFoundHttpException::class, TRUE, TRUE, FALSE],
[FALSE, \Exception::class, FALSE, FALSE],
[FALSE, \Exception::class, TRUE, FALSE],
[FALSE, \Exception::class, FALSE, TRUE],
[FALSE, \Exception::class, TRUE, TRUE],
[FALSE, \Exception::class, TRUE, TRUE, FALSE],
);
}
/**
* @covers ::shouldRedirectToInstaller
* @dataProvider providerShouldRedirectToInstaller
*/
function testShouldRedirectToInstaller($expected, $exception, $connection, $connection_info, $session_table_exists = TRUE) {
try {
throw new $exception();
}
catch (\Exception $e) {
// Mock the trait.
$trait = $this->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));
}
}
}