Skip to main content
Bounty Ended with 100 reputation awarded by Pete Navarra
added 620 characters in body
Source Link

Solution/Workaround for Single-Site Solutions

Before reading this section, note that when I first deduced the workaround, I did so by recordingmaking and then proving/disproving assumptions, in order to "unit test" the method by hand. After doing so and presenting the solution to the OP, we worked together to debug and confirm the hypothesis and identify which assumption case was correct.

  • Your site is a multi-site and you sometimes generate links for one site on another (as in the example)
  • Your site generates sitemaps using the siteInfo.TargetHostName
  • Your site generates absolute links using siteInfo.TargetHostName

Note that you can still work around these caveats using custom processors, like the HandleInternalLink processor, or other logic in your code. However, there is no catch-all solution that will handle these caveats or the solution without extending and overriding the native LinkProvider (see the "Alternative Solution"). Unless you decide that you are going to extend and override the native LinkProvider, I do not recommend adding such code as it will basically be a band-aid on a hemorrhage, and Sitecore Support should be able to provide you with the URL.

Solution/Workaround for Single-Site Solutions

Before reading this section, note that when I first deduced the workaround, I did so by recording and proving assumptions, in order to "unit test" the method by hand. After doing so and presenting the solution to the OP, we worked together to debug and confirm the hypothesis and identify which assumption case was correct.

  • Your site is a multi-site
  • Your site generates sitemaps using the siteInfo.TargetHostName
  • Your site generates absolute links using siteInfo.TargetHostName

Solution/Workaround

Before reading this section, note that when I first deduced the workaround, I did so by making and then proving/disproving assumptions, in order to "unit test" the method by hand. After doing so and presenting the solution to the OP, we worked together to debug and confirm the hypothesis and identify which assumption case was correct.

  • Your site is a multi-site and you sometimes generate links for one site on another (as in the example)
  • Your site generates sitemaps using the siteInfo.TargetHostName
  • Your site generates absolute links using siteInfo.TargetHostName

Note that you can still work around these caveats using custom processors, like the HandleInternalLink processor, or other logic in your code. However, there is no catch-all solution that will handle these caveats or the solution without extending and overriding the native LinkProvider (see the "Alternative Solution"). Unless you decide that you are going to extend and override the native LinkProvider, I do not recommend adding such code as it will basically be a band-aid on a hemorrhage, and Sitecore Support should be able to provide you with the URL.

Source Link

Let me start by saying that I believe that this is a bug in Sitecore, and the results of our peer debugging session have strengthened that belief.

Solution/Workaround for Single-Site Solutions

You should be able to work around this issue by adding an asterisk (*) to the hostName of the <site> definition node for your site, and leaving the targetHostName blank, as you currently have it. For example, you could do:

<site hostName="*brandname.local" ... targetHostName="" />

What this is going to do is force the LinkProvider.GetServerUrlElement(...) method to return a relative URL, as it will not have enough information to parse the actual server URL. The method is smart enough to recognize the asterisk in the hostName and knows that it cannot strip it from or use the property's value.

Analyzing the Workaround and the Bug

Note that this workround was found after an hour of mental variable substitution, which I did - at first - attempt to include here, but the post became far too long. I will leave it to you to do the variable substitution on your own, and will instead discuss specific parts of the substitution that are particularly critical to the solution and understanding the bugs.

For starters, let's have a look at the below snippet, noting that I have added "markers" in comments to help identify different pieces of logic in our discussion:

// Marker 1
if (!this.AlwaysIncludeServerUrl && siteInfo.Name.Equals(str1, StringComparison.OrdinalIgnoreCase) && hostName.Equals(str4, StringComparison.OrdinalIgnoreCase) || (string.IsNullOrEmpty(str4) || str4.IndexOf('*') >= 0))
    return str2;
string scheme = this.GetScheme();
StringComparison comparisonType = StringComparison.OrdinalIgnoreCase;
// Marker 2
if (str4.Equals(hostName, comparisonType) && defaultValue == port && str5.Equals(scheme, comparisonType))
    return str2;
