Handle URL redirects in a Sitecore multisite solution using FileMapProvider

redirect-me-baby_3

I would like to share with you guys how you could do URL redirects in a Sitecore multisite solution with Microsoft’s URL Rewrite Module using the FileMapProvider.

FilemapProvider is very cool, it reads the URL mappings from a text file.

It can be used instead of the built-in rewrite maps functionality when the amount of rewrite map entries is very large and it is not practical to keep them in a web.config file.

And the best thing of all:
No need to do any custom pipelines in Sitecore – it’s clean 🙂
You don’t have to do an IIS reset after the redirect file has been changed/updated.

The idea is this:
We have a Sitecore solution with several websites, each website will have a number of redirects. In Sitecore we will have a Bucket with “URL Redirects” items, each item will contain an old and a new url. From the Content Editor we want to be able to click on a command button that will trigger a remote event(we need it to work in a multi-server environment), which will write the redirects to a text file(for the FileMapProvider).

How about divide the work into following:
1. Configure the FileMapProvider/s.
2. Setup the URL redirects in Sitecore.
3. Create the command button in Sitecore Content Editor.
4. Create the (remote) event and write redirects to file.

1. Configure the FileMapProvider/s.

The FileMapProvider is part of the URL Rewrite Extensibility Samples, which allows us to use custom providers for the rewrite\redirect rules – in our case: external .txt file with URL’s.

/old/catalog/product; /new/category/product
/old/contactus/index; /new/contactus

In our multisite scenario we will have two websites running, testsite1.com and testsite2.com. Which means we will have two FileMapProvider’s, one for each website.

Let’s have a look at how the FilemapProviders are configured in the UrlRewrite.config. Yes it’s a web.config transformation(because Sitecore config file patching only works within the sitecore section)

<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
  <system.webServer>
	<rewrite>
	  <providers>
		<provider name="FileMapProviderForTestSite1" type="FileMapProvider, Microsoft.Web.Iis.Rewrite.Providers, Version=7.1.761.0, Culture=neutral, PublicKeyToken=0545b0627da60a5f"  xdt:Transform="Insert">
		  <settings>
			<add key="FilePath" value="D:\Files\Data\UrlRedirectsForTestSite1.txt"/>
			<add key="IgnoreCase" value="1"/>
			<add key="Separator" value=";"/>
		  </settings>
		</provider>
		<provider name="FileMapProviderForTestSite2" type="FileMapProvider, Microsoft.Web.Iis.Rewrite.Providers, Version=7.1.761.0, Culture=neutral, PublicKeyToken=0545b0627da60a5f"  xdt:Transform="Insert">
		  <settings>
			<add key="FilePath" value="D:\Files\Data\UrlRedirectsForTestSite2.txt"/>
			<add key="IgnoreCase" value="1"/>
			<add key="Separator" value=";"/>
		  </settings>
		</provider>
	  </providers>
	  <rules>
		<rule name="RuleForFileMapProviderForTestSite1" stopProcessing="false" xdt:Transform="Insert">
		  <match url="(.*)"/>
		  <conditions>
		    <add input="{HTTP_HOST}" pattern="^testsite1.com$"/>
			<add input="{FileMapProviderForTestSite1:{REQUEST_URI}}" pattern="(.+)"/>
		  </conditions>
		  <action type="Redirect" url="{C:1}" appendQueryString="false"/>
		 </rule>
		 <rule name="RuleForFileMapProviderForTestSite2" stopProcessing="false" xdt:Transform="Insert">
		  <match url="(.*)"/>
		  <conditions>
		    <add input="{HTTP_HOST}" pattern="^testsite2.com$"/>
			<add input="{FileMapProviderForTestSite2:{REQUEST_URI}}" pattern="(.+)"/>
		  </conditions>
		  <action type="Redirect" url="{C:1}" appendQueryString="false"/>
		</rule>
	  </rules>
	</rewrite>
  </system.webServer>
</configuration>		

