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('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 .= '