Addressing Vanity URLs in a Content Migration

It's been a blistering few months prepping for MCM, going through the MCM program, and getting caught up on the massive backlog of work and life that occurs when you tell coworkers, friends, and family that you'll be dropping off the face of the earth for three weeks. :)  Now that I finally have a breather to start assimilating some of the novel and interesting lessons learned on my recent projects, here goes item #1...

The Problem

We've been migrating a large corporate intranet from Lotus Notes to MOSS. As you may have surmised from my previous post about maintaining link integrity, one of the many problems we needed to address was updating existing Lotus Notes URLs in the content to their new MOSS-based URLs.

At a high level, the migration tools executed a two-stage process - convert content from Notes to SharePoint, then update any Notes links in the content to reference their new SharePoint links. However, there was one class of heavily utilized links that weren't in the content, and therefore couldn't be updated by any tool - these were hundreds of "vanity URLs" that were referenced in thousands of users' bookmarks and in unknown numbers of non-Notes systems and files. For example, these were URLs like https://vacation (redirecting to http:/{notes server address for the vacation policy page in an HR intranet site}) or https://someeventname  (redirecting to the announcement page for some big annual company event).

Before the migration, this is the way these vanity URLs worked:

  • DNS mapped the vanity URL to the VIP for the Notes servers
  • (black box, but you get the idea) The Notes server contained a mapping of a particular host header to a deep URL.

Since these aliases weren't content, there was no way to convert them. Also, since there were hundreds of vanity URLs, we needed a way to implement the redirects in such a way that the migration tool would be able to update the redirect links in an automated fashion. As a secondary goal, many of these vanity URLs had been around for ages and were due for a cleanup, so a convenient means of tracking their usage would aid in retiring the unused ones, which incurred additional configuration overhead.

The Solution

We already had a tool for updating Notes links in SharePoint content to the new format/location, so the simplest solution was to transform the redirects into SharePoint content. Here are the basic requirements and approaches we took to address them:

  • Requirement: Map host headers to SharePoint content with a minimum of overhead
    • Approach: Use host header site collections as the basis for this solution. One host header site collection per vanity URL.
  • Requirement: Minimize configuration effort for creating the content
    • Approach: Script the creation of the host header site collections via STSADM.
  • Requirement: Implement a standard way of configuring redirects 
    • Approach: Implement a standard "redirect control" on the host header site collection home pages that used configurable settings to execute a redirect to an arbitrary URL.
  • Requirement: Minimize configuration effort for implementing the redirects
    • Approach: Enable script-based configuration of the redirect settings via STSADM.
  • Requirement: Minimize configuration effort for permissioning the redirect sites for all intranet users (anonymous and authenticated)
    • Approach: Enable script-based configuration of anonymous authentication settings via STSADM.
  • Requirement: Minimize the payload of redirect pages
    • Approach: Apply a master page to the host header site collections that renders an absolute minimum of content.
  • Requirement: Enable vanity URL owners to track their usage
    • Approach: Having one host header site collection per vanity URL provides standard SharePoint usage tracking info that is available to each site owner.
  • Requirement: Keep it simple
    • Approach: Create a new "redirect site" site definition that is based largely on the Blank Site site definition.

So to summarize, we would create a new site definition based on the Blank Site site definition with a lean, mean master page, and a built-in redirect control on the default page. We'd create these sites as host header site collections via STSADM and extend STSADM to enable us to script their creation and the configuration of the redirect links. There's a little more to this on the infrastructure side (DNS and IIS configuration are the most notable), but to keep the focus on the SharePoint part of the solution, the key thing to note is that once the new SharePoint sites were in production, the key cutover task was to change the DNS records for the alias sites from the Notes VIP to the SharePoint farm VIP.

The Design

There are two sets of components to the design - the UI components and the management features.

UI Components

The Redirect Site solution consists of a site definition, a feature, and a web control.

The site definition is a copy of the Blank Site site definition, with the following change:

· Default.aspx

o The SimpleRedirect.ascx control was registered with the page.

o A reference to the SimpleRedirect control was added to the PlaceHolderAdditionalPageHead placeholder.

o The master page was changed to empty.master.

· ONET.xml

o Elements that are not relevant to the Blank Site site definition (i.e. Team Site and Document Workspace-only elements) were removed

o A <Feature/> element for the Redirect Site Master Page Feature was added.

The Redirect Site Master Page Feature installs the empty.master master page in the master page library for the Redirect Site. All placeholders on the master page are set with Visible=”false”.