If you notice there are two FileMapProvider configurations, one provider for each site – how cool is that 🙂 The provider points to a text file(containing the redirects for that specific site).
Now in order to get the redirect magic to work we need to set up a rule. If you look at the rule RuleForFileMapProviderForTestSite1, you will see that this rule will only work for website – testsite1.com.
We do that by using pattern(below) to check if it matches with HTTP_HOST:

pattern="^testsite1.com$"

Then we use/get the “site” specific provider, FileMapProviderForTestSite1, to do the redirect.

Tip!
If you guys don’t want to install the URL Rewrite Extensibility Sample on a test or production server, fear not. Just grab it from your GAC on your local dev machine.
Fire up your PowerShell(for windows) and use the SUBST Command. Suppose you want to create a G Drive (G for GAC), use the following command:

SUBST G: C:\WINDOWS\ASSEMBLY

Look in folder G:\GAC_MSIL and you will find Microsoft.Web.Iis.Rewrite.Providers 🙂

2. Setup the URL redirects in Sitecore

Next will be to setup the URL redirects in sitecore. The folder structure for the redirects.
treeredirects

UrlRedirectsItem: It will contain two fields – NewUrl and OldUrl
redirectitem

UrlRedirectsFolderItem: I’ve added a field in order to figure out what site it belongs to.
folderitem

3. Create the command button in Sitecore Content Editor

Next will be to create the command that will trigger the writing of redirects to a text file. I wanted the button only to be visible when the “UrlRedirects folder” is selected/marked:
contenteditorcommand
I also added a confirm dialog, in case the editor wants to cancel the request.

In the Sitecore Core database we will add the command button, we will use the Large Button template. Navigate to /sitecore/content/Applications/Content Editor/Ribbons/Contextual Ribbons and create the new ribbon – Sync Url Redirects:
corecommand
We will also create a new command – SyncUrlRedirects:ToFile

In order for the command button only to be visible for a specific template(in this case the Url Redirects folder) we need to go to the template and then in the Appearance Section locate the drop tree field, Ribbon. And finally select our newly created ribbon – Sync Url Redirects:
templatecommand

We will patch the new command to a config file:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <commands>
      <command name="SyncUrlRedirects:ToFile" type="Sandbox.Website.Commands.SyncUrlRedirectsFromSitecoreToFileCommand,Sandbox.Website" />
    </commands>
  </sitecore>
</configuration> 

Now we need to add some code for the command button.
In the Execute method we will verify that we are standing on the UrlRedirectsSiteFolder item, next will be to get all the children(deriving from template UrlRedirect). The GetChildrenByTemplateWithFallback method is an extension method from the Sitecore.ContentSearch.Utilities.
I need to send some parameters to the Confirm dialog(number of redirect items and the item id of the Redirects Folder) and finally call/execute the Confirm dialog by using Sitecore.Context.ClientPage.Start.

public class SyncUrlRedirectsFromSitecoreToFileCommand : Command
{

	private const string QueryParamRedirectsToBeSynced = "count";
	private const string QueryParamFolderId = "folderid";

	public override void Execute(CommandContext context)
	{
		Assert.ArgumentNotNull(context, "context");

		Assert.IsNotNull(context.Items, "context items are null");

		Assert.IsTrue(context.Items.Length > 0, "context items length is 0");

		Item item = context.Items[0];

		Assert.IsNotNull(item, "Item is null");

		Assert.IsTrue(item.IsDerived(SitecoreTemplates.UrlRedirectsSiteFolder.ID), "Item is not of template UrlRedirectsSiteFolder");

		int numberOfRedirects =
		item.GetChildrenByTemplateWithFallback(SitecoreTemplates.UrlRedirect.ID.ToString()).Count();

		context.Parameters.Add(QueryParamRedirectsToBeSynced, numberOfRedirects.ToString());
		context.Parameters.Add(QueryParamFolderId, item.ID.ToString());

		Sitecore.Context.ClientPage.Start(this, "Confirm", context.Parameters);
	}

