Enforcing lowercase URLs is pretty easy with .NET 4.5, as long as you use the URL Helper to build your URLs:
public static void RegisterRoutes(RouteCollection routes)
{
routes.LowercaseUrls = true;
...
}
RouteCollection.LowercaseUrls
Also, it is generally a best practice to use lower case URLs for better SEO and caching support.
I suspect this has not been reported before now because most people use the @Html.ActionLink
, @Html.RouteLink
, or @Html.Url
helpers to build their URLs, which always makes them the same case, so the comparison will always work.
I'd appreciate it if you report this as a new issue @ GitHub so we can add this to the work queue, as I agree this is a bug that needs to be addressed.
The underlying problem is twofold:
- The SiteMap stores the URLs as a key in one of the dictionaries, which does a case sensitive match.
- The URL path is compared (case sensitive) first before the case insensitive routes are compared.
This means that if all of the URLs were generated lowercase, the problem would go away. This is because MvcSiteMapProvider uses the MVC UrlHelper class to generate the URLs.
However, another potential fix would be to rearrange the order in which the comparisons are done so the routes are compared first. You can accomplish this by inheriting RequestCacheableSiteMap and overriding FindSiteMapNode().
// Original
protected virtual ISiteMapNode FindSiteMapNode(HttpContextBase httpContext)
{
// Match RawUrl
var node = this.FindSiteMapNodeFromRawUrl(httpContext);
// Try MVC
if (node == null)
{
node = this.FindSiteMapNodeFromMvc(httpContext);
}
// Try ASP.NET Classic (for interop)
if (node == null)
{
node = this.FindSiteMapNodeFromAspNetClassic(httpContext);
}
// Try the path without the querystring
if (node == null)
{
node = this.FindSiteMapNode(httpContext.Request.Path);
}
// Check accessibility
return this.ReturnNodeIfAccessible(node);
}
// Fixed
protected override ISiteMapNode FindSiteMapNode(HttpContextBase httpContext)
{
// Match MVC
var node = this.FindSiteMapNodeFromMvc(httpContext);
// Try RawUrl
if (node == null)
{
node = this.FindSiteMapNodeFromRawUrl(httpContext);
}
// Try ASP.NET Classic (for interop)
if (node == null)
{
node = this.FindSiteMapNodeFromAspNetClassic(httpContext);
}
// Try the path without the querystring
if (node == null)
{
node = this.FindSiteMapNode(httpContext.Request.Path);
}
// Check accessibility
return this.ReturnNodeIfAccessible(node);
}
You can then either inherit SiteMapFactory or create your own ISiteMapFactory that returns your SiteMap type and inject it with external DI. This solution won't work if using the internal DI container.
using System;
using MvcSiteMapProvider.Builder;
using MvcSiteMapProvider.Web.Mvc;
using MvcSiteMapProvider.Web;
public class MySiteMapFactory
: ISiteMapFactory
{
public MySiteMapFactory(
ISiteMapPluginProviderFactory pluginProviderFactory,
IMvcResolverFactory mvcResolverFactory,
IMvcContextFactory mvcContextFactory,
ISiteMapChildStateFactory siteMapChildStateFactory,
IUrlPath urlPath,
IControllerTypeResolverFactory controllerTypeResolverFactory,
IActionMethodParameterResolverFactory actionMethodParameterResolverFactory
)
{
if (pluginProviderFactory == null)
throw new ArgumentNullException("pluginProviderFactory");
if (mvcResolverFactory == null)
throw new ArgumentNullException("mvcResolverFactory");
if (mvcContextFactory == null)
throw new ArgumentNullException("mvcContextFactory");
if (siteMapChildStateFactory == null)
throw new ArgumentNullException("siteMapChildStateFactory");
if (urlPath == null)
throw new ArgumentNullException("urlPath");
if (controllerTypeResolverFactory == null)
throw new ArgumentNullException("controllerTypeResolverFactory");
if (actionMethodParameterResolverFactory == null)
throw new ArgumentNullException("actionMethodParameterResolverFactory");
this.pluginProviderFactory = pluginProviderFactory;
this.mvcResolverFactory = mvcResolverFactory;
this.mvcContextFactory = mvcContextFactory;
this.siteMapChildStateFactory = siteMapChildStateFactory;
this.urlPath = urlPath;
this.controllerTypeResolverFactory = controllerTypeResolverFactory;
this.actionMethodParameterResolverFactory = actionMethodParameterResolverFactory;
}
protected readonly ISiteMapPluginProviderFactory pluginProviderFactory;
protected readonly IMvcResolverFactory mvcResolverFactory;
protected readonly IMvcContextFactory mvcContextFactory;
protected readonly ISiteMapChildStateFactory siteMapChildStateFactory;
protected readonly IUrlPath urlPath;
protected readonly IControllerTypeResolverFactory controllerTypeResolverFactory;
protected readonly IActionMethodParameterResolverFactory actionMethodParameterResolverFactory;
#region ISiteMapFactory Members
public virtual ISiteMap Create(ISiteMapBuilder siteMapBuilder, ISiteMapSettings siteMapSettings)
{
var routes = mvcContextFactory.GetRoutes();
var requestCache = mvcContextFactory.GetRequestCache();
// IMPORTANT: We need to ensure there is one instance of controllerTypeResolver and
// one instance of ActionMethodParameterResolver per SiteMap instance because each of
// these classes does internal caching.
var controllerTypeResolver = controllerTypeResolverFactory.Create(routes);
var actionMethodParameterResolver = actionMethodParameterResolverFactory.Create();
var mvcResolver = mvcResolverFactory.Create(controllerTypeResolver, actionMethodParameterResolver);
var pluginProvider = pluginProviderFactory.Create(siteMapBuilder, mvcResolver);
return new MySiteMap(
pluginProvider,
mvcContextFactory,
siteMapChildStateFactory,
urlPath,
siteMapSettings,
requestCache);
}
#endregion
}
And then inject your factory something like this in the MvcSiteMapProvider DI module (StructureMap example shown):
For<ISiteMapFactory>().Use<MySiteMapFactory>();
Do note you will have to re-implement the constructor of RequestCacheableSiteMap so all of its dependencies are passed into the object just as they are in the MySiteMapFactory implementation.
I haven't tested this solution to determine if there are any side effects, especially if you are using a mixed bag of routes and URLs.