URL Remapping

Using ASP.NET HTTP Module

In this tutorial you will learn how to install and configure an ASP.NET HTTP Module, which analyzes incoming requests and then does permanent HTTP redirects when the requests meet the rules you specify.

The module is restricted to handling requests which is handled by ASP.NET – like "default.aspx".

Installation

To install the remapper module:

  1. Download and copy the file RequestUrlRemapperHttpModule.cs to /App_Code
  2. Download and copy the sample file RequestUrlRemappings.xml to /App_Data
  3. In the /web.config file, add the following element as the first child of configuration/system.webServer/modules :
<add name="RequestUrlRemapper" 
type="Composite.Demo.RequestUrlRemapperHttpModule"/>
At this point the mapper module is running and all there is left is to configure and test it.

Configuration

You can configure the rules by editing /App_Data/RequestUrlRemappings.xml. This XML files can contain none, one or more <Remapping /> elements:

<Remapping 
	requestPath="/old-page" 
	requestHost="contoso.com" 
	requestPathStartsWith="false" 
	requestHostEndsWith="true" 
	rewritePath="/new-page" 
	rewriteHost="www.contoso.com" />

This will redirect all incoming requests for http://*.contoso.com/old.aspx to http://www.contoso.com/new.aspx

Changes made to the RequestUrlRemappings.xml file is immediately picked up. Errors are logged to the C1 CMS Server log. Also, if the configuration file contains any errors the HTTP filter will fail on application start.

The following attributes can be used on the Remapping element:

  • requestPath: Required. The path (or beginning of the path) the remapping rule should react to. To match all paths use "/" and set requestPathStartsWith to "true".
  • requestHost: Optional. The host (or domain) the remapping rule should react to. To match all hosts, leave this empty. If requestsHostEndsWith is "true" (see below), then your host rule will match all incoming requests host names which end with the specified value, i.e. "contoso.com" will match "www.contoso.com".
  • requestPathStartsWith:  Optional. Boolean. When "true", the requestPath will match all paths which start with the specified requestPath value, i.e. "/dk/" will match "/dk/Contoso/Produkter"
  • requestHostEndsWith:  Optional. Boolean. When "true", the specified requestHost will be used as a "domain match" instead of a literal match.
  • rewritePath: Optional. The new absolute path the user should be redirected to. If you leave this attribute blank, the path will not be changed.
  • rewriteHost: Optional. The new host the user should be redirected to. If you leave this attribute blank, the host will not be changed.

Known limitations

This remapper module only works on http (not https) and only on port 80. The source code is freely available if you require such features (see below).

The host name will not be changed if the user is logged into the CMS Console. If you are logged in, use a new browser instance (an instance, not just a window) to test your rules.

Only requests handled by ASP.NET will be seen by the remapper module. This typically excludes requests to *.gif, *.php files etc.

Source code

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Web;
using System.Xml;
using System.Xml.Linq;
using Composite.Core.Logging;
using Composite.C1Console.Security;

namespace Composite.Demo
{

    /// <summary>
    /// Uses file '/App_Data/RequestUrlRemappings.xml' to re-map incoming requests.
    /// </summary>
    public class RequestUrlRemapperHttpModule : IHttpModule
    {
        private const string _remappingsRelativePath = "/App_Data/RequestUrlRemappings.xml";
        private string _remappingsFilePath;

        private List<Remapping> _pathRemappings = new List<Remapping>();
        private List<Remapping> _pathStartsWithRemappings = new List<Remapping>();
        private readonly List<XName> _validRemappingAttributeNames = new List<XName> { "requestPathStartsWith", "requestPath", "requestHostEndsWith", "requestHost", "rewritePath", "rewriteHost" };
        private FileSystemWatcher _remappingsFileWatcher;



        public void Init(HttpApplication context)
        {
            _remappingsFilePath = context.Context.Server.MapPath(_remappingsRelativePath);

            LoadRemappings();

            SetRemappingsFileWatcher();

            context.BeginRequest += new EventHandler(ExecuteRemappingsOnRequest);
        }



