diff --git a/core/lib/Drupal/Core/Routing/CompiledRoute.php b/core/lib/Drupal/Core/Routing/CompiledRoute.php index 775d4ae470..76d6ef16eb 100644 --- a/core/lib/Drupal/Core/Routing/CompiledRoute.php +++ b/core/lib/Drupal/Core/Routing/CompiledRoute.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Routing; +use Drupal\Component\Utility\Unicode; use Symfony\Component\Routing\CompiledRoute as SymfonyCompiledRoute; /** @@ -65,7 +66,10 @@ public function __construct($fit, $pattern_outline, $num_parts, $staticPrefix, $ parent::__construct($staticPrefix, $regex, $tokens, $pathVariables, $hostRegex, $hostTokens, $hostVariables, $variables); $this->fit = $fit; - $this->patternOutline = $pattern_outline; + // Support case-insensitive route matching by ensuring the pattern outline + // is lowercase. + // @see \Drupal\Core\Routing\RouteProvider::getRoutesByPath() + $this->patternOutline = Unicode::strtolower($pattern_outline); $this->numParts = $num_parts; } diff --git a/core/lib/Drupal/Core/Routing/RouteCompiler.php b/core/lib/Drupal/Core/Routing/RouteCompiler.php index 52d0041ce6..e4aef43980 100644 --- a/core/lib/Drupal/Core/Routing/RouteCompiler.php +++ b/core/lib/Drupal/Core/Routing/RouteCompiler.php @@ -2,7 +2,6 @@ namespace Drupal\Core\Routing; -use Drupal\Component\Utility\Unicode; use Symfony\Component\Routing\RouteCompilerInterface; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCompiler as SymfonyRouteCompiler; @@ -38,8 +37,7 @@ public static function compile(Route $route) { // The Drupal-specific compiled information. $stripped_path = static::getPathWithoutDefaults($route); $fit = static::getFit($stripped_path); - // Store a lower-case pattern outline to enable case-insensitive matching. - $pattern_outline = Unicode::strtolower(static::getPatternOutline($stripped_path)); + $pattern_outline = static::getPatternOutline($stripped_path); // We count the number of parts including any optional trailing parts. This // allows the RouteProvider to filter candidate routes more efficiently. $num_parts = count(explode('/', trim($route->getPath(), '/'))); @@ -54,10 +52,9 @@ public static function compile(Route $route) { // Set the static prefix to an empty string since it is redundant to // the matching in \Drupal\Core\Routing\RouteProvider::getRoutesByPath() - // and by skipping it we more easily make the routing case insensitive. + // and by skipping it we more easily make the routing case-insensitive. '', - // Set the regex to use UTF-8 and be case-insensitive. - $symfony_compiled->getRegex() . 'ui', + $symfony_compiled->getRegex(), $symfony_compiled->getTokens(), $symfony_compiled->getPathVariables(), $symfony_compiled->getHostRegex(), diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php index 4586652358..f16aa9f0a5 100644 --- a/core/lib/Drupal/Core/Routing/RouteProvider.php +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php @@ -2,10 +2,10 @@ namespace Drupal\Core\Routing; +use Drupal\Component\Utility\Unicode; 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; @@ -330,8 +330,9 @@ 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. Convert to lower case here so we can - // have a case insensitive match from the incoming path to the lower case + // have a case-insensitive match from the incoming path to the lower case // pattern outlines from \Drupal\Core\Routing\RouteCompiler::compile(). + // @see \Drupal\Core\Routing\CompiledRoute::__construct() $parts = preg_split('@/+@', Unicode::strtolower($path), NULL, PREG_SPLIT_NO_EMPTY); $collection = new RouteCollection(); diff --git a/core/lib/Drupal/Core/Routing/Router.php b/core/lib/Drupal/Core/Routing/Router.php index 80a31f7957..f2ff54f381 100644 --- a/core/lib/Drupal/Core/Routing/Router.php +++ b/core/lib/Drupal/Core/Routing/Router.php @@ -160,6 +160,106 @@ public function matchRequest(Request $request) { } /** + * Tries to match a URL with a set of routes. + * + * @param string $pathinfo + * The path info to be parsed + * @param \Symfony\Component\Routing\RouteCollection $routes + * The set of routes. + * + * @return array + * An array of 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) { + // Try a case-sensitive match. + $match = $this->doMatchCollection($pathinfo, $routes, TRUE); + // Try a case-insensitive match. + if ($match === NULL && $routes->count() > 0) { + $match = $this->doMatchCollection($pathinfo, $routes, FALSE); + } + return $match; + } + + /** + * Tries to match a URL with a set of routes. + * + * This code is very similar to Symfony's UrlMatcher::matchCollection() but it + * supports case-insensitive matching. The static prefix optimization is + * removed as this duplicates work done by the query in + * RouteProvider::getRoutesByPath(). + * + * @param string $pathinfo + * The path info to be parsed + * @param \Symfony\Component\Routing\RouteCollection $routes + * The set of routes. + * @param bool $case_sensitive + * Determines if the match should be case-sensitive of not. + * + * @return array|null + * An array of parameters. NULL when there is no match. + * + * @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. + * + * @see \Symfony\Component\Routing\Matcher\UrlMatcher::matchCollection() + * @see \Drupal\Core\Routing\RouteProvider::getRoutesByPath() + */ + protected function doMatchCollection($pathinfo, RouteCollection $routes, $case_sensitive) { + foreach ($routes as $name => $route) { + $compiledRoute = $route->compile(); + + // Set the regex to use UTF-8. + $regex = $compiledRoute->getRegex() . 'u'; + if (!$case_sensitive) { + $regex = $regex . 'i'; + } + if (!preg_match($regex, $pathinfo, $matches)) { + continue; + } + + $hostMatches = array(); + if ($compiledRoute->getHostRegex() && !preg_match($compiledRoute->getHostRegex(), $this->context->getHost(), $hostMatches)) { + $routes->remove($name); + continue; + } + + // Check HTTP method requirement. + if ($requiredMethods = $route->getMethods()) { + // HEAD and GET are equivalent as per RFC. + if ('HEAD' === $method = $this->context->getMethod()) { + $method = 'GET'; + } + + if (!in_array($method, $requiredMethods)) { + $this->allow = array_merge($this->allow, $requiredMethods); + $routes->remove($name); + continue; + } + } + + $status = $this->handleRouteRequirements($pathinfo, $name, $route); + + if (self::ROUTE_MATCH === $status[0]) { + return $status[1]; + } + + if (self::REQUIREMENT_MISMATCH === $status[0]) { + $routes->remove($name); + continue; + } + + return $this->getAttributes($route, $name, array_replace($matches, $hostMatches)); + } + } + + /** * Returns a collection of potential matching routes for a request. * * @param \Symfony\Component\HttpFoundation\Request $request diff --git a/core/lib/Drupal/Core/Routing/routing.api.php b/core/lib/Drupal/Core/Routing/routing.api.php index 6f47520609..c127b2a394 100644 --- a/core/lib/Drupal/Core/Routing/routing.api.php +++ b/core/lib/Drupal/Core/Routing/routing.api.php @@ -43,10 +43,16 @@ * 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). Note: The path in Drupal is treated case insensitive so - * /example and /EXAmplE should return the same page. - * @todo Fix https://www.drupal.org/node/2075889 to actually get this - * behaviour. + * base URL). Generally, paths in Drupal are treated as case-insensitive, + * which overrides the default Symfony behavior. Specifically: + * - If different routes are defined for /example and /EXAmplE, the exact + * match is respected. + * - If there is no exact match, the route falls back to a case-insensitive + * match, so /example and /EXAmplE will return the same page. + * Relying on case-sensitive path matching is not recommended because it + * negatively affects user experience, and path aliases do not support case- + * sensitive matches. The case-sensitive exact match is currently supported + * only for backwards compatility and may be deprecated in a later release. * - 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/system/src/Tests/Routing/RouterTest.php b/core/modules/system/src/Tests/Routing/RouterTest.php index db8a8af905..8ff515436d 100644 --- a/core/modules/system/src/Tests/Routing/RouterTest.php +++ b/core/modules/system/src/Tests/Routing/RouterTest.php @@ -113,14 +113,19 @@ public function testDuplicateRoutePaths() { $this->assertRaw('router_test.two_duplicate1'); // Tests three routes with same the path. One of the routes the path has a - // different case. The route with the maximum fit and lowest sorting route - // name will match, regardless of the order the routes are declared. - $this->drupalGet('router-test/duplicate-path3'); + // different case. + $this->drupalGet('router-test/case-sensitive-duplicate-path3'); $this->assertResponse(200); - $this->assertRaw('router_test.three_duplicate1'); - $this->drupalGet('router-test/Duplicate-PATH3'); + $this->assertRaw('router_test.case_sensitive_duplicate1'); + // While case-insensitive matching works, exact matches are preferred. + $this->drupalGet('router-test/case-sensitive-Duplicate-PATH3'); $this->assertResponse(200); - $this->assertRaw('router_test.three_duplicate1'); + $this->assertRaw('router_test.case_sensitive_duplicate2'); + // Test that case-insensitive matching works, falling back to the first + // route defined. + $this->drupalGet('router-test/case-sensitive-Duplicate-Path3'); + $this->assertResponse(200); + $this->assertRaw('router_test.case_sensitive_duplicate1'); } /** 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 b9a0958d02..4d5d241baa 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 @@ -227,22 +227,22 @@ router_test.two_duplicate2: requirements: _access: 'TRUE' -router_test.three_duplicate2: - path: '/router-test/duplicate-path3' +router_test.case_sensitive_duplicate1: + path: '/router-test/case-sensitive-duplicate-path3' defaults: _controller: '\Drupal\router_test\TestControllers::testRouteName' requirements: _access: 'TRUE' -router_test.three_duplicate3: - path: '/router-test/Duplicate-PATH3' +router_test.case_sensitive_duplicate2: + path: '/router-test/case-sensitive-Duplicate-PATH3' defaults: _controller: '\Drupal\router_test\TestControllers::testRouteName' requirements: _access: 'TRUE' -router_test.three_duplicate1: - path: '/router-test/duplicate-path3' +router_test.case_sensitive_duplicate3: + path: '/router-test/case-sensitive-duplicate-path3' defaults: _controller: '\Drupal\router_test\TestControllers::testRouteName' requirements: diff --git a/core/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php b/core/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php index 183d140728..3aaac143ea 100644 --- a/core/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php +++ b/core/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php @@ -70,7 +70,7 @@ public function testMixedCasePaths() { $this->assertSession()->linkExists('FooBarBaz'); $this->assertSession()->linkByHrefExists($node->toUrl()->toString()); - // Make sure the path is case insensitive, and query case is preserved. + // Make sure the path is case-insensitive, and query case is preserved. $this->drupalGet('Admin/Content', [ 'query' => [ diff --git a/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php b/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php index f5c8047e2d..524587089f 100644 --- a/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php +++ b/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php @@ -216,7 +216,7 @@ public function providerMixedCaseRoutePaths() { } /** - * Confirms that we find routes using a case insensitive path match. + * Confirms that we find routes using a case-insensitive path match. * * @dataProvider providerMixedCaseRoutePaths */