// Marker 3
string str6 = str5 + "://" + str4;
// Marker 4
if (defaultValue > 0 && defaultValue != 80)
    str6 = str6 + ":" + (object) defaultValue;
return str6;

Reverse Engineering

The OP gives us a pretty good amount of information with which to debug this issue, including giving us the exit points for each scenario.

Scenario 2

Scenario 2 works correctly, and returns inside the conditional at Marker 2:

// Marker 2
if (str4.Equals(hostName, comparisonType) && defaultValue == port && str5.Equals(scheme, comparisonType))
  return str2;

The returned value is stored in the variable str2 which means, since we are looking for a relative URL - which Scenario 2 has - and a relative URL should have an empty string for a server URL, that str2 is equal to string.Empty. We can validate this by looking up in the method and seeing that the following line is used to set str2:

string str2 = this.AlwaysIncludeServerUrl ? WebUtil.GetServerUrl() : string.Empty

Since we know that AlwaysIncludeServerUrl is false and the value of str2 is never modified so long as AlwaysIncludeServerUrl is false, we know that with or without the workaround str2 holds the value we want to return.

Given the above and what we know from the OP, we can conclude that all of the following are true before getting to Marker 2:

// returned value
str2 == string.Empty

// required based on AND logic
str4 == hostName
defaultValue == port
str5 == scheme

Scenario 1

Let's now have a look over at Scenario 1, which returns after Marker 4. The first thing we will do is have a look at Marker 3, where the variable with th return value is initialized, and the conditional at Marker 4.

// Marker 3
string str6 = str5 + "://" + str4;
// Marker 4
if (defaultValue > 0 && defaultValue != 80)
    str6 = str6 + ":" + (object) defaultValue;
return str6;

Looking at the returned value in the OP, ://brandname.local, and the fact that we know that str6 holds the returned value, we can conclude that all of the following are true before Marker 3:

// nothing before the "://" in the returned value
str5 == string.Empty   

// value after the "://" in the URL             
str4 == "brandname.local" 

// there is no port number appended to the returned value
defaultValue <= 0 || defaultValue == 80

Looking at the Rest of the Method:

Just looking at what we have analyzed from the exit points of Scenario 1 and Scenario 2, we do not yet have enough information to find the workaround. Have a look at the conditional at Marker 1:

// Marker 1
if (!this.AlwaysIncludeServerUrl && siteInfo.Name.Equals(str1, StringComparison.OrdinalIgnoreCase) && hostName.Equals(str4, StringComparison.OrdinalIgnoreCase) || (string.IsNullOrEmpty(str4) || str4.IndexOf('*') >= 0))
    return str2;

Note that for both scenarios, this condition evaluated to false. Thus, we know that the following is false:

!this.AlwaysIncludeServerUrl && 
    siteInfo.Name.Equals(str1, StringComparison.OrdinalIgnoreCase) && 
    hostName.Equals(str4, StringComparison.OrdinalIgnoreCase) || 
    (string.IsNullOrEmpty(str4) || str4.IndexOf('*') >= 0)

Substituting in what we know from the OP and what we have learned so far, we get:

!(false) && 
    siteInfo.Name.Equals(str1, StringComparison.OrdinalIgnoreCase) && 
    hostName.Equals(str4, StringComparison.OrdinalIgnoreCase) ||
    (string.IsNullOrEmpty(str4) || str4.IndexOf('*') >= 0)

If we look at the logic that actually sets str4, we can see that it is set to the return of the GetTargetHostName(...) method, unless that value is null or empty, in which case it falls back to the hostName variable, which is the result of the GetHostName() method, which encapsulates the WebUtil.GetHostName() method.

Have a look at the GetTargetHostName(...) method:

  protected virtual string GetTargetHostName(SiteInfo siteInfo)
  {
    Assert.ArgumentNotNull((object) siteInfo, "siteInfo");
    if (!siteInfo.TargetHostName.IsNullOrEmpty())
      return siteInfo.TargetHostName;
    string hostName = siteInfo.HostName;
    if (hostName.IndexOfAny(new char[2]{ '*', '|' }) < 0)
      return hostName;
    return string.Empty;
  }