	protected void Confirm(ClientPipelineArgs args)
	{

		if (args.IsPostBack)
		{
			switch (args.Result)
			{
				case "yes":
					SyncUrlRedirectsEvent syncUrlRedirectsEvent = new SyncUrlRedirectsEvent(args.Parameters[QueryParamFolderId]);
					Sitecore.Eventing.EventManager.QueueEvent(syncUrlRedirectsEvent, true, true);
					return;

				case "no":
					return;
			}
		}

		SheerResponse.Confirm($"It is important that you publish all redirects before you continue. {args.Parameters[QueryParamRedirectsToBeSynced]} redirects will be synced.");
		args.WaitForPostBack();

	}

	public override CommandState QueryState(CommandContext context)
	{
		Assert.ArgumentNotNull(context, "context");

		if (context.Items.Length != 1)
			return CommandState.Hidden;

		Item item = context.Items[0];

		Assert.IsNotNull(item, "First context item is null");

		Sitecore.Data.Version[] versionNumbers = item.Versions.GetVersionNumbers(false);
		if (versionNumbers == null || versionNumbers.Length == 0 || item.Appearance.ReadOnly)
			return CommandState.Disabled;

		if (!item.Access.CanWrite() || !item.Access.CanRemoveVersion() || IsLockedByOther(item))
			return CommandState.Disabled;

		return base.QueryState(context);

	}

}

In the Confirm method we will check if the if the editor hits the yes button, if so we will call the SyncUrlRedirectsEvent and pass along the item id of the Redirects Folder.

4. Create the (remote) event and write redirects to file

In order to write the redirects to a file on a CD server, we will need a remote event – SyncUrlRedirectsEvent. The event is quite simple, it will just hold the item id of the Redirects Folder.
Here is the event and its EventArgs class:

[DataContract]
public class SyncUrlRedirectsEvent
{
	public SyncUrlRedirectsEvent(string urlRedirectsSiteFolderItemId)
	{
		UrlRedirectsSiteFolderItemId = urlRedirectsSiteFolderItemId;
	}

	[DataMember]
	public string UrlRedirectsSiteFolderItemId { get; protected set; }

}

[Serializable]
public class SyncUrlRedirectsEventArgs : EventArgs, IPassNativeEventArgs
{
	public SyncUrlRedirectsEventArgs(string urlRedirectsSiteFolderItemId)
	{
		UrlRedirectsSiteFolderItemId = urlRedirectsSiteFolderItemId;
	}

	public string UrlRedirectsSiteFolderItemId { get; protected set; }
}

Next is the eventhandler, that is the one which will be called/executed on the CD server, so we can write the redirects for the FileMapProvider. Here we fetch all redirects from the “UrlRedirectsSiteFolderItem id”(which we got from the SyncUrlRedirectsEventArgs) and then write them to a text file.
I will not go into how the SyncUrlRedirectsService works, it’s quite straightforward. If you are interested in how it’s done just ping me and I will show you 🙂

public class SyncUrlRedirectsEventHandler
{
	private static ILogger _logger;
	private readonly ISyncUrlRedirectsService _syncUrlRedirectsService;
	private readonly IDatabaseRepository _databaseRepository;


	public SyncUrlRedirectsEventHandler()
	{
		_logger = IocContext.NonRequestContainer.GetInstance<ILogger>();
		_syncUrlRedirectsService = IocContext.NonRequestContainer.GetInstance<ISyncUrlRedirectsService>();
		_databaseRepository = new DatabaseRepository();
			
	}