        void ExecuteRemappingsOnRequest(object sender, EventArgs e)
        {
            HttpApplication application = (HttpApplication)sender;
            HttpContext context = application.Context;

            string host = context.Request.Url.Host.ToLower();
            string path = context.Request.Url.AbsolutePath.ToLower();

            Remapping remapping = _pathRemappings.Where(
                                    f => f.RequestPath == path &&
                                    (f.RequestHost == null ||
                                     f.RequestHost == host ||
                                     f.RequestHostEndsWith == true && host.EndsWith(f.RequestHost))).FirstOrDefault();

            if (remapping == null)
            {
                remapping = _pathStartsWithRemappings.Where(
                                f => path.StartsWith(f.RequestPath) &&
                                (f.RequestHost == null ||
                                 f.RequestHost == host ||
                                 f.RequestHostEndsWith == true && host.EndsWith(f.RequestHost))).FirstOrDefault();
            }

            if (remapping != null)
            {

                bool isLoggedIn = false;

                try { isLoggedIn = UserValidationFacade.IsLoggedIn(); }
                catch (Exception ex) { isLoggedIn = false; }

                if (isLoggedIn || context.Request.Url.PathAndQuery.Contains("dataScope=administrated"))
                    return;

                string newHost = remapping.RewriteHost ?? context.Request.Url.Host;
                string newPathAndQuery = remapping.RewritePath ?? context.Request.Url.PathAndQuery;

                if (newHost.ToLower() != context.Request.Url.Host.ToLower() ||
                    newPathAndQuery.ToLower() != context.Request.Url.PathAndQuery.ToLower())
                {
                    string newUrl = string.Format("http://{0}{1}", newHost, newPathAndQuery);

                    context.Response.RedirectLocation = newUrl;
                    context.Response.StatusCode = 301;
                    context.ApplicationInstance.CompleteRequest();
                }
            }

        }



        public void Dispose()
        {
            _remappingsFileWatcher.Dispose();
        }



        private void LoadRemappings()
        {
            XDocument remappingsDocument = LoadRemappingsDocument();

            List<Remapping> pathRemappings = new List<Remapping>();
            List<Remapping> pathStartsWithRemappings = new List<Remapping>();

            foreach (XElement remappingElement in remappingsDocument.Descendants("Remapping"))
            {
                ValidateAttributes(remappingElement);

                Remapping remapping = new Remapping
                {
                    RequestPath = LowerValue(remappingElement, "requestPath"),
                    RequestHost = LowerValueOrNull(remappingElement.Attribute("requestHost")),
                    RequestHostEndsWith = AsBool(remappingElement, "requestHostEndsWith", false),
                    RewritePath = ValueOrNull(remappingElement.Attribute("rewritePath")),
                    RewriteHost = LowerValueOrNull(remappingElement.Attribute("rewriteHost"))
                };

                bool requestPathStartsWith = AsBool(remappingElement, "requestPathStartsWith", false);

                if (requestPathStartsWith == true)
                {
                    pathStartsWithRemappings.Add(remapping);
                }
                else
                {
                    pathRemappings.Add(remapping);
                }
            }

            // order so "most matching paths", "most matching hosts" are first in lists.
            Func<Remapping, int> orderByFunc = f => f.RequestPath.Length * 1000 + (f.RequestHost == null ? 0 : f.RequestHost.Length);
            _pathRemappings = new List<Remapping>(pathRemappings.OrderByDescending(orderByFunc));
            _pathStartsWithRemappings = new List<Remapping>(pathStartsWithRemappings.OrderByDescending(orderByFunc));
        }



        private void ValidateAttributes(XElement remappingElement)
        {
            IEnumerable<XAttribute> unexpectedAttributes = remappingElement.Attributes().Where(f => _validRemappingAttributeNames.Contains(f.Name) == false);
            if (unexpectedAttributes.Any())
            {
                string unexpectedAttributeList = "";
                foreach (XAttribute unexpectedAttribute in unexpectedAttributes)
                {
                    if (string.IsNullOrEmpty(unexpectedAttributeList) == false)
                    {
                        unexpectedAttributeList += ", ";
                    }
                    unexpectedAttributeList += unexpectedAttribute.Name.LocalName;
                }

                string expectedAttributeList = "";
                foreach (XName expectedAttributeName in _validRemappingAttributeNames)
                {
                    if (string.IsNullOrEmpty(expectedAttributeList) == false)
                    {
                        expectedAttributeList += ", ";
                    }
                    expectedAttributeList += expectedAttributeName.LocalName;
                }

                throw new InvalidOperationException(string.Format("Found unexpected attributes '{0}' on element '{1}'. Expected attributes are '{2}'.",
                    unexpectedAttributeList, remappingElement.Name, expectedAttributeList));
            }

        }



        private XDocument LoadRemappingsDocument()
        {
            if (File.Exists(_remappingsFilePath) == false) throw new InvalidOperationException(string.Format("The remappings XML File required by this module could not be found at '{0}'. Either create this file or remove this HTTP Module from web.config.", _remappingsRelativePath));

            try
            {
                return XDocument.Load(_remappingsFilePath);
            }
            catch (XmlException ex)
            {
                throw new InvalidOperationException(string.Format("{0} Either remove this HTTP Handler from web.config or correct this error in file '{1}'.",
                    ex.Message, _remappingsRelativePath));
            }
        }



