diff --git a/core/lib/Drupal/Core/Routing/RouteCompiler.php b/core/lib/Drupal/Core/Routing/RouteCompiler.php index 937bf48..00b426c 100644 --- a/core/lib/Drupal/Core/Routing/RouteCompiler.php +++ b/core/lib/Drupal/Core/Routing/RouteCompiler.php @@ -2,6 +2,7 @@ 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; @@ -32,7 +33,16 @@ class RouteCompiler extends SymfonyRouteCompiler implements RouteCompilerInterfa */ public static function compile(Route $route) { - $symfony_compiled = parent::compile($route); + // Split the path up on the slashes, ignoring multiple slashes in a row + // or leading or trailing slashes. + $parts = preg_split('@/+@', Unicode::strtolower($route->getPath()), NULL, PREG_SPLIT_NO_EMPTY); + // This seemingly round-about approach accounts for the fact that Drupal + // may be installed on PHP without multibyte support, so we need to be + // consistent and transform paths parts using Unicode::strtolower() to + // perform any matching. + $lowered_path_route = clone($route); + $lowered_path_route->setPath('/' . implode('/', $parts)); + $symfony_compiled = parent::compile($lowered_path_route); // The Drupal-specific compiled information. $stripped_path = static::getPathWithoutDefaults($route); @@ -40,7 +50,7 @@ public static function compile(Route $route) { $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(), '/'))); + $num_parts = count($parts); return new CompiledRoute( $fit, @@ -50,7 +60,7 @@ public static function compile(Route $route) { $symfony_compiled->getStaticPrefix(), $symfony_compiled->getRegex(), $symfony_compiled->getTokens(), - $symfony_compiled->getPathVariables(), + static::getMappedPathVariables($parts, $symfony_compiled->getPathVariables()), $symfony_compiled->getHostRegex(), $symfony_compiled->getHostTokens(), $symfony_compiled->getHostVariables(), @@ -59,10 +69,38 @@ 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 array $parts + * The path parts 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($parts, array $variables) { + $map = []; + 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 - * placeholders are equal strings and default values are removed. + * placeholders are equal strings and default values are removed, and the path + * is converted to lowercase. * * @param string $path * The path for which we want the normalized outline. @@ -71,7 +109,7 @@ public static function compile(Route $route) { * The path pattern outline. */ public static function getPatternOutline($path) { - return preg_replace('#\{\w+\}#', '%', $path); + return Unicode::strtolower(preg_replace('#\{\w+\}#', '%', $path)); } /** diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php index 53ed626..d65e1ce 100644 --- a/core/lib/Drupal/Core/Routing/RouteProvider.php +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php @@ -5,6 +5,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; @@ -143,7 +144,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); @@ -151,14 +154,14 @@ 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. $query_parameters = $request->query->all(); - $routes = $this->getRoutesByPath(rtrim($path, '/')); + $routes = $this->getRoutesByPath($path); $cache_value = [ 'path' => $path, 'query' => $query_parameters, @@ -317,15 +320,17 @@ 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. * * @return \Symfony\Component\Routing\RouteCollection * Returns a route collection of matching routes. */ 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); + // or leading or trailing slashes. Convert to lower case here so we can + // have a case insensitive match from the incoming path to the pattern + // outlines from \Drupal\Core\Routing\RouteCompiler::getPatternOutline() + $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 9056903..ad1ff00 100644 --- a/core/lib/Drupal/Core/Routing/UrlMatcher.php +++ b/core/lib/Drupal/Core/Routing/UrlMatcher.php @@ -2,6 +2,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; @@ -41,4 +42,85 @@ 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) { + // Convert the path to lowercase, so that we match the patterns from + // routes where we forced all paths to be lowercase. Also remove any + // repeated slashes so the pattern and path are consistent. + // @see \Drupal\Core\Routing\RouteCompiler::compile() + $lowered_parts = preg_split('@/+@', Unicode::strtolower($pathinfo), NULL, PREG_SPLIT_NO_EMPTY); + $lowered_pathinfo = '/' . implode('/', $lowered_parts); + foreach ($routes as $name => $route) { + /** @var \Symfony\Component\Routing\Route $route */ + $compiledRoute = $route->compile(); + + if (!preg_match($compiledRoute->getRegex(), $lowered_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/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php b/core/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php new file mode 100644 index 0000000..83c58b5 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Routing/CaseInsensitivePathTest.php @@ -0,0 +1,36 @@ +drupalGet('user/login'); + $this->assertEquals($this->getSession()->getStatusCode(), 200); + $this->drupalGet('User/Login'); + $this->assertEquals($this->getSession()->getStatusCode(), 200); + } + + public function testViewsPath() { + $admin = $this->drupalCreateUser(['access administration pages', 'administer nodes', 'access content overview']); + $this->drupalLogin($admin); + + $this->drupalGet('admin/content'); + $this->assertEquals($this->getSession()->getStatusCode(), 200); + $this->drupalGet('Admin/Content'); + $this->assertEquals($this->getSession()->getStatusCode(), 200); + } + +}