diff --git a/core/lib/Drupal/Core/Path/AliasStorage.php b/core/lib/Drupal/Core/Path/AliasStorage.php index 899c39e..4385923 100644 --- a/core/lib/Drupal/Core/Path/AliasStorage.php +++ b/core/lib/Drupal/Core/Path/AliasStorage.php @@ -8,6 +8,7 @@ namespace Drupal\Core\Path; use Drupal\Core\Cache\Cache; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\Connection; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Language\LanguageInterface; @@ -98,7 +99,8 @@ 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); + // Use LIKE for case-insensitive matching. + $select->condition($field, $value, 'LIKE'); } return $select ->fields('url_alias') @@ -115,7 +117,8 @@ public function delete($conditions) { $path = $this->load($conditions); $query = $this->connection->delete('url_alias'); foreach ($conditions as $field => $value) { - $query->condition($field, $value); + // Use LIKE for case-insensitive matching. + $query->condition($field, $value, 'LIKE'); } $deleted = $query->execute(); // @todo Switch to using an event for this instead of a hook. @@ -129,7 +132,6 @@ public function delete($conditions) { */ public function preloadPathAlias($preloaded, $langcode) { $args = array( - ':system[]' => $preloaded, ':langcode' => $langcode, ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, ); @@ -140,19 +142,22 @@ public function preloadPathAlias($preloaded, $langcode) { // 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. + $select = $this->connection->select('url_alias'); + $select->fields('url_alias', ['source', 'alias']); + $select->condition('source', $preloaded, 'IN'); if ($langcode == LanguageInterface::LANGCODE_NOT_SPECIFIED) { - // Prevent PDO from complaining about a token the query doesn't use. + // Don't put the same value in the IN query twice. 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); } 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->condition('langcode', $args, 'IN'); + $select->orderBy('pid', 'ASC'); + return $select->execute()->fetchAllKeyed(); } /** @@ -160,23 +165,26 @@ public function preloadPathAlias($preloaded, $langcode) { */ public function lookupPathAlias($path, $langcode) { $args = array( - ':source' => $path, ':langcode' => $langcode, ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, ); // See the queries above. + $select = $this->connection->select('url_alias'); + $select->fields('url_alias', ['alias']); 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(); } 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; + // Use LIKE for case-insensitive matching. + $select->condition('source', $path, 'LIKE'); + $select->condition('langcode', $args, 'IN'); + $select->orderBy('pid', 'DESC'); + return $select->execute()->fetchField(); } /** @@ -184,23 +192,26 @@ public function lookupPathAlias($path, $langcode) { */ public function lookupPathSource($path, $langcode) { $args = array( - ':alias' => $path, ':langcode' => $langcode, ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, ); // See the queries above. + $select = $this->connection->select('url_alias'); + $select->fields('url_alias', ['source']); 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); } 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(); + // Use LIKE for case-insensitive matching. + $select->condition('alias', $path, 'LIKE'); + $select->condition('langcode', $args, 'IN'); + $select->orderBy('pid', 'DESC'); + return $select->execute()->fetchField(); } /** @@ -208,7 +219,8 @@ public function lookupPathSource($path, $langcode) { */ public function aliasExists($alias, $langcode, $source = NULL) { $query = $this->connection->select('url_alias') - ->condition('alias', $alias) + // Use LIKE for case-insensitive matching. + ->condition('alias', $alias, 'LIKE') ->condition('langcode', $langcode); if (!empty($source)) { $query->condition('source', $source, '<>'); diff --git a/core/lib/Drupal/Core/Path/AliasStorageInterface.php b/core/lib/Drupal/Core/Path/AliasStorageInterface.php index 5ac77a3..7717aef 100644 --- a/core/lib/Drupal/Core/Path/AliasStorageInterface.php +++ b/core/lib/Drupal/Core/Path/AliasStorageInterface.php @@ -135,8 +135,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 a wildcard + * value. * * @return array * Array of items to be displayed on the current page. diff --git a/core/lib/Drupal/Core/Routing/CompiledRoute.php b/core/lib/Drupal/Core/Routing/CompiledRoute.php index 2e2ff6b..ef7ca47 100644 --- a/core/lib/Drupal/Core/Routing/CompiledRoute.php +++ b/core/lib/Drupal/Core/Routing/CompiledRoute.php @@ -56,7 +56,8 @@ class CompiledRoute extends SymfonyCompiledRoute { * @param array $tokens * An array of tokens to use to generate URL for this route * @param array $pathVariables - * An array of path variables + * An array of path variable names where the numeric array keys correspond + * to the path part where the named variable is found in the path pattern. * @param string|null $hostRegex * Host regex * @param array $hostTokens @@ -143,6 +144,27 @@ public function getRequirements() { } /** + * Returns the path variables. + * + * The Drupal implementation differs from the parent class in that the numeric + * array keys correspond to a matching path part containing that variable. + * For example, for a route with path + * @code + * /node/{node}/revisions/{node_revision}/view + * @endcode + * this method will return the array + * @code + * [1 => 'node', 3 => 'node_revision'] + * @endcode + * + * @return array + * The path variable names keyed by numeric path part. + */ + public function getPathVariables() { + return parent::getPathVariables(); + } + + /** * {@inheritdoc} */ public function serialize() { diff --git a/core/lib/Drupal/Core/Routing/RouteBuilder.php b/core/lib/Drupal/Core/Routing/RouteBuilder.php index a78cf0c..c88a033 100644 --- a/core/lib/Drupal/Core/Routing/RouteBuilder.php +++ b/core/lib/Drupal/Core/Routing/RouteBuilder.php @@ -8,6 +8,7 @@ namespace Drupal\Core\Routing; use Drupal\Component\Discovery\YamlDiscovery; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Access\CheckProviderInterface; use Drupal\Core\Controller\ControllerResolverInterface; use Drupal\Core\Extension\ModuleHandlerInterface; @@ -174,7 +175,9 @@ public function rebuild() { 'condition' => '', ); - $route = new Route($route_info['path'], $route_info['defaults'], $route_info['requirements'], $route_info['options'], $route_info['host'], $route_info['schemes'], $route_info['methods'], $route_info['condition']); + // Lowercase the path here so that the events get a consistent path, + // even though we force them all to be lowercase later. + $route = new Route(Unicode::strtolower($route_info['path']), $route_info['defaults'], $route_info['requirements'], $route_info['options'], $route_info['host'], $route_info['schemes'], $route_info['methods'], $route_info['condition']); $collection->add($name, $route); } } @@ -182,11 +185,15 @@ public function rebuild() { // DYNAMIC is supposed to be used to add new routes based upon all the // static defined ones. $this->dispatcher->dispatch(RoutingEvents::DYNAMIC, new RouteBuildEvent($collection)); + // Process the whole collection since we cannot tell what was newly added. + $this->lowercaseCollection($collection); // ALTER is the final step to alter all the existing routes. We cannot stop // people from adding new routes here, but we define two separate steps to // make it clear. $this->dispatcher->dispatch(RoutingEvents::ALTER, new RouteBuildEvent($collection)); + // Process the whole collection since we cannot tell what was changed. + $this->lowercaseCollection($collection); $this->checkProvider->setChecks($collection); @@ -223,6 +230,20 @@ public function destruct() { } /** + * Lowercases the path for each route in the collection. + * + * @param \Symfony\Component\Routing\RouteCollection $collection + * A route collection. + */ + protected function lowercaseCollection(RouteCollection $collection) { + foreach ($collection->all() as $route) { + // Force each path to be lowercase. + $path = Unicode::strtolower($route->getPath()); + $route->setPath($path); + } + } + + /** * Retrieves all defined routes from .routing.yml files. * * @return array diff --git a/core/lib/Drupal/Core/Routing/RouteCompiler.php b/core/lib/Drupal/Core/Routing/RouteCompiler.php index 639feff..ece32e2 100644 --- a/core/lib/Drupal/Core/Routing/RouteCompiler.php +++ b/core/lib/Drupal/Core/Routing/RouteCompiler.php @@ -55,7 +55,7 @@ public static function compile(Route $route) { $symfony_compiled->getStaticPrefix(), $symfony_compiled->getRegex(), $symfony_compiled->getTokens(), - $symfony_compiled->getPathVariables(), + static::getMappedPathVariables($route->getPath(), $symfony_compiled->getPathVariables()), $symfony_compiled->getHostRegex(), $symfony_compiled->getHostTokens(), $symfony_compiled->getHostVariables(), @@ -64,6 +64,36 @@ public static function compile(Route $route) { } /** + * Returns the mapping of route variables to numeric path part. + * + * Drupal only supports path wildcard (variables) that consist of a complete + * part of the path separated by slashes. + * + * @see \Symfony\Component\Routing\RouteCompiler::compilePattern() + * + * @param string $path + * The path for which we want the mapping. + * @param array $variables + * The names of placeholder variables in the path. + * + * @return array + * The mapping of numeric path part to variable name. + */ + public static function getMappedPathVariables($path, array $variables) { + $map = []; + // Split the path up on the slashes, ignoring multiple slashes in a row + // or leading or trailing slashes. + $parts = preg_split('@/+@', $path, NULL, PREG_SPLIT_NO_EMPTY); + foreach ($variables as $name) { + $index = array_search('{' . $name . '}', $parts, TRUE); + if ($index !== FALSE) { + $map[$index] = $name; + } + } + return $map; + } + + /** * Returns the pattern outline. * * The pattern outline is the path pattern but normalized so that all diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php index 58b8320..77b9449 100644 --- a/core/lib/Drupal/Core/Routing/RouteProvider.php +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php @@ -10,6 +10,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Cache\CacheTagsInvalidatorInterface; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Path\CurrentPathStack; use Drupal\Core\PathProcessor\InboundPathProcessorInterface; use Drupal\Core\State\StateInterface; @@ -149,7 +150,9 @@ public function __construct(Connection $connection, StateInterface $state, Curre */ public function getRouteCollectionForRequest(Request $request) { // Cache both the system path as well as route parameters and matching - // routes. + // routes. We can not yet convert the path to lowercase since wildcard path + // portions may be case sensitive if they contain data like a base64 encoded + // token. $cid = 'route:' . $request->getPathInfo() . ':' . $request->getQueryString(); if ($cached = $this->cache->get($cid)) { $this->currentPath->setPath($cached->data['path'], $request); @@ -157,9 +160,9 @@ public function getRouteCollectionForRequest(Request $request) { return $cached->data['routes']; } else { - // Just trim on the right side. $path = $request->getPathInfo(); - $path = $path === '/' ? $path : rtrim($request->getPathInfo(), '/'); + // Just trim path on the right side. + $path = $path === '/' ? $path : rtrim($path, '/'); $path = $this->pathProcessor->processInbound($path, $request); $this->currentPath->setPath($path, $request); // Incoming path processors may also set query parameters. @@ -316,10 +319,12 @@ public function getRoutesByPattern($pattern) { } /** - * Get all routes which match a certain pattern. + * Get all routes which match a certain path pattern. + * + * The path will be converted to lowercase before performing the match. * * @param string $path - * The route pattern to search for (contains % as placeholders). + * The route path pattern to search for (contains % as placeholders). * * @return \Symfony\Component\Routing\RouteCollection * Returns a route collection of matching routes. @@ -327,7 +332,7 @@ public function getRoutesByPattern($pattern) { protected function getRoutesByPath($path) { // Split the path up on the slashes, ignoring multiple slashes in a row // or leading or trailing slashes. - $parts = preg_split('@/+@', $path, NULL, PREG_SPLIT_NO_EMPTY); + $parts = preg_split('@/+@', Unicode::strtolower($path), NULL, PREG_SPLIT_NO_EMPTY); $collection = new RouteCollection(); diff --git a/core/lib/Drupal/Core/Routing/UrlMatcher.php b/core/lib/Drupal/Core/Routing/UrlMatcher.php index 49bff8f..f802d2f 100644 --- a/core/lib/Drupal/Core/Routing/UrlMatcher.php +++ b/core/lib/Drupal/Core/Routing/UrlMatcher.php @@ -7,6 +7,7 @@ namespace Drupal\Core\Routing; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Path\CurrentPathStack; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\RouteCollection; @@ -46,4 +47,83 @@ public function finalMatch(RouteCollection $collection, Request $request) { return $this->match($this->currentPath->getPath($request)); } + /** + * Tries to match a URL with a set of routes. + * + * This version differs from the Symfony parent version in two respects. + * First, the $pathinfo string is converted to lowercase when matching the + * route path regular expression. In addition, we remove the check against any + * static prefix since we would already have matched the static prefix in + * \Drupal\Core\Routing\RouteProvider before arriving here. + * + * @param string $pathinfo + * The path to be parsed. + * @param \Symfony\Component\Routing\RouteCollection $routes + * The set of routes. + * + * @return array + * An array of route parameters. + * + * @throws \Symfony\Component\Routing\Exception\ResourceNotFoundException + * If the resource could not be found. + * @throws \Symfony\Component\Routing\Exception\MethodNotAllowedException + * If the resource was found but the request method is not allowed. + */ + protected function matchCollection($pathinfo, RouteCollection $routes) { + foreach ($routes as $name => $route) { + /** @var \Symfony\Component\Routing\Route $route */ + $compiledRoute = $route->compile(); + + // Convert the path to lowercase, so that we match the patterns from + // routes where we forced all paths to be lowercase. + // @see \Drupal\Core\Routing\RouteBuilder::rebuild() + // @see \Drupal\Core\Routing\RouteProvider::getRoutesByPath() + if (!preg_match($compiledRoute->getRegex(), Unicode::strtolower($pathinfo), $matches)) { + continue; + } + // Recover the original value for wildcard (named variable) portions + // of the path, since they may be case-sensitive data like a base64 + // encoded token. We create this positional mapping in the route compiler. + // @see \Drupal\Core\Routing\RouteCompiler::getMappedPathVariables() + $parts = preg_split('@/+@', $pathinfo, NULL, PREG_SPLIT_NO_EMPTY); + foreach ($compiledRoute->getPathVariables() as $position => $variable_name) { + if (isset($matches[$variable_name])) { + $matches[$variable_name] = $parts[$position]; + } + } + + $hostMatches = array(); + if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) { + continue; + } + + // Check the HTTP method requirement. + if ($requiredMethods = $route->getMethods()) { + // HEAD and GET are equivalent as per RFC. + $method = $this->context->getMethod(); + if ('HEAD' === $method) { + $method = 'GET'; + } + + if (!in_array($method, $requiredMethods)) { + $this->allow = array_merge($this->allow, $requiredMethods); + + continue; + } + } + + $status = $this->handleRouteRequirements($pathinfo, $name, $route); + + if (self::ROUTE_MATCH === $status[0]) { + return $status[1]; + } + + if (self::REQUIREMENT_MISMATCH === $status[0]) { + continue; + } + + return $this->getAttributes($route, $name, array_replace($matches, $hostMatches)); + } + } + } diff --git a/core/lib/Drupal/Core/Routing/routing.api.php b/core/lib/Drupal/Core/Routing/routing.api.php index 7c5e80a..f5a38e3 100644 --- a/core/lib/Drupal/Core/Routing/routing.api.php +++ b/core/lib/Drupal/Core/Routing/routing.api.php @@ -43,7 +43,7 @@ * by the machine name of the module that defines the route, or the name of * a subsystem. * - The 'path' line gives the URL path of the route (relative to the site's - * base URL). + * base URL). Paths are handled with case-insensitive matching. * - The 'defaults' section tells how to build the main content of the route, * and can also give other information, such as the page title and additional * arguments for the route controller method. There are several possibilities diff --git a/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php b/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php index 13b56d5..387aeaa 100644 --- a/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php +++ b/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php @@ -8,6 +8,7 @@ namespace Drupal\config_translation\Tests; use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\Unicode; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\simpletest\WebTestBase; @@ -96,7 +97,7 @@ public function testMapperListPage() { foreach ($labels as $label) { $test_entity = entity_create('config_test', array( - 'id' => $this->randomMachineName(), + 'id' => Unicode::strtolower($this->randomMachineName()), 'label' => $label, )); $test_entity->save(); diff --git a/core/modules/locale/src/Tests/LocalePathTest.php b/core/modules/locale/src/Tests/LocalePathTest.php index 820814e..b04d4e3 100644 --- a/core/modules/locale/src/Tests/LocalePathTest.php +++ b/core/modules/locale/src/Tests/LocalePathTest.php @@ -7,6 +7,7 @@ namespace Drupal\locale\Tests; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; use Drupal\simpletest\WebTestBase; @@ -72,7 +73,7 @@ public function testPathLanguageConfiguration() { // Create a path alias in default language (English). $path = 'admin/config/search/path/add'; - $english_path = $this->randomMachineName(8); + $english_path = Unicode::strtolower($this->randomMachineName(8)); $edit = array( 'source' => '/node/' . $node->id(), 'alias' => '/' . $english_path, @@ -81,7 +82,7 @@ public function testPathLanguageConfiguration() { $this->drupalPostForm($path, $edit, t('Save')); // Create a path alias in new custom language. - $custom_language_path = $this->randomMachineName(8); + $custom_language_path = Unicode::strtolower($this->randomMachineName(8)); $edit = array( 'source' => '/node/' . $node->id(), 'alias' => '/' . $custom_language_path, @@ -98,7 +99,7 @@ public function testPathLanguageConfiguration() { $this->assertText($node->label(), 'Custom language alias works.'); // Create a custom path. - $custom_path = $this->randomMachineName(8); + $custom_path = Unicode::strtolower($this->randomMachineName(8)); // Check priority of language for alias by source path. $edit = array( diff --git a/core/modules/path/path.module b/core/modules/path/path.module index 307a08b..dbb8d97 100644 --- a/core/modules/path/path.module +++ b/core/modules/path/path.module @@ -21,12 +21,13 @@ function path_help($route_name, RouteMatchInterface $route_match) { $output = ''; $output .= '

