diff --git a/core/lib/Drupal/Core/Path/AliasStorage.php b/core/lib/Drupal/Core/Path/AliasStorage.php index 899c39e..b9f65fe 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; @@ -59,7 +60,7 @@ public function save($source, $alias, $langcode = LanguageInterface::LANGCODE_NO $fields = array( 'source' => $source, - 'alias' => $alias, + 'alias' => Unicode::strtolower($alias), 'langcode' => $langcode, ); @@ -98,6 +99,9 @@ public function save($source, $alias, $langcode = LanguageInterface::LANGCODE_NO public function load($conditions) { $select = $this->connection->select('url_alias'); foreach ($conditions as $field => $value) { + if ($field == 'alias') { + $value = Unicode::strtolower($value); + } $select->condition($field, $value); } return $select @@ -115,6 +119,9 @@ public function delete($conditions) { $path = $this->load($conditions); $query = $this->connection->delete('url_alias'); foreach ($conditions as $field => $value) { + if ($field == 'alias') { + $value = Unicode::strtolower($value); + } $query->condition($field, $value); } $deleted = $query->execute(); @@ -184,7 +191,7 @@ public function lookupPathAlias($path, $langcode) { */ public function lookupPathSource($path, $langcode) { $args = array( - ':alias' => $path, + ':alias' => Unicode::strtolower($path), ':langcode' => $langcode, ':langcode_undetermined' => LanguageInterface::LANGCODE_NOT_SPECIFIED, ); @@ -208,7 +215,7 @@ public function lookupPathSource($path, $langcode) { */ public function aliasExists($alias, $langcode, $source = NULL) { $query = $this->connection->select('url_alias') - ->condition('alias', $alias) + ->condition('alias', Unicode::strtolower($alias)) ->condition('langcode', $langcode); if (!empty($source)) { $query->condition('source', $source, '<>'); @@ -234,7 +241,7 @@ public function getAliasesForAdminListing($header, $keys = NULL) { ->extend('Drupal\Core\Database\Query\TableSortExtender'); if ($keys) { // Replace wildcards with PDO wildcards. - $query->condition('alias', '%' . preg_replace('!\*+!', '%', $keys) . '%', 'LIKE'); + $query->condition('alias', '%' . preg_replace('!\*+!', '%', Unicode::strtolower($keys)) . '%', 'LIKE'); } return $query ->fields('url_alias') diff --git a/core/lib/Drupal/Core/Path/AliasStorageInterface.php b/core/lib/Drupal/Core/Path/AliasStorageInterface.php index 5ac77a3..598f986 100644 --- a/core/lib/Drupal/Core/Path/AliasStorageInterface.php +++ b/core/lib/Drupal/Core/Path/AliasStorageInterface.php @@ -20,7 +20,7 @@ * @param string $source * The internal system path. * @param string $alias - * The URL alias. + * The URL alias. This will be converted to lower case. * @param string $langcode * (optional) The language code of the alias. * @param int|null $pid @@ -97,7 +97,8 @@ public function lookupPathAlias($path, $langcode); * Returns Drupal system URL of an alias. * * @param string $path - * The path to investigate for corresponding system URLs. + * The path to investigate for corresponding system URLs. This will be + * converted to lower case. * @param string $langcode * Language code to search the path with. If there's no path defined for * that language it will search paths without language. @@ -111,7 +112,7 @@ public function lookupPathSource($path, $langcode); * Checks if alias already exists. * * @param string $alias - * Alias to check against. + * Alias to check against. This will be converted to lower case. * @param string $langcode * Language of the alias. * @param string|null $source @@ -135,8 +136,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. This will be converted to lower case. * * @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..6f3d84d 100644 --- a/core/lib/Drupal/Core/Routing/CompiledRoute.php +++ b/core/lib/Drupal/Core/Routing/CompiledRoute.php @@ -36,6 +36,13 @@ class CompiledRoute extends SymfonyCompiledRoute { protected $numParts; /** + * The mapping of numeric path parts to path variable names. + * + * @var array + */ + protected $mappedPathVariables; + + /** * Constructs a new compiled route object. * * This is a ridiculously long set of constructor parameters, but as this @@ -56,7 +63,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 @@ -72,6 +80,7 @@ public function __construct($fit, $pattern_outline, $num_parts, $staticPrefix, $ $this->fit = $fit; $this->patternOutline = $pattern_outline; $this->numParts = $num_parts; + $this->mappedPathVariables = $pathVariables; } /** @@ -143,6 +152,19 @@ public function getRequirements() { } /** + * Returns the path variables. + * + * This differs from the parent method in that the numeric array keys + * correspond to a matching path part containing that variable. + * + * @return array + * The path variable names keyed by numeric path part. + */ + public function getPathVariables() { + return $this->mappedPathVariables; + } + + /** * {@inheritdoc} */ public function serialize() { @@ -166,6 +188,7 @@ public function unserialize($serialized) { $this->fit = $data['fit']; $this->patternOutline = $data['patternOutline']; $this->numParts = $data['numParts']; + $this->mappedPathVariables = $data['path_vars']; } diff --git a/core/lib/Drupal/Core/Routing/RouteBuilder.php b/core/lib/Drupal/Core/Routing/RouteBuilder.php index a78cf0c..5d51a85 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 lower 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() { } /** + * Lower cases 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 lower case. + $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..86e7ffb 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,34 @@ 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. + * + * @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..63fa818 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 lower case 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 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. @@ -319,7 +322,8 @@ public function getRoutesByPattern($pattern) { * Get all routes which match a certain pattern. * * @param string $path - * The route pattern to search for (contains % as placeholders). + * The route pattern to search for (contains % as placeholders). Will be + * converted to lower case. * * @return \Symfony\Component\Routing\RouteCollection * Returns a route collection of matching routes. @@ -327,7 +331,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..74c46a5 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,82 @@ 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 lower case. 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 lower case, so that we match the patterns from + // routes where we forced all paths to be lower case. + // @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. + $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..65fbf69 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 lower case, and are matched in a case insensitive fashion.'); $output .= '