The SimpleRedirect.ascx web control renders a simple META REFRESH tag (https://msdn.microsoft.com/en-us/library/bb159711.aspx ), using the following property bag values:

· REDIRECT_URL – The URL to be loaded.

· REDIRECT_TIME – The delay in seconds to be applied before loading the REDIRECT_URL.

When users navigate to the base URL for a redirect site, the default page loads, using the very streamlined empty.master page, and rendering the SimpleRedirect web control, causing them to be redirected to the REDIRECT_URL within REDIRECT_TIME seconds.

Both the site definition and the feature are hidden, as they are never intended for use by end users.

Management Components

The management functionality required two new commands - setbagproperty and setanonymousaccess - and a third was added for convenience (getbagproperty).

The setbagproperty command has the following syntax:

stsadm -o setbagproperty -url <url> -propertykey <keyname> -propertyvalue <value>

Where the placeholders above have the following usage:

· url – the URL to the SPWeb to be updated

· keyname – the property key to be created (if it does not exist) or updated (if It does exist)

· value – the value to be set for the specified property key

Example – setting the value of the “test” property to “thisvalue” for the web https://intranet.example.com/sites/testsite :

stsadm -o setbagproperty -url https://intranet.example.com/sites/testsite -propertykey test -propertyvalue thisvalue

The getbagproperty command has the following syntax:

stsadm -o setbagproperty -url <url> -propertykey <keyname>

Where the placeholders above have the following usage:

· url – the URL to the SPWeb to be updated

· keyname – the property key whose value is to be retrieved

Example – retrieving the value of the “test” property for the web https://intranet.example.com/sites/testsite :

stsadm -o setbagproperty -url https://intranet.example.com/sites/testsite -propertykey test

The setanonymousaccess command has the following syntax:

stsadm -o setanonymousaccess -url <url> -state <state>

Where the placeholders above have the following usage:

· url – the URL to the SPWeb whose anonymous access settings are to be configured

· state – anonymous access state, which should be one of the following:

o 0 – Disabled: Specifies that anonymous users have no access to a Web site.

o 1 – Enabled: Specifies that anonymous users can access lists and libraries if the lists and libraries allow anonymous access.

o 2 – On: Specifies that anonymous users can access the entire Web site. This setting means that the value of the AnonymousPermMask64 property depends on the permission mask for the Limited Access role.

Example – enabling anonymous users to access lists and libraries if the lists and libraries allow anonymous access for the web https://intranet.example.com/sites/testsite :

stsadm -o setanonymousaccess -url https://intranet.example.com/sites/testsite -state 1

The Code

Here are a few of the novel components of the solution in case you find them useful - STSADM extensions and the redirect control.

SimpleRedirect.ascx

<%

@ Control Language="C#" compilationMode="Always" %>

<%

@ Import Namespace="Microsoft.SharePoint" %>

<%

@ Import Namespace="Microsoft.SharePoint.WebControls" %>

<%

@ Import Namespace="Microsoft.SharePoint.Utilities" %>

<%

SPWeb web = SPControl.GetContextWeb(Context);

string strRedirectMetaTag = string.Empty;

if ((web.Properties.ContainsKey("REDIRECT_URL")) && (web.Properties.ContainsKey("REDIRECT_TIME")) )

{

strRedirectMetaTag =

"<META http-equiv=\"REFRESH\" content=\"" + web.Properties["REDIRECT_TIME"] + ";url=" + SPHttpUtility.HtmlEncode(web.Properties["REDIRECT_URL"]) + "\">";

}

%>

<%

=strRedirectMetaTag%>

GetBagProperty.cs:

using

System;

using

System.Collections.Generic;

using

System.Text;

using

Microsoft.SharePoint.StsAdmin;

using

Microsoft.SharePoint;

namespace

SharePoint.StsAdmin

{

public class GetBagProperty : ISPStsadmCommand

{

#region

ISPStsadmCommand Members

const string USAGE_SYNTAX = "stsadm -o getbagproperty -url <url> -propertykey <keyname>";

public string GetHelpMessage(string command)

{

return USAGE_SYNTAX;

}

public int Run(string command, System.Collections.Specialized.StringDictionary keyValues, out string output)

{

output =

string.Empty;

string strWebUrl = string.Empty;

string strPropKey = string.Empty;

//read in the arguments

if (keyValues.ContainsKey("url"))

{

strWebUrl = keyValues[

"url"];

}

if (keyValues.ContainsKey("propertykey"))

{

strPropKey = keyValues[

"propertykey"];

}

//confirm that all required arguments are present

if ((strPropKey.Length > 0) && (strWebUrl.Length > 0))

{

try

{

using (SPSite siteCol = new SPSite(strWebUrl))

{

//use the utility to obtain the actual web's url

string webUrl = Utility.GetWebUrl(strWebUrl, siteCol.ServerRelativeUrl);

using (SPWeb web = siteCol.OpenWeb(webUrl))

{

if (web.Properties.ContainsKey(strPropKey))

output +=

string.Format("Property found. Value: {0}", web.Properties[strPropKey]);

else

output +=

"Property does not exist.";

}

}

}

catch (Exception ex)

{

output += ex.Message;

}

}

else

{

output +=

"Missing argument.\r\n" + USAGE_SYNTAX;

}

return 0;

}

#endregion

}

}

SetBagProperty.cs:

using

System;

using

System.Collections.Generic;

using

System.Text;

using

Microsoft.SharePoint.StsAdmin;

using

Microsoft.SharePoint;

namespace

SharePoint.StsAdmin

{

public class SetBagProperty : ISPStsadmCommand

{

#region

ISPStsadmCommand Members

const string USAGE_SYNTAX = "stsadm -o setbagproperty -url <url> -propertykey <keyname> -propertyvalue <value>";

public string GetHelpMessage(string command)

{

return USAGE_SYNTAX;

}

public int Run(string command, System.Collections.Specialized.StringDictionary keyValues, out string output)

{

output =

string.Empty;

string strWebUrl = string.Empty;

string strPropKey = string.Empty;

string strPropValue = string.Empty;

//read in the arguments

if (keyValues.ContainsKey("url"))

{

strWebUrl = keyValues[

"url"];

}

if (keyValues.ContainsKey("propertykey"))

{

strPropKey = keyValues[

"propertykey"];

}

if (keyValues.ContainsKey("propertyvalue"))

{

strPropValue = keyValues[

"propertyvalue"];

}

//confirm that all required arguments are present

if ((strPropValue.Length > 0) && (strPropKey.Length > 0) && (strWebUrl.Length > 0))

{

try

{

using (SPSite siteCol = new SPSite(strWebUrl))

{

//use the utility to obtain the actual web's url

string webUrl = Utility.GetWebUrl(strWebUrl, siteCol.ServerRelativeUrl);

using (SPWeb web = siteCol.OpenWeb(webUrl))

{

if (web.Properties.ContainsKey(strPropKey))

{

output +=

"Key exists - updating.\r\n";

web.Properties[strPropKey] = strPropValue;

}

else

{

output +=

"Key does not exist - adding.\r\n";

web.Properties.Add(strPropKey, strPropValue);

}

web.Properties.Update();

}

}

output +=

"Property set.";

}

catch (Exception ex)

{

output += ex.Message;

}

}

else

{

output +=

"Missing argument.\r\n" + USAGE_SYNTAX;

}

return 0;

}

#endregion

}

}

SetAnonymousAccess.cs:

using

System;

using

System.Collections.Generic;

using

System.Text;

using

Microsoft.SharePoint.StsAdmin;

using

Microsoft.SharePoint;

namespace

SharePoint.StsAdmin

{

public class SetAnonymousAccess : ISPStsadmCommand

{

#region

ISPStsadmCommand Members

const string USAGE_SYNTAX = "stsadm -o setanonymousaccess -url <url> -state <state>"

+

"\r\n\r\n\tState Values:" + "\r\n\t0: Disabled" + "\r\n\t1: Enabled" + "\r\n\t2: On";

public string GetHelpMessage(string command)

{

return USAGE_SYNTAX;

}

public int Run(string command, System.Collections.Specialized.StringDictionary keyValues, out string output)

{

output =

string.Empty;

string strWebUrl = string.Empty;

string strState = string.Empty;

string strNewState = string.Empty;

int state = -1;

//read in the arguments

if (keyValues.ContainsKey("url"))

{

strWebUrl = keyValues[

"url"];

}

if (keyValues.ContainsKey("state"))

{

strState = keyValues[

"state"];

try

{

state =

Convert.ToInt32(strState);

}

catch (FormatException fex)

{

output +=

"Invalid argument for state parameter.";

return 0;

}

catch (OverflowException oex)

{

output +=

"Invalid argument for state parameter.";

return 0;

}

}

//confirm that all required arguments are present and valid

if ((state > -1) && (state < 3) && (strWebUrl.Length > 0))

{

try

{

using (SPSite siteCol = new SPSite(strWebUrl))

{

//use the utility to obtain the actual web's url

string webUrl = Utility.GetWebUrl(strWebUrl, siteCol.ServerRelativeUrl);

using (SPWeb web = siteCol.OpenWeb(webUrl))

{

if (state == 0)

web.AnonymousState = SPWeb.WebAnonymousState.Disabled;

else if (state == 1)

web.AnonymousState = SPWeb.WebAnonymousState.Enabled;

else if (state == 2)

web.AnonymousState = SPWeb.WebAnonymousState.On;

strNewState = web.AnonymousState.ToString();

}

}

output +=

"Anonymous access state set to " + strNewState;

}

catch (Exception ex)

{

output += ex.Message;

}

}

else

{

output +=

"Missing or invalid argument.\r\n" + USAGE_SYNTAX;

}

return 0;

}

#endregion

}

}