Here, we can see that if siteInfo.TargetHostName value is an empty string, which we know to be the case from the OP, then the hostName is returned so long as it doesn't contain an asterisk or a pipe (|). Otherwise, an empty string is returned.

Accounting for EXM Email Execution: The Bug and the Workaround

Before reading this section, note that when I first deduced the workaround, I did so by recording and proving assumptions, in order to "unit test" the method by hand. After doing so and presenting the solution to the OP, we worked together to debug and confirm the hypothesis and identify which assumption case was correct.

When EXM sends out emails it does so without a request context. As such. HttpContext.Current.Request is not set, so the return value of the GetHostName() method will be an empty string.

This is where the logic bug starts to become apparent: the siteInfo.TargetHostName falls back to the siteInfo.HostName so long as it doesn't contain an asterisk, and the result falls back to the HttpContext.Current.Request.Url.Host, which we know to be empty in our execution, since we are executing via EXM.

Look back at the Marker 1 condition that we substituted in the previous section, we can reduce the condition to the following:

siteInfo.Name.Equals(str1, StringComparison.OrdinalIgnoreCase) && 
    hostName.Equals(str4, StringComparison.OrdinalIgnoreCase) ||
    (string.IsNullOrEmpty(str4) || str4.IndexOf('*') >= 0)

Substituting in for simpler variables, we can equate this to:

A && B || (C || D)

which reduces, like so:

A && B || C || D

and if we set foo = A && B and substitute back in for C and D we get:

foo && string.IsNullOrEmpty(str4) || str4.IndexOf('*') >= 0

The Workaround:

So long as one of the last two expressions is true, it doesn't matter what foo is. Looking back at the GetTargetHostName(...) method, str4 should never be set to the siteInfo.HostName if siteInfo.HostName contains an asterisk, and so - since an asterisk is invalid in a URL's hostname and thus cannot come from the Request.Url.Host (whether or not there is a request context) - unless an asterisk is mistakenly put into the siteInfo.TargetHostName expression D can never be true. This leaves us with expression C. We can ensure that str4 is null or empty, by simply adding an asterisk or a pipe to the siteInfo.HostName, since there is no request context.

The Bug:

The problem here is that the LinkProvider.GetServerUrlElement(...) method does not correctly account for the possibility that the HttpContext could be unavailable. Since this is the case when EXM runs, the LinkProvider.GetServerUrlElement(...) method does not work correctly during EXM email dispatch.

Caveats of the Workaround

The bit caveat with this workaround is that it requires that you do not set the siteInfo.TargetHostName and that you add an asterisk to the hostName. This means that Sitecore will not be able to generate absolute URLs for any links, unless the code that is generating them has access to the HttpContext.

Furthermore, if the site is a multi-site and the code generating the links is supposed to generate them for a different <site> from the current request then the links will be generated for the wrong site. For example, if you are generating a link to the page http://foo.com/some/page and you are making the request from the URL http://bar.com then the resulting link would be http://bar.com/some/page instead of the expected value http://foo.com/some/page.

As such if any of the following are true then this workaround may not work for you:

  • Your site is a multi-site
  • Your site generates sitemaps using the siteInfo.TargetHostName
  • Your site generates absolute links using siteInfo.TargetHostName

Next Steps: Sitecore Support

Regardless of whether or not this workaround works for you, it should be only temporary. Reach out to Sitecore Support with the issue details and have them provide you with a patch for the LinkProvider, which you can use as a more permanent solution.

Alternative Solution in Concept

Alternatively, you can create your own LinkProvider to override the Sitecore one, allowing you to fix the bug in the code, directly, without having to deal with the caveats that I described. This is a good bit more involved than it was in previous versions, and I have not tried it myself.

Sitecore now registers the LinkProvider using the DI container included with Sitecore. In order to replace it with your own implementation, you will need to add a new <initialize> processor after the native LinkProvider is registered with the container, remove the native LinkProvider registration in your processor and then register your own. This would be the better solution, but it is still a bit black-box to me, as I have been having trouble finding the processor in which the native LinkProvider is registered. Given that, I have listed it only as an alternative solution.

Even if you do choose to implement this solution, I still recommend that you reach out to Sitecore Support for a patch to serve as a more permanent solution.