If I set for example a views page with "my-view" as path, the page can also be accessed with "My-view" (or even "mY-ViEw",...). This is not so good for SEO but it's the way Drupal 7 works (path column in menu_router table is case-insensitive).
I thought Global redirect would solve that with the "Case Sensitive URL Checking" setting, but it doesn't.
Here's the code for case sensitivity checking in Global redirect:

  $alias = drupal_get_path_alias($current_path, $langcode);
  // (...)
  // Alias case sensitivity check.
  // NOTE: This has changed. In D6 the $alias matched the request (in terms of
  // case). However in D7 $alias is already a true alias (accurate in case),
  // and therefore not the "true" request. So, if the alias and the request
  // path are case-insensitive the same then, if case sensitive URL's are
  // enabled, the alias SHOULD be the accurate $alias from above, otherwise it
  // should be the request_path().
  // TODO: Test if this breaks the language checking above!
  if (strcasecmp($alias, request_path()) == 0) {
    // The alias and the request are identical (case insensitive)... Therefore...
    $alias = $settings['case_sensitive_urls'] ? $alias : request_path();
  }

This works when the path has an alias, but if it doesn't, drupal_get_path_alias($current_path, $langcode) simply returns $current_path, with no case change.
I suggest to add this just before if (strcasecmp($alias, request_path()) == 0) {:

  // There's an exception: if there's no alias for the current path, it is
  // returned as is. To avoid URL duplicates, let's keep lowercase version only.
  if ($settings['case_sensitive_urls'] && $alias == $current_path) {
    $alias = strtolower($current_path);
  }

Any thoughts? Maybe this should be another setting, as it's actually not related to aliasing?

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

GaëlG’s picture

Status: Needs review » Needs work

Mmmm... looks like I spoke too fast. This will break some AJAX calls, among others things. We cannot assume the lowercase path is the right one.

GaëlG’s picture

GaëlG’s picture

There's actually no easy way to find the right path from a wrong path.

We can check if the path matches the router item's case by copying the menu_get_item()'s query but with a cast to be case sensitive (the cast data type has to be different for each SQL backend though):

    $original_map = arg(NULL, $current_path);
    $parts = array_slice($original_map, 0, MENU_MAX_PARTS);
    $ancestors = menu_get_ancestors($parts);
    $query = db_select('menu_router', 'mr');
    $query->fields('mr');
    $query->orderBy('fit', 'DESC');
    $query->range(0, 1);    
    $or = db_or();
    foreach ($ancestors as $ancestor) {
      $or->where('CAST(:ancestor AS BINARY) = CAST(path AS BINARY)',array(':ancestor' => $ancestor));
    }
    $query->condition($or);
   $router_item = $query->execute()->fetchAssoc();

But if we found out that the path doesn't match, we still have to correct its case, and this can be tricky (the path can contain placeholders,...).

So that the only easy thing we can do to avoid such duplicates is to give a 404 when the case is wrong. This can be done by two ways (at least):
* change the menu_router table structure so that it's case sensitive, as mentioned by cuebix in the related issue for D8.
* use hook_menu_get_item_alter() to re-execute a menu router query, but this time case sensitive.

GaëlG’s picture

Status: Needs work » Needs review

Here's a code that seems to work well. It might be in a separate module. It's a bit weird but it has the advantage to work on D7 without any database structure change, which is safer.

/**
 * Implements hook_menu_get_item_alter().
 * Make URL routing case-sensitive.
 * Works for MySQL only.
 * TODO Make it work for other SQL backends(switch BINARY reserved word according 
 * to backends).
 * @see menu_get_item().
 */
function globalredirect_menu_get_item_alter(&$router_item, $path, $original_map) {
  $parts = array_slice($original_map, 0, MENU_MAX_PARTS);
  $ancestors = menu_get_ancestors($parts);
  $query = db_select('menu_router', 'mr');
  $query->fields('mr');
  $query->orderBy('fit', 'DESC');
  $query->range(0, 1);
  // We cannot use condition() because of our tricky CAST statement, and the
  // db_or() pattern doesn't apply to the where() method.
  $wheres = array();
  $placeholders = array();
  foreach ($ancestors as $num => $ancestor) {
     $wheres[] = 'CAST(:ancestor' . $num . ' AS BINARY) = CAST(path AS BINARY)';
    $placeholders[':ancestor' . $num] = $ancestor;
  }
  $query->where(implode(' OR ', $wheres), $placeholders);
  $right_router_item = $query->execute()->fetchAssoc();
  if (!$right_router_item || ($right_router_item['path'] != $router_item['path'])) {
    $router_item['page_callback'] = 'drupal_not_found';
  }
}

/**
 * Implements hook_module_implements_alter().
 * Ensure that our menu_get_item_alter implementation get executed first.
 */
function globalredirect_module_implements_alter(&$implementations, $hook) {
  if ($hook == 'menu_get_item_alter') {
    $module = 'globalredirect';
    $group = array($module => $implementations[$module]);
    unset($implementations[$module]);
    $implementations = $group + $implementations;
  }
}
El Alemaño’s picture

Hi GaëlG,
I try to use your code... but is not working als expected. I am not sure if I understand it or if I used it right.
I am using Pathauto module, and I want the URL to be case sensitive. Can you help me with that?

Thanks!

GaëlG’s picture

Hi El Alemaño,
This issue is about unaliased paths. If you want case sensitive redirection for aliases (Pathauto), this is another topic. You should look for an answer in the doc or in a more relevant issue, ask for help on IRC or submit your own support issue to the module maintainers.
Good luck!

El Alemaño’s picture

Ok, thanks!

heddn’s picture

This wouldn't account for all custom entity paths but it does account for the most common i.e. core paths:

/**
 * Implements hook_url_outbound_alter().
 */
function example_url_outbound_alter(&$path, &$options, $original_path) {
  if (strncasecmp($path, 'node/', 5) == 0) {
    $path = substr_replace($path, 'node', 0, 4);
  }
  if (strncasecmp($path, 'taxonomy/term/', 14) == 0) {
    $path = substr_replace($path, 'taxonomy/term', 0, 13);
  }
  if (strncasecmp($path, 'comment/', 8) == 0) {
    $path = substr_replace($path, 'comment', 0, 7);
  }

  // File entity module.
  if (strncasecmp($path, 'file/', 5) == 0) {
    $path = substr_replace($path, 'file', 0, 4);
  }
}
heddn’s picture

#8 only solves links. It doesn't fix typed in urls, which is really the point behind globalredirect. So let's see if this fixes things.

heddn’s picture

Let's switch out that else logic.

heddn’s picture

Part of #10 isn't needed if #2048137: Canonical redirect breaks path cache gets committed.

heddn’s picture

This removes the cache flushing from the patch.

azinck’s picture

Status: Needs review » Needs work

#12 will normalize cases for all virtually all paths without respecting the case_sensitive_urls setting. Needs to take that setting into account.

azinck’s picture

Status: Needs work » Postponed

#12 causes other problems, too. Specifically I'm seeing problems with the admin_menu module's js/admin_menu/cache path where the cache-busting hashes get stripped out by this code and I get a cached version of the menu that belongs to a different user.

I think the right solution to the problem is to not patch Global Redirect and instead get core's APIs to return more consistent and case-correct aliases. The core patch here does the trick for me without requiring any patches to Global Redirect: #2400539: Path cache won't always contain the canonical alias, depending on order of calls to drupal_lookup_path()

I'm leaving this as postponed since I'm not sure I want to close it until we get more feedback on the patch to core.

azinck’s picture

Status: Postponed » Needs work

Ok, very sorry for hijacking this issue. After reading through the original post carefully I see that my work tries to solve a different problem. I've been working to make incorrectly-cased aliased paths, or incorrectly-cased normal paths that have aliases, redirect to the correctly-cased aliases. Redirecting incorrectly-cased normal paths to correctly-cased normal paths is a slightly different problem and not one that is addressed by my work in #2400539: Path cache won't always contain the canonical alias, depending on order of calls to drupal_lookup_path().

As for the actual problem at hand: I agree with the general direction of #4 but I have to think there's a better way somehow....

dandaman’s picture

Patch #12 I found helpful.

Here's the problem that I see:

Any internal URL such as "user/login" or even URLs defined by custom modules should have a defined case. Global Redirect 7.x-1.5, at least, does not redirect a URLs such as "User/Login" or "USEr/login" to "user/login". For most content, this is handled by URL aliases, but these don't have aliases, just menu items. So I think it is helpful to see if it does have a menu item and, if it does, redirect it to that menu item's case.

Hope this feature can get included in future versions!

dandaman’s picture

#12 caused a number of other issues. I'll try to add a patch at some point of what I did instead that fixes case sensitivity it for most menu items that do not have dynamic portions to them.

mahimajulka’s picture

Has anyone found a fix for this yet ?

dandaman’s picture

I added this code to about line 215 of globalredirect.module:

  // Check if there is a menu item with the same case sensitivity.
  $item = menu_get_item();
  if ($settings['case_sensitive_urls']) {
    $item = menu_get_item();
    if (strcasecmp($item['href'], $alias) == 0) {
      // The menu item and the alias are identical (case insensitive)... Therefore...
      $alias = $item['href'];
    }
  }

This, at least, if there's a menu item set for this URL, then it uses the case of the menu item. I tried some other things to sanitize it, but it also started removing extra URL arguments in some cases that were not desired. This at least fixed my problem, but is not, I think, a complete solution for all the problems described here, as not all paths is necessarily a "menu item".

mahimajulka’s picture

The Patch #19 makes the view access in drupal 7 site case insensitive.
Thanks for this @dandaman
I could not find any issues on this yet. Will post if I do.