diff --git a/core/lib/Drupal/Core/Path/AliasStorage.php b/core/lib/Drupal/Core/Path/AliasStorage.php index 899c39e..57ff6a9 100644 --- a/core/lib/Drupal/Core/Path/AliasStorage.php +++ b/core/lib/Drupal/Core/Path/AliasStorage.php @@ -14,6 +14,10 @@ /** * Provides a class for CRUD operations on path aliases. + * + * All queries perform case-insensitive matching on the 'source' and 'alias' + * fields, so the 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 +102,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 +125,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. @@ -159,21 +175,38 @@ public function preloadPathAlias($preloaded, $langcode) { * {@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_undetermined = LanguageInterface::LANGCODE_NOT_SPECIFIED; + + // See the queries above. Use LIKE for case-insensitive matching. 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(); + $alias = $this->connection->select('url_alias') + ->fields('url_alias', ['alias']) + ->condition('source', $source, 'LIKE') + ->condition('langcode', $langcode_undetermined) + ->orderBy('pid', 'DESC') + ->execute() + ->fetchField(); } 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(); + $alias = $this->connection->select('url_alias') + ->fields('url_alias', ['alias']) + ->condition('source', $source, 'LIKE') + ->condition('langcode', [$langcode, $langcode_undetermined], 'IN') + ->orderBy('langcode', 'DESC') + ->orderBy('pid', 'DESC') + ->execute() + ->fetchField(); } 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(); + $alias = $this->connection->select('url_alias') + ->fields('url_alias', ['alias']) + ->condition('source', $source, 'LIKE') + ->condition('langcode', [$langcode, $langcode_undetermined], 'IN') + ->orderBy('langcode', 'ASC') + ->orderBy('pid', 'DESC') + ->execute() + ->fetchField(); } return $alias; @@ -183,21 +216,35 @@ public function lookupPathAlias($path, $langcode) { * {@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_undetermined = LanguageInterface::LANGCODE_NOT_SPECIFIED; + + // See the queries above. Use LIKE for case-insensitive matching. 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); + $result = $this->connection->select('url_alias') + ->fields('url_alias', ['source']) + ->condition('alias', $alias, 'LIKE') + ->condition('langcode', $langcode_undetermined) + ->orderBy('pid', 'DESC') + ->execute(); } 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); + $result = $this->connection->select('url_alias') + ->fields('url_alias', ['source']) + ->condition('alias', $alias, 'LIKE') + ->condition('langcode', [$langcode, $langcode_undetermined], 'IN') + ->orderBy('langcode', 'DESC') + ->orderBy('pid', 'DESC') + ->execute(); } 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); + $result = $this->connection->select('url_alias') + ->fields('url_alias', ['source']) + ->condition('alias', $alias, 'LIKE') + ->condition('langcode', [$langcode, $langcode_undetermined], 'IN') + ->orderBy('langcode', 'ASC') + ->orderBy('pid', 'DESC') + ->execute(); } return $result->fetchField(); @@ -207,11 +254,12 @@ public function lookupPathSource($path, $langcode) { * {@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 5ac77a3..3b9c4ee 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 8c84841..95d261a 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 651c11f..c60e797 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,40 @@ 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. + $edit['alias'] = '/alias' . // Lower-case letters. + "- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters. "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string. - "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets. + "中國書۞"; // 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 these characters. + $edit['alias'] .= "ïвβéø"; // Lower-case characters from various non-ASCII alphabets. + } $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 +134,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')); @@ -219,13 +247,20 @@ function testNodeAlias() { // Change alias to one containing "exotic" characters. $previous = $edit['path[0][alias]']; - $edit['path[0][alias]'] = "/- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters. + $edit['path[0][alias]'] = '/alias' . // Lower-case letters. + "- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters. "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string. - "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets. + "中國書۞"; // 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 these characters. + $edit['path[0][alias]'] .= "ïвβéø"; // Lower-case characters from various non-ASCII alphabets. + } $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 0000000..98180e3 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Path/AliasStorageTest.php @@ -0,0 +1,86 @@ +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)); + } + +}