' . t('About') . '

'; $output .= '

' . t('The Path module allows you to specify an alias, or custom URL, for any existing internal system path. Aliases should not be confused with URL redirects, which allow you to forward a changed or inactive URL to a new URL. In addition to making URLs more readable, aliases also help search engines index content more effectively. Multiple aliases may be used for a single internal system path. To automate the aliasing of paths, you can install the contributed module Pathauto. For more information, see the online documentation for the Path module.', array(':path' => 'https://www.drupal.org/documentation/modules/path', ':pathauto' => 'https://www.drupal.org/project/pathauto')) . '

'; + $output .= '

' . t('Aliases must be unique, are converted to lowercase, and are matched in a case insensitive fashion.'); $output .= '

' . t('Uses') . '

'; $output .= '
'; $output .= '
' . t('Creating aliases') . '
'; - $output .= '
' . t('If you create or edit a taxonomy term you can add an alias (for example music/jazz) in the field "URL alias". When creating or editing content you can add an alias (for example about-us/team) under the section "URL path settings" in the field "URL alias". Aliases for any other path can be added through the page URL aliases. To add aliases a user needs the permission Create and edit URL aliases.', array(':aliases' => \Drupal::url('path.admin_overview'), ':permissions' => \Drupal::url('user.admin_permissions', array(), array('fragment' => 'module-path')))) . '
'; + $output .= '
' . t('If you create or edit a taxonomy term you can add a unique alias (for example /music/jazz) in the field "URL alias". When creating or editing content you can add an alias (for example /about-us/team) under the section "URL path settings" in the field "URL alias". Aliases for any other path can be added through the page URL aliases. To add aliases a user needs the permission Create and edit URL aliases.', array(':aliases' => \Drupal::url('path.admin_overview'), ':permissions' => \Drupal::url('user.admin_permissions', array(), array('fragment' => 'module-path')))) . '
'; $output .= '
' . t('Managing aliases') . '
'; - $output .= '
' . t('The Path module provides a way to search and view a list of all aliases that are in use on your website. Aliases can be added, edited and deleted through this list.', array(':aliases' => \Drupal::url('path.admin_overview'))) . '
'; + $output .= '
' . t('The Path module provides a way to search and view a list of all aliases that are in use on your website. Aliases need to be unique and are converted to lowercase. Aliases can be added, edited and deleted through this list.', array(':aliases' => \Drupal::url('path.admin_overview'))) . '
'; $output .= '
'; return $output; diff --git a/core/modules/path/src/Form/PathFormBase.php b/core/modules/path/src/Form/PathFormBase.php index 8c84841..e296b6c 100644 --- a/core/modules/path/src/Form/PathFormBase.php +++ b/core/modules/path/src/Form/PathFormBase.php @@ -116,7 +116,7 @@ public function buildForm(array $form, FormStateInterface $form_state, $pid = NU '#default_value' => $this->path['alias'], '#maxlength' => 255, '#size' => 45, - '#description' => $this->t('Specify an alternative path by which this data can be accessed. For example, type "/about" when writing an about page. Use a relative path with a slash in front..'), + '#description' => $this->t('Specify an alternative path by which this data can be accessed. For example, type "/about" when writing an about page. Use a relative path with a slash in front. The path alias will be converted to lowercase and must be unique.'), '#field_prefix' => $this->requestContext->getCompleteBaseUrl(), '#required' => TRUE, ); diff --git a/core/modules/path/src/Tests/PathAdminTest.php b/core/modules/path/src/Tests/PathAdminTest.php index 610b909..1c85415 100644 --- a/core/modules/path/src/Tests/PathAdminTest.php +++ b/core/modules/path/src/Tests/PathAdminTest.php @@ -7,6 +7,8 @@ namespace Drupal\path\Tests; +use Drupal\Component\Utility\Unicode; + /** * Tests the Path admin UI. * @@ -39,21 +41,21 @@ public function testPathFiltering() { $node3 = $this->drupalCreateNode(); // Create aliases. - $alias1 = '/' . $this->randomMachineName(8); + $alias1 = '/' . Unicode::strtolower($this->randomMachineName(8)); $edit = array( 'source' => '/node/' . $node1->id(), 'alias' => $alias1, ); $this->drupalPostForm('admin/config/search/path/add', $edit, t('Save')); - $alias2 = '/' . $this->randomMachineName(8); + $alias2 = '/' . Unicode::strtolower($this->randomMachineName(8)); $edit = array( 'source' => '/node/' . $node2->id(), 'alias' => $alias2, ); $this->drupalPostForm('admin/config/search/path/add', $edit, t('Save')); - $alias3 = '/' . $this->randomMachineName(4) . '/' . $this->randomMachineName(4); + $alias3 = '/' . Unicode::strtolower($this->randomMachineName(4)) . '/' . Unicode::strtolower($this->randomMachineName(4)); $edit = array( 'source' => '/node/' . $node3->id(), 'alias' => $alias3, diff --git a/core/modules/path/src/Tests/PathAliasTest.php b/core/modules/path/src/Tests/PathAliasTest.php index 651c11f..063e988 100644 --- a/core/modules/path/src/Tests/PathAliasTest.php +++ b/core/modules/path/src/Tests/PathAliasTest.php @@ -7,6 +7,7 @@ namespace Drupal\path\Tests; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Cache\Cache; /** @@ -82,6 +83,13 @@ function testAdminAlias() { $this->drupalGet($edit['alias']); $this->assertText($node1->label(), 'Alias works.'); $this->assertResponse(200); + // Confirm that the alias works in a case-insensitive way. + $this->drupalGet(Unicode::strtolower($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']); diff --git a/core/modules/simpletest/src/AssertContentTrait.php b/core/modules/simpletest/src/AssertContentTrait.php index 3d27687..f143de6 100644 --- a/core/modules/simpletest/src/AssertContentTrait.php +++ b/core/modules/simpletest/src/AssertContentTrait.php @@ -10,6 +10,7 @@ use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\Html; use Drupal\Component\Utility\SafeMarkup; +use Drupal\Component\Utility\Unicode; use Drupal\Component\Utility\Xss; use Drupal\Core\Render\RenderContext; use Symfony\Component\CssSelector\CssSelector; @@ -359,7 +360,7 @@ protected function assertNoLink($label, $message = '', $group = 'Other') { * TRUE if the assertion succeeded, FALSE otherwise. */ protected function assertLinkByHref($href, $index = 0, $message = '', $group = 'Other') { - $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => $href)); + $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => Unicode::strtolower($href))); $message = ($message ? $message : SafeMarkup::format('Link containing href %href found.', array('%href' => $href))); return $this->assert(isset($links[$index]), $message, $group); } @@ -384,7 +385,7 @@ protected function assertLinkByHref($href, $index = 0, $message = '', $group = ' * TRUE if the assertion succeeded, FALSE otherwise. */ protected function assertNoLinkByHref($href, $message = '', $group = 'Other') { - $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => $href)); + $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => Unicode::strtolower($href))); $message = ($message ? $message : SafeMarkup::format('No link containing href %href found.', array('%href' => $href))); return $this->assert(empty($links), $message, $group); } diff --git a/core/modules/system/src/Tests/Menu/MenuRouterTest.php b/core/modules/system/src/Tests/Menu/MenuRouterTest.php index 42332e0..deefc99 100644 --- a/core/modules/system/src/Tests/Menu/MenuRouterTest.php +++ b/core/modules/system/src/Tests/Menu/MenuRouterTest.php @@ -7,6 +7,7 @@ namespace Drupal\system\Tests\Menu; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Url; use Drupal\simpletest\WebTestBase; @@ -66,7 +67,7 @@ public function testMenuIntegration() { */ protected function doTestHookMenuIntegration() { // Generate base path with random argument. - $machine_name = $this->randomMachineName(8); + $machine_name = Unicode::strtolower($this->randomMachineName(8)); $base_path = 'foo/' . $machine_name; $this->drupalGet($base_path); // Confirm correct controller activated. diff --git a/core/modules/system/src/Tests/Path/AliasTest.php b/core/modules/system/src/Tests/Path/AliasTest.php index 2ceb4c9..b156921 100644 --- a/core/modules/system/src/Tests/Path/AliasTest.php +++ b/core/modules/system/src/Tests/Path/AliasTest.php @@ -7,6 +7,7 @@ namespace Drupal\system\Tests\Path; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Cache\MemoryCounterBackend; use Drupal\Core\Path\AliasStorage; use Drupal\Core\Database\Database; @@ -107,6 +108,7 @@ function testLookupPath() { $aliasStorage->save($path['source'], $path['alias'], $path['langcode']); // Hook that clears cache is not executed with unit tests. \Drupal::service('path.alias_manager')->cacheClear(); + $this->assertEqual($aliasManager->getAliasByPath($path['source']), $path['alias'], 'English alias overrides language-neutral alias.'); $this->assertEqual($aliasManager->getPathByAlias($path['alias']), $path['source'], 'English source overrides language-neutral source.'); diff --git a/core/modules/system/src/Tests/Routing/RouterTest.php b/core/modules/system/src/Tests/Routing/RouterTest.php index 076d0ce..da8a956 100644 --- a/core/modules/system/src/Tests/Routing/RouterTest.php +++ b/core/modules/system/src/Tests/Routing/RouterTest.php @@ -212,6 +212,71 @@ public function testRouterMatching() { } /** + * Tests the case insensitivity of route paths and path variable handling. + * + * The un-routed URLs are constructed using base: so that we are sure they are + * used as written without being processed by the routing system. + */ + public function testRoutePathMixedCase() { + $this->drupalGet(Url::fromUri('base:router_test/TEST14/1')); + $this->assertResponse(200); + $this->assertText('User route "entity.user.canonical" was matched.'); + + /** @var \Drupal\Core\Routing\RouteProvider $route_provider */ + $route_provider = $this->container->get('router.route_provider'); + $route2 = $route_provider->getRouteByName('router_test.mixedcase.2'); + // Verify that the route path is stored as lowercase despite being defined + // with mixed case in the YAML file. + $this->assertIdentical('/router_test/mixedcase2', $route2->getPath()); + $this->drupalGet(Url::fromUri('base:router_test/mixEDCASE2')); + $this->assertResponse(200); + $this->assertRaw('test2', 'The expected controller output was found.'); + + $this->drupalGet(Url::fromUri('base:router_TEST/MIXEDcase2')); + $this->assertResponse(200); + $this->assertRaw('test2', 'The expected controller output was found.'); + + $this->drupalGet(Url::fromUri('base:router_test/mixedcase2')); + $this->assertResponse(200); + $this->assertRaw('test2', 'The expected controller output was found.'); + + $route3 = $route_provider->getRouteByName('router_test.mixedcase.3'); + // Verify that the variable name in the route path is stored as lowercase + // despite being defined with mixed case in the YAML file. + $this->assertIdentical('/router_test/mixedcase3/{value}', $route3->getPath()); + + // Verify that data in variable path parts is retained as the original, + // mixed-case value. Matches route path /router_test/mixedcase3/{value} + $this->drupalGet(Url::fromUri('base:router_test/mixedcase3/mixedCASEstring')); + $this->assertResponse(200); + $this->assertRaw('mixedCASEstring', 'The variable string was output with unmodified case.'); + + $this->drupalGet(Url::fromUri('base:router_test/mixedcase3/mixedcasestring')); + $this->assertResponse(200); + $this->assertRaw('mixedcasestring', 'The variable string was output with unmodified case.'); + + $this->drupalGet(Url::fromUri('base:router_test/mixedcase3/MIXEDCASESTRING')); + $this->assertResponse(200); + $this->assertRaw('MIXEDCASESTRING', 'The variable string was output with unmodified case.'); + // Test routes added by \Drupal\router_test\RouteTestSubscriber. + // Check that a dynamically added route has path changed to lower-case. + $route4 = $route_provider->getRouteByName('router_test.mixedcase.4'); + $this->assertIdentical('/router_test/mixedcase4', $route4->getPath()); + $this->drupalGet(Url::fromUri('base:router_TEST/MixedCASE4')); + $this->assertResponse(200); + // Check that a route added dynamically and altered has path lower-cased. + $route5 = $route_provider->getRouteByName('router_test.mixedcase.5'); + $this->assertIdentical('/router_test/mixedcase5/altered', $route5->getPath()); + $this->drupalGet(Url::fromUri('base:router_test/MixedCASE5/altereD')); + $this->assertResponse(200); + // Check that a route added during the alter event has path lower-cased. + $route6 = $route_provider->getRouteByName('router_test.mixedcase.6'); + $this->assertIdentical('/router_test/mixedcase6', $route6->getPath()); + $this->drupalGet(Url::fromUri('base:router_test/MixedCASE6')); + $this->assertResponse(200); + } + + /** * Tests that a PSR-7 response works. */ public function testRouterResponsePsr7() { diff --git a/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml b/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml index e0c91dd..7bf05b7 100644 --- a/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml +++ b/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml @@ -189,3 +189,17 @@ router_test.hierarchy_parent_child2: _controller: '\Drupal\router_test\TestControllers::test' requirements: _access: 'TRUE' + +router_test.mixedcase.2: + path: '/router_test/mixedCASE2' + defaults: + _controller: '\Drupal\router_test\TestControllers::test2' + requirements: + _access: 'TRUE' + +router_test.mixedcase.3: + path: '/router_test/mixedcase3/{ValUe}' + defaults: + _controller: '\Drupal\router_test\TestControllers::test3' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/router_test_directory/src/RouteTestSubscriber.php b/core/modules/system/tests/modules/router_test_directory/src/RouteTestSubscriber.php index 3bc703b..72b824b 100644 --- a/core/modules/system/tests/modules/router_test_directory/src/RouteTestSubscriber.php +++ b/core/modules/system/tests/modules/router_test_directory/src/RouteTestSubscriber.php @@ -7,21 +7,82 @@ namespace Drupal\router_test; -use Drupal\Core\Routing\RouteSubscriberBase; -use Symfony\Component\Routing\RouteCollection; +use Drupal\Core\Routing\RouteBuildEvent; +use Drupal\Core\Routing\RoutingEvents; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Routing\Route; /** * Listens to the dynamic route event and add a test route. */ -class RouteTestSubscriber extends RouteSubscriberBase { +class RouteTestSubscriber implements EventSubscriberInterface { /** * {@inheritdoc} */ - protected function alterRoutes(RouteCollection $collection) { + public static function getSubscribedEvents() { + $events[RoutingEvents::DYNAMIC] = 'onDynamicRoutes'; + $events[RoutingEvents::ALTER] = 'onAlterRoutes'; + return $events; + } + + /** + * Adds dynamic routes for a specific collection from an event. + * + * @param \Drupal\Core\Routing\RouteBuildEvent $event + * The route build event. + */ + public function onDynamicRoutes(RouteBuildEvent $event) { + $collection = $event->getRouteCollection(); + $route = new Route( + '/router_test/mixedCASE4', + [ + '_controller' => '\Drupal\router_test\TestControllers::test2', + ], + [ + '_access' => 'TRUE', + ] + ); + $collection->add("router_test.mixedcase.4", $route); + $route = new Route( + '/router_test/tobealtered', + [ + '_controller' => '\Drupal\router_test\TestControllers::test2', + ], + [ + '_access' => 'TRUE', + ] + ); + $collection->add("router_test.mixedcase.5", $route); + } + + /** + * Alters existing routes for a specific collection from an event. + * + * @param \Drupal\Core\Routing\RouteBuildEvent $event + * The route build event. + */ + public function onAlterRoutes(RouteBuildEvent $event) { + $collection = $event->getRouteCollection(); $route = $collection->get('router_test.6'); // Change controller method from test1 to test5. $route->setDefault('_controller', '\Drupal\router_test\TestControllers::test5'); + // Change a route path from lower case to mixed case. + $route = $collection->get('router_test.mixedcase.5'); + $route->setPath('/router_tesT/MIXedcase5/ALTERED'); + // We can also add routes in the alter, which happens in classes like + // \Drupal\content_translation\Routing\ContentTranslationRouteSubscriber + // that need to add routes based on other routes that are added dynamically. + $route = new Route( + '/router_test/mixedCASE6', + [ + '_controller' => '\Drupal\router_test\TestControllers::test2', + ], + [ + '_access' => 'TRUE', + ] + ); + $collection->add("router_test.mixedcase.6", $route); } } diff --git a/core/modules/views/src/Plugin/views/display/PathPluginBase.php b/core/modules/views/src/Plugin/views/display/PathPluginBase.php index 599e7f0..0fe53ad 100644 --- a/core/modules/views/src/Plugin/views/display/PathPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/PathPluginBase.php @@ -245,7 +245,9 @@ public function alterRoutes(RouteCollection $collection) { // Ensure that we don't override a route which is already controlled by // views. if (!$route->hasDefault('view_id') && ('/' . $view_path == $route_path)) { - $parameters = $route->compile()->getPathVariables(); + // The path variables array may have arbitrary keys, so take just the + // values to ensure we have a simple numeric array starting from 0. + $parameters = array_values($route->compile()->getPathVariables()); // @todo Figure out whether we need to merge some settings (like // requirements). diff --git a/core/modules/views/src/Tests/Wizard/BasicTest.php b/core/modules/views/src/Tests/Wizard/BasicTest.php index 8a64df8..4216594 100644 --- a/core/modules/views/src/Tests/Wizard/BasicTest.php +++ b/core/modules/views/src/Tests/Wizard/BasicTest.php @@ -9,6 +9,7 @@ use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\SafeMarkup; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Url; use Drupal\views\Views; @@ -66,9 +67,9 @@ function testViewsWizardAndListing() { $view2['description'] = $this->randomMachineName(16); $view2['page[create]'] = 1; $view2['page[title]'] = $this->randomMachineName(16); - $view2['page[path]'] = $this->randomMachineName(16); + $view2['page[path]'] = Unicode::strtolower($this->randomMachineName(16)); $view2['page[feed]'] = 1; - $view2['page[feed_properties][path]'] = $this->randomMachineName(16); + $view2['page[feed_properties][path]'] = Unicode::strtolower($this->randomMachineName(16)); $this->drupalPostForm('admin/structure/views/add', $view2, t('Save and edit')); $this->drupalGet($view2['page[path]']); $this->assertResponse(200); @@ -115,7 +116,7 @@ function testViewsWizardAndListing() { $view3['show[type]'] = 'page'; $view3['page[create]'] = 1; $view3['page[title]'] = $this->randomMachineName(16); - $view3['page[path]'] = $this->randomMachineName(16); + $view3['page[path]'] = Unicode::strtolower($this->randomMachineName(16)); $view3['block[create]'] = 1; $view3['block[title]'] = $this->randomMachineName(16); $this->drupalPostForm('admin/structure/views/add', $view3, t('Save and edit')); diff --git a/core/modules/views/src/Tests/Wizard/MenuTest.php b/core/modules/views/src/Tests/Wizard/MenuTest.php index af6a98d..ffae5bc 100644 --- a/core/modules/views/src/Tests/Wizard/MenuTest.php +++ b/core/modules/views/src/Tests/Wizard/MenuTest.php @@ -8,6 +8,7 @@ namespace Drupal\views\Tests\Wizard; use Drupal\Component\Utility\SafeMarkup; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Url; /** @@ -30,7 +31,7 @@ function testMenus() { $view['description'] = $this->randomMachineName(16); $view['page[create]'] = 1; $view['page[title]'] = $this->randomMachineName(16); - $view['page[path]'] = $this->randomMachineName(16); + $view['page[path]'] = Unicode::strtolower($this->randomMachineName(16)); $view['page[link]'] = 1; $view['page[link_properties][menu_name]'] = 'main'; $view['page[link_properties][title]'] = $this->randomMachineName(16);