        private string LowerValue(XElement remappingElement, string attributeName)
        {
            XAttribute attribute = remappingElement.Attribute(attributeName);

            if (attribute == null)
            {
                throw new InvalidOperationException(string.Format("Required attribute '{0}' not found on element '{1}'. Either remove this HTTP Handler from web.config or add this attribute to the element in file '{2}'.",
                    attributeName, remappingElement.Name.LocalName, _remappingsRelativePath));

            }

            return attribute.Value.ToLower();
        }



        private string LowerValueOrNull(XAttribute attribute)
        {
            if (attribute == null) return null;

            if (string.IsNullOrEmpty(attribute.Value)) return null;

            return attribute.Value.ToLower();
        }



        private string ValueOrNull(XAttribute attribute)
        {
            if (attribute == null) return null;

            if (string.IsNullOrEmpty(attribute.Value)) return null;

            return attribute.Value;
        }



        private bool AsBool(XElement remappingElement, string attributeName, bool fallbackValue)
        {
            XAttribute attribute = remappingElement.Attribute(attributeName);

            if (attribute == null)
            {
                return fallbackValue;
            }

            try
            {
                return bool.Parse(attribute.Value);
            }
            catch
            {
                throw new InvalidOperationException(string.Format("Attribute '{0}' on element '{1}' could not be parsed as a boolean. Either remove this HTTP Handler from web.config or specify 'true' or 'false' for this attribute in file '{2}'.",
                                                                  attributeName, remappingElement.Name.LocalName, _remappingsRelativePath));
            }
        }



        private void SetRemappingsFileWatcher()
        {
            _remappingsFileWatcher = new FileSystemWatcher(Path.GetDirectoryName(_remappingsFilePath), Path.GetFileName(_remappingsFilePath));
            _remappingsFileWatcher.Changed += new FileSystemEventHandler(RemappingsFileChanged);
            _remappingsFileWatcher.Renamed += new RenamedEventHandler(RemappingsFileChanged);
            _remappingsFileWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.LastAccess | NotifyFilters.FileName;
            _remappingsFileWatcher.EnableRaisingEvents = true;
        }


        object _lock = new object();

        void RemappingsFileChanged(object sender, FileSystemEventArgs e)
        {
            lock (_lock)
            {
                try
                {
                    Thread.Sleep(50);
                    LoadRemappings();
                    LoggingService.LogVerbose("RequestUrlRemapper", "Remappings reloaded");
                }
                catch (Exception ex)
                {
                    LoggingService.LogError("RequestUrlRemapper", ex);
                }
            }
        }



        private class Remapping
        {
            public string RequestPath { get; set; }
            public string RequestHost { get; set; }
            public bool RequestHostEndsWith { get; set; }

            public string RewritePath { get; set; }
            public string RewriteHost { get; set; }

            public override string ToString()
            {
                return string.Format("http://{0}{1} --> http://{2}{3}",
                    (RequestHost ?? "*"),
                    RequestPath,
                    (RewriteHost ?? "*"),
                    (RewritePath ?? "/*"));
            }
        }
    }
}
Download RequestUrlRemapperHttpModule.cs

Sample configuration

<RequestUrlRemappings>
  <!-- make sure that *.dk and *.com start page requests go to the same URL -->
  <Remapping requestPath="/" requestHost="contoso.dk" requestPathStartsWith="false" requestHostEndsWith="true" rewritePath="/dk/Contoso" rewriteHost="www.contoso.dk" />
  <Remapping requestPath="/" requestHost="contoso.com" requestPathStartsWith="false" requestHostEndsWith="true" rewritePath="/dk/Contoso" rewriteHost="www.contoso.dk" />

  <!-- handle a specific host name, so it goes to a matching language version -->
  <Remapping requestPath="/" requestHost="contoso.nl" requestPathStartsWith="false" requestHostEndsWith="true" rewritePath="/nl/DutchContoso" />

  <!-- catch requests where .dk is serving /nl/ content (and visa versa) and fix host name -->
  <!-- forcing the client on to the 'correct' host name can have a positive influence on search engine rankings -->
  <Remapping requestPath="/dk/" requestHost="contoso.nl" requestPathStartsWith="true" requestHostEndsWith="true" rewriteHost="www.contoso.dk" />
  <Remapping requestPath="/nl/" requestHost="contoso.dk" requestPathStartsWith="true" requestHostEndsWith="true" rewriteHost="www.contoso.nl" />

</RequestUrlRemappings>
Download RequestUrlRemappings.xml (sample)