diff --git a/core/lib/Drupal/Core/Path/AliasStorage.php b/core/lib/Drupal/Core/Path/AliasStorage.php index 899c39e08f6d40aa70c809a6f3c61601ed9a7fc3..8caae97e63f175a69951662a8c6386e79141c368 100644 --- a/core/lib/Drupal/Core/Path/AliasStorage.php +++ b/core/lib/Drupal/Core/Path/AliasStorage.php @@ -11,9 +11,14 @@ use Drupal\Core\Database\Connection; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Database\Query\Condition; /** * Provides a class for CRUD operations on path aliases. + * + * All queries perform case-insensitive matching on the 'source' and 'alias' + * fields, so the aliases '/test-alias' and '/test-Alias' are considered to be + * the same, and will both refer to the same internal system path. */ class AliasStorage implements AliasStorageInterface { /** @@ -98,7 +103,13 @@ public function save($source, $alias, $langcode = LanguageInterface::LANGCODE_NO public function load($conditions) { $select = $this->connection->select('url_alias'); foreach ($conditions as $field => $value) { - $select->condition($field, $value); + if ($field == 'source' || $field == 'alias') { + // Use LIKE for case-insensitive matching. + $select->condition($field, $this->connection->escapeLike($value), 'LIKE'); + } + else { + $select->condition($field, $value); + } } return $select ->fields('url_alias') @@ -115,7 +126,13 @@ public function delete($conditions) { $path = $this->load($conditions); $query = $this->connection->delete('url_alias'); foreach ($conditions as $field => $value) { - $query->condition($field, $value); + if ($field == 'source' || $field == 'alias') { + // Use LIKE for case-insensitive matching. + $query->condition($field, $this->connection->escapeLike($value), 'LIKE'); + } + else { + $query->condition($field, $value); + } } $deleted = $query->execute(); // @todo Switch to using an event for this instead of a hook. @@ -128,90 +145,101 @@ public function delete($conditions) { * {@inheritdoc} */ public function preloadPathAlias($preloaded, $langcode) { - $args = array( - ':system[]' => $preloaded, - ':langcode' => $langcode, - ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, - ); + $langcode_list = [$langcode, LanguageInterface::LANGCODE_NOT_SPECIFIED]; + $select = $this->connection->select('url_alias') + ->fields('url_alias', ['source', 'alias']); + + if (!empty($preloaded)) { + $conditions = new Condition('OR'); + foreach ($preloaded as $preloaded_item) { + $conditions->condition('source', $this->connection->escapeLike($preloaded_item), 'LIKE'); + } + $select->condition($conditions); + } + // Always get the language-specific alias before the language-neutral one. // For example 'de' is less than 'und' so the order needs to be ASC, while // 'xx-lolspeak' is more than 'und' so the order needs to be DESC. We also // order by pid ASC so that fetchAllKeyed() returns the most recently // created alias for each source. Subsequent queries using fetchField() must - // use pid DESC to have the same effect. For performance reasons, the query - // builder is not used here. + // use pid DESC to have the same effect. if ($langcode == LanguageInterface::LANGCODE_NOT_SPECIFIED) { - // Prevent PDO from complaining about a token the query doesn't use. - unset($args[':langcode']); - $result = $this->connection->query('SELECT source, alias FROM {url_alias} WHERE source IN ( :system[] ) AND langcode = :langcode_undetermined ORDER BY pid ASC', $args); + array_pop($langcode_list); } elseif ($langcode < LanguageInterface::LANGCODE_NOT_SPECIFIED) { - $result = $this->connection->query('SELECT source, alias FROM {url_alias} WHERE source IN ( :system[] ) AND langcode IN (:langcode, :langcode_undetermined) ORDER BY langcode ASC, pid ASC', $args); + $select->orderBy('langcode', 'ASC'); } else { - $result = $this->connection->query('SELECT source, alias FROM {url_alias} WHERE source IN ( :system[] ) AND langcode IN (:langcode, :langcode_undetermined) ORDER BY langcode DESC, pid ASC', $args); + $select->orderBy('langcode', 'DESC'); } - return $result->fetchAllKeyed(); + $select->orderBy('pid', 'ASC'); + $select->condition('langcode', $langcode_list, 'IN'); + return $select->execute()->fetchAllKeyed(); } /** * {@inheritdoc} */ public function lookupPathAlias($path, $langcode) { - $args = array( - ':source' => $path, - ':langcode' => $langcode, - ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, - ); - // See the queries above. + $source = $this->connection->escapeLike($path); + $langcode_list = [$langcode, LanguageInterface::LANGCODE_NOT_SPECIFIED]; + + // See the queries above. Use LIKE for case-insensitive matching. + $select = $this->connection->select('url_alias') + ->fields('url_alias', ['alias']) + ->condition('source', $source, 'LIKE'); if ($langcode == LanguageInterface::LANGCODE_NOT_SPECIFIED) { - unset($args[':langcode']); - $alias = $this->connection->query("SELECT alias FROM {url_alias} WHERE source = :source AND langcode = :langcode_undetermined ORDER BY pid DESC", $args)->fetchField(); + array_pop($langcode_list); } elseif ($langcode > LanguageInterface::LANGCODE_NOT_SPECIFIED) { - $alias = $this->connection->query("SELECT alias FROM {url_alias} WHERE source = :source AND langcode IN (:langcode, :langcode_undetermined) ORDER BY langcode DESC, pid DESC", $args)->fetchField(); + $select->orderBy('langcode', 'DESC'); } else { - $alias = $this->connection->query("SELECT alias FROM {url_alias} WHERE source = :source AND langcode IN (:langcode, :langcode_undetermined) ORDER BY langcode ASC, pid DESC", $args)->fetchField(); + $select->orderBy('langcode', 'ASC'); } - return $alias; + $select->orderBy('pid', 'DESC'); + $select->condition('langcode', $langcode_list, 'IN'); + return $select->execute()->fetchField(); } /** * {@inheritdoc} */ public function lookupPathSource($path, $langcode) { - $args = array( - ':alias' => $path, - ':langcode' => $langcode, - ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, - ); - // See the queries above. + $alias = $this->connection->escapeLike($path); + $langcode_list = [$langcode, LanguageInterface::LANGCODE_NOT_SPECIFIED]; + + // See the queries above. Use LIKE for case-insensitive matching. + $select = $this->connection->select('url_alias') + ->fields('url_alias', ['source']) + ->condition('alias', $alias, 'LIKE'); if ($langcode == LanguageInterface::LANGCODE_NOT_SPECIFIED) { - unset($args[':langcode']); - $result = $this->connection->query("SELECT source FROM {url_alias} WHERE alias = :alias AND langcode = :langcode_undetermined ORDER BY pid DESC", $args); + array_pop($langcode_list); } elseif ($langcode > LanguageInterface::LANGCODE_NOT_SPECIFIED) { - $result = $this->connection->query("SELECT source FROM {url_alias} WHERE alias = :alias AND langcode IN (:langcode, :langcode_undetermined) ORDER BY langcode DESC, pid DESC", $args); + $select->orderBy('langcode', 'DESC'); } else { - $result = $this->connection->query("SELECT source FROM {url_alias} WHERE alias = :alias AND langcode IN (:langcode, :langcode_undetermined) ORDER BY langcode ASC, pid DESC", $args); + $select->orderBy('langcode', 'ASC'); } - return $result->fetchField(); + $select->orderBy('pid', 'DESC'); + $select->condition('langcode', $langcode_list, 'IN'); + return $select->execute()->fetchField(); } /** * {@inheritdoc} */ public function aliasExists($alias, $langcode, $source = NULL) { + // Use LIKE and NOT LIKE for case-insensitive matching. $query = $this->connection->select('url_alias') - ->condition('alias', $alias) + ->condition('alias', $this->connection->escapeLike($alias), 'LIKE') ->condition('langcode', $langcode); if (!empty($source)) { - $query->condition('source', $source, '<>'); + $query->condition('source', $this->connection->escapeLike($source), 'NOT LIKE'); } $query->addExpression('1'); $query->range(0, 1); diff --git a/core/lib/Drupal/Core/Path/AliasStorageInterface.php b/core/lib/Drupal/Core/Path/AliasStorageInterface.php index 5ac77a3efaa19f4f49613d00905009b68ba9734f..3b9c4eecd962e8a932489d771af9016e601c07c4 100644 --- a/core/lib/Drupal/Core/Path/AliasStorageInterface.php +++ b/core/lib/Drupal/Core/Path/AliasStorageInterface.php @@ -44,6 +44,9 @@ public function save($source, $alias, $langcode = LanguageInterface::LANGCODE_NO /** * Fetches a specific URL alias from the database. * + * The default implementation performs case-insensitive matching on the + * 'source' and 'alias' strings. + * * @param array $conditions * An array of query conditions. * @@ -60,6 +63,9 @@ public function load($conditions); /** * Deletes a URL alias. * + * The default implementation performs case-insensitive matching on the + * 'source' and 'alias' strings. + * * @param array $conditions * An array of criteria. */ @@ -82,6 +88,9 @@ public function preloadPathAlias($preloaded, $langcode); /** * Returns an alias of Drupal system URL. * + * The default implementation performs case-insensitive matching on the + * 'source' and 'alias' strings. + * * @param string $path * The path to investigate for corresponding path aliases. * @param string $langcode @@ -96,6 +105,9 @@ public function lookupPathAlias($path, $langcode); /** * Returns Drupal system URL of an alias. * + * The default implementation performs case-insensitive matching on the + * 'source' and 'alias' strings. + * * @param string $path * The path to investigate for corresponding system URLs. * @param string $langcode @@ -110,6 +122,9 @@ public function lookupPathSource($path, $langcode); /** * Checks if alias already exists. * + * The default implementation performs case-insensitive matching on the + * 'source' and 'alias' strings. + * * @param string $alias * Alias to check against. * @param string $langcode @@ -135,8 +150,9 @@ public function languageAliasExists(); * * @param array $header * Table header. - * @param string[]|null $keys - * (optional) Search keys. + * @param string|null $keys + * (optional) Search keyword that may include one or more '*' as wildcard + * values. * * @return array * Array of items to be displayed on the current page. diff --git a/core/modules/path/src/Form/PathFormBase.php b/core/modules/path/src/Form/PathFormBase.php index 8c84841676092a04ad4b8f387df8f1e18382443c..95d261ac7e8e9b57169b1a273325e7dd881f9a0d 100644 --- a/core/modules/path/src/Form/PathFormBase.php +++ b/core/modules/path/src/Form/PathFormBase.php @@ -180,8 +180,22 @@ public function validateForm(array &$form, FormStateInterface $form_state) { $langcode = $form_state->getValue('langcode', LanguageInterface::LANGCODE_NOT_SPECIFIED); if ($this->aliasStorage->aliasExists($alias, $langcode, $this->path['source'])) { - $form_state->setErrorByName('alias', t('The alias %alias is already in use in this language.', array('%alias' => $alias))); + $stored_alias = $this->aliasStorage->load(['alias' => $alias, 'langcode' => $langcode]); + if ($stored_alias['alias'] !== $alias) { + // The alias already exists with different capitalization as the default + // implementation of AliasStorageInterface::aliasExists is + // case-insensitive. + $form_state->setErrorByName('alias', t('The alias %alias could not be added because it is already in use in this language with different capitalization: %stored_alias.', [ + '%alias' => $alias, + '%stored_alias' => $stored_alias['alias'], + ])); + } + else { + $form_state->setErrorByName('alias', t('The alias %alias is already in use in this language.', ['%alias' => $alias])); + } } + + if (!$this->pathValidator->isValid(trim($source, '/'))) { $form_state->setErrorByName('source', t("The path '@link_path' is either invalid or you do not have access to it.", array('@link_path' => $source))); } diff --git a/core/modules/path/src/Tests/PathAliasTest.php b/core/modules/path/src/Tests/PathAliasTest.php index 651c11f9ea6361b79828ad42f1288b16a8e92fcb..c310f767b370b0eb1acb0adbcc63c07ad713d21e 100644 --- a/core/modules/path/src/Tests/PathAliasTest.php +++ b/core/modules/path/src/Tests/PathAliasTest.php @@ -7,7 +7,10 @@ namespace Drupal\path\Tests; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Cache\Cache; +use Drupal\simpletest\RandomGeneratorTrait; +use Drupal\Core\Database\Database; /** * Add, edit, delete, and change alias and verify its consistency in the @@ -17,6 +20,8 @@ */ class PathAliasTest extends PathTestBase { + use RandomGeneratorTrait; + /** * Modules to enable. * @@ -75,25 +80,45 @@ function testAdminAlias() { // Create alias. $edit = array(); $edit['source'] = '/node/' . $node1->id(); - $edit['alias'] = '/' . $this->randomMachineName(8); + $edit['alias'] = '/' . $this->getRandomGenerator()->word(8); $this->drupalPostForm('admin/config/search/path/add', $edit, t('Save')); // Confirm that the alias works. $this->drupalGet($edit['alias']); $this->assertText($node1->label(), 'Alias works.'); $this->assertResponse(200); + // Confirm that the alias works in a case-insensitive way. + $this->assertTrue(ctype_lower(ltrim($edit['alias'], '/'))); + $this->drupalGet($edit['alias']); + $this->assertText($node1->label(), 'Alias works lower case.'); + $this->assertResponse(200); + $this->drupalGet(Unicode::strtoupper($edit['alias'])); + $this->assertText($node1->label(), 'Alias works upper case.'); + $this->assertResponse(200); // Change alias to one containing "exotic" characters. $pid = $this->getPID($edit['alias']); $previous = $edit['alias']; - $edit['alias'] = "/- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters. - "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string. - "éøïвβä¸åœ‹æ›¸Ûž"; // Characters from various non-ASCII alphabets. + $edit['alias'] = '/alias' . // Lower-case letters. + // "Special" ASCII characters. + "- ._~!$'\"()*@[]?&+%#,;=:" . + // Characters that look like a percent-escaped string. + "%23%25%26%2B%2F%3F" . + // Characters from various non-ASCII alphabets. + "ä¸åœ‹æ›¸Ûž"; + $connection = Database::getConnection(); + if ($connection->databaseType() != 'sqlite') { + // When using LIKE for case-insensitivity, the SQLite driver is + // currently unable to find the upper-case versions of non-ASCII + // characters. + // @todo fix this in https://www.drupal.org/node/2607432 + $edit['alias'] .= "ïвβéø"; + } $this->drupalPostForm('admin/config/search/path/edit/' . $pid, $edit, t('Save')); // Confirm that the alias works. - $this->drupalGet($edit['alias']); + $this->drupalGet(Unicode::strtoupper($edit['alias'])); $this->assertText($node1->label(), 'Changed alias works.'); $this->assertResponse(200); @@ -114,6 +139,14 @@ function testAdminAlias() { // Confirm no duplicate was created. $this->assertRaw(t('The alias %alias is already in use in this language.', array('%alias' => $edit['alias'])), 'Attempt to move alias was rejected.'); + $edit_upper = $edit; + $edit_upper['alias'] = Unicode::strtoupper($edit['alias']); + $this->drupalPostForm('admin/config/search/path/add', $edit_upper, t('Save')); + $this->assertRaw(t('The alias %alias could not be added because it is already in use in this language with different capitalization: %stored_alias.', [ + '%alias' => $edit_upper['alias'], + '%stored_alias' => $edit['alias'], + ]), 'Attempt to move upper-case alias was rejected.'); + // Delete alias. $this->drupalPostForm('admin/config/search/path/edit/' . $pid, array(), t('Delete')); $this->drupalPostForm(NULL, array(), t('Confirm')); @@ -217,15 +250,27 @@ function testNodeAlias() { $elements = $this->xpath("//link[contains(@rel, 'shortlink') and contains(@href, 'node/" . $node1->id() . "')]"); $this->assertTrue(!empty($elements), 'Page contains shortlink URL.'); - // Change alias to one containing "exotic" characters. $previous = $edit['path[0][alias]']; - $edit['path[0][alias]'] = "/- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters. - "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string. - "éøïвβä¸åœ‹æ›¸Ûž"; // Characters from various non-ASCII alphabets. + // Change alias to one containing "exotic" characters. + $edit['path[0][alias]'] = '/alias' . // Lower-case letters. + // "Special" ASCII characters. + "- ._~!$'\"()*@[]?&+%#,;=:" . + // Characters that look like a percent-escaped string. + "%23%25%26%2B%2F%3F" . + // Characters from various non-ASCII alphabets. + "ä¸åœ‹æ›¸Ûž"; + $connection = Database::getConnection(); + if ($connection->databaseType() != 'sqlite') { + // When using LIKE for case-insensitivity, the SQLite driver is + // currently unable to find the upper-case versions of non-ASCII + // characters. + // @todo fix this in https://www.drupal.org/node/2607432 + $edit['path[0][alias]'] .= "ïвβéø"; + } $this->drupalPostForm('node/' . $node1->id() . '/edit', $edit, t('Save')); // Confirm that the alias works. - $this->drupalGet($edit['path[0][alias]']); + $this->drupalGet(Unicode::strtoupper($edit['path[0][alias]'])); $this->assertText($node1->label(), 'Changed alias works.'); $this->assertResponse(200); diff --git a/core/tests/Drupal/KernelTests/Core/Path/AliasStorageTest.php b/core/tests/Drupal/KernelTests/Core/Path/AliasStorageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..98180e3a3431919a4d43d5492d53e8035b224601 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Path/AliasStorageTest.php @@ -0,0 +1,86 @@ +<?php + +/** + * @file + * Contains \Drupal\KernelTests\Core\Path\AliasStorageTest. + */ + +namespace Drupal\KernelTests\Core\Path; + +use Drupal\Core\Language\LanguageInterface; +use Drupal\KernelTests\KernelTestBase; + +/** + * @coversDefaultClass \Drupal\Core\Path\AliasStorage + * @group path + */ +class AliasStorageTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['system']; + + /** @var \Drupal\Core\Path\AliasStorage */ + protected $storage; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installSchema('system', 'url_alias'); + $this->storage = $this->container->get('path.alias_storage'); + } + + /** + * @covers ::load + */ + public function testLoad() { + $this->storage->save('/test-source-Case', '/test-alias-Case'); + + $expected = [ + 'pid' => 1, + 'alias' => '/test-alias-Case', + 'source' => '/test-source-Case', + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + ]; + + $this->assertEquals($expected, $this->storage->load(['alias' => '/test-alias-Case'])); + $this->assertEquals($expected, $this->storage->load(['alias' => '/test-alias-case'])); + $this->assertEquals($expected, $this->storage->load(['source' => '/test-source-Case'])); + $this->assertEquals($expected, $this->storage->load(['source' => '/test-source-case'])); + } + + /** + * @covers ::lookupPathAlias + */ + public function testLookupPathAlias() { + $this->storage->save('/test-source-Case', '/test-alias'); + + $this->assertEquals('/test-alias', $this->storage->lookupPathAlias('/test-source-Case', LanguageInterface::LANGCODE_NOT_SPECIFIED)); + $this->assertEquals('/test-alias', $this->storage->lookupPathAlias('/test-source-case', LanguageInterface::LANGCODE_NOT_SPECIFIED)); + } + + /** + * @covers ::lookupPathSource + */ + public function testLookupPathSource() { + $this->storage->save('/test-source', '/test-alias-Case'); + + $this->assertEquals('/test-source', $this->storage->lookupPathSource('/test-alias-Case', LanguageInterface::LANGCODE_NOT_SPECIFIED)); + $this->assertEquals('/test-source', $this->storage->lookupPathSource('/test-alias-case', LanguageInterface::LANGCODE_NOT_SPECIFIED)); + } + + /** + * @covers ::aliasExists + */ + public function testAliasExists() { + $this->storage->save('/test-source-Case', '/test-alias-Case'); + + $this->assertTrue($this->storage->aliasExists('/test-alias-Case', LanguageInterface::LANGCODE_NOT_SPECIFIED)); + $this->assertTrue($this->storage->aliasExists('/test-alias-case', LanguageInterface::LANGCODE_NOT_SPECIFIED)); + } + +}