	public virtual void OnRemoteSyncUrlRedirects(object sender, EventArgs e)
	{

		if (e == null)
		{
			_logger.Error("SyncUrlRedirectsEventArgs is empty");
			return;
		}
			

		SyncUrlRedirectsEventArgs syncUrlRedirectsEventArgs = (SyncUrlRedirectsEventArgs) e;

		if (syncUrlRedirectsEventArgs.UrlRedirectsSiteFolderItemId.IsNullOrWhiteSpace())
		{
			_logger.Error("UrlRedirectsSiteFolder item id is missing in SyncUrlRedirectsEventArgs");
			return;
		}

		Item urlRedirectsSiteFolderItem =
			_databaseRepository.ContextDatabase.GetItem(new ID(syncUrlRedirectsEventArgs.UrlRedirectsSiteFolderItemId));


		if (urlRedirectsSiteFolderItem == null)
		{
			_logger.Error("UrlRedirectsSiteFolderItem does not exist on {0}. Id: {1}", _databaseRepository.ContextDatabase.Name, syncUrlRedirectsEventArgs.UrlRedirectsSiteFolderItemId);
			return;
		}

		string sitename = urlRedirectsSiteFolderItem.GetString(SitecoreTemplates.UrlRedirectsSiteFolder.Fields.UrlRedirectsSiteName);

		if (sitename.IsNullOrWhiteSpace())
		{
			_logger.Error("Site name is not set in UrlRedirectsSiteFolder item id, {0}", urlRedirectsSiteFolderItem.ID);
			return;
		}


		IList<string[]> urlRedirectsList =	urlRedirectsSiteFolderItem.GetChildrenByTemplateWithFallback(SitecoreTemplates.UrlRedirect.ID.ToString())
			.Select(
				item =>
					new[] {item.GetString(SitecoreTemplates.UrlRedirect.Fields.UrlRedirectOldUrl), item.GetString(SitecoreTemplates.UrlRedirect.Fields.UrlRedirectNewUrl) }).ToList();


		Tuple<IList<string>, string> redirectsAndFilePath =  _syncUrlRedirectsService.PrepareRedirectsAndFilePathForFile(urlRedirectsList, sitename);

		_syncUrlRedirectsService.TryWriteRedirectsToFile(redirectsAndFilePath, sitename);

	}


	public static void Run(SyncUrlRedirectsEvent syncUrlRedirectsEvent)
	{
		_logger.Information("SyncUrlRedirectsEventHandler - Run", typeof(SyncUrlRedirectsEventHandler));

		SyncUrlRedirectsEventArgs args = new SyncUrlRedirectsEventArgs(syncUrlRedirectsEvent.UrlRedirectsSiteFolderItemId);

		Event.RaiseEvent("syncurlredirects:remote", args);
	}
}

The Run method is called from a Hook.

To glue it all together(event and eventhandler) we need to register and trigger the eventhandler, for that we will use a hook. See the hook as a very light pipeline.

public class SyncUrlRedirectsHook : IHook
{
	public void Initialize()
	{
		Sitecore.Eventing.EventManager.Subscribe(new Action<SyncUrlRedirectsEvent>(SyncUrlRedirectsEventHandler.Run));
	}
}

Here is the config file for the event and the hook:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
	<hooks>
	  <hook type="Sandbox.Website.Redirect.Events.SyncUrlRedirectsHook, Sandbox.Website" />
	</hooks>
    <events>
      <event name="syncurlredirects:remote">
        <handler type="Sandbox.Website.Redirect.Events.SyncUrlRedirectsEventHandler, Sandbox.Website" method="OnRemoteSyncUrlRedirects" />
      </event>
    </events>
  </sitecore>
</configuration>

Instead of using a hook, you can use a pipeline(for the event). Here are some great documentation regarding events in Sitecore:
http://sitecore-community.github.io/docs/pipelines-and-events/events/

That’s all for now folks 🙂


2 thoughts on “Handle URL redirects in a Sitecore multisite solution using FileMapProvider

  1. That is really cool. So far I always either went for a custom httpRequestBegin pipeline processor or config-based, which both have its down-sides. This seems like the perfect solution.
    I am wondering though, if it would not be more intuitive for authors, when we trigger SyncUrlRedirects through an item:saved:remote event which will check if the saved item is the redirects bucket. That way they just need to publish the bucket and don’t have to remember clicking the sync button.

    Liked by 1 person

    1. Thank you 🙂 yes you have a good point there. I’ve also been thinking having a syncFileToItems button, which will present a speak ui where the editors can upload a csv file of redirects to generate redirect items.

      Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.