Skip to content
This repository has been archived by the owner on Dec 14, 2018. It is now read-only.

Absolute URL Methods Added to UrlHelper, Canonical URL Support and RequireHttpsAttribute Fix #2810

Closed
wants to merge 6 commits into from
48 changes: 40 additions & 8 deletions src/Microsoft.AspNet.Mvc.Core/IUrlHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,47 @@ public interface IUrlHelper
/// <returns>The fully qualified or absolute URL.</returns>
string RouteUrl([NotNull] UrlRouteContext routeContext);

/// <inheritdoc />
/// <summary>
/// Generates an absolute URL using the specified route name and values.
/// Generates a fully qualified URL to an action method by using the specified action name, controller name and
/// route values.
/// </summary>
/// <param name="routeName">The name of the route that is used to generate the URL.</param>
/// <param name="values">An object that contains the route values.</param>
/// <returns>The generated absolute URL.</returns>
/// <remarks>
/// The protocol and host is obtained from the current request.
/// </remarks>
string Link(string routeName, object values);
/// <param name="url">The URL helper.</param>
/// <param name="actionName">The name of the action method.</param>
/// <param name="controllerName">The name of the controller.</param>
/// <param name="routeValues">The route values.</param>
/// <returns>The absolute URL.</returns>
string AbsoluteAction(
string actionName,
string controllerName,
object routeValues = null);

/// <summary>
/// Generates a fully qualified URL to the specified content by using the specified content path. Converts a
/// virtual (relative) path to an application absolute path.
/// </summary>
/// <param name="url">The URL helper.</param>
/// <param name="contentPath">The content path.</param>
/// <returns>The absolute URL.</returns>
string AbsoluteContent(string contentPath);

/// <summary>
/// Generates a fully qualified URL to the specified content by using the specified content path and host.
/// Converts a virtual (relative) path to an application absolute path.
/// </summary>
/// <param name="url">The URL helper.</param>
/// <param name="contentPath">The content path.</param>
/// <param name="host">The host name.</param>
/// <returns>The absolute URL.</returns>
string AbsoluteContent(string contentPath, string host);

/// <summary>
/// Generates a fully qualified URL to the specified route by using the route name and route values.
/// </summary>
/// <param name="url">The URL helper.</param>
/// <param name="routeName">Name of the route.</param>
/// <param name="routeValues">The route values.</param>
/// <returns>The absolute URL.</returns>
string AbsoluteRouteUrl(string routeName, object routeValues = null);
}
}
59 changes: 59 additions & 0 deletions src/Microsoft.AspNet.Mvc.Core/NoTrailingSlashAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace Microsoft.AspNet.Mvc
{
using System;

/// <summary>
/// Requires that a HTTP request does not contain a trailing slash. If it does, return a 404 Not Found. This is
/// useful if you are dynamically generating something which acts like it's a file on the web server.
/// E.g. /Robots.txt/ should not have a trailing slash and should be /Robots.txt. Note, that we also don't care if
/// it is upper-case or lower-case in this instance.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
public class NoTrailingSlashAttribute : AuthorizationFilterAttribute
{
private const char QueryCharacter = '?';
private const char SlashCharacter = '/';

/// <summary>
/// Determines whether a request contains a trailing slash and, if it does, calls the
/// <see cref="HandleTrailingSlashRequest"/> method.
/// </summary>
/// <param name="context">An object that encapsulates information that is required in order to use the
/// <see cref="RequireHttpsAttribute"/> attribute.</param>
public override void OnAuthorization(AuthorizationContext context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}

string canonicalUrl = context.HttpContext.Request.Path.ToString();
int queryIndex = canonicalUrl.IndexOf(QueryCharacter);

if (queryIndex == -1)
{
if (canonicalUrl[canonicalUrl.Length - 1] == SlashCharacter)
{
this.HandleTrailingSlashRequest(context);
}
}
else
{
if (canonicalUrl[queryIndex - 1] == SlashCharacter)
{
this.HandleTrailingSlashRequest(context);
}
}
}

/// <summary>
/// Handles HTTP requests that have a trailing slash but are not meant to.
/// </summary>
/// <param name="filterContext">An object that encapsulates information that is required in order to use the
/// <see cref="RequireHttpsAttribute"/> attribute.</param>
protected virtual void HandleTrailingSlashRequest(AuthorizationContext filterContext)
{
filterContext.Result = new HttpNotFoundResult();
}
}
}
208 changes: 208 additions & 0 deletions src/Microsoft.AspNet.Mvc.Core/RedirectToCanonicalUrlAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
namespace Microsoft.AspNet.Mvc
{
using System;

/// <summary>
/// To improve Search Engine Optimization SEO, there should only be a single URL for each resource. Case
/// differences and/or URL's with/without trailing slashes are treated as different URL's by search engines. This
/// filter redirects all non-canonical URL's based on the settings specified to their canonical equivalent.
/// Note: Non-canonical URL's are not generated by this site template, it is usually external sites which are
/// linking to your site but have changed the URL case or added/removed trailing slashes.
/// (See Google's comments at http://googlewebmastercentral.blogspot.co.uk/2010/04/to-slash-or-not-to-slash.html
/// and Bing's at http://blogs.bing.com/webmaster/2012/01/26/moving-content-think-301-not-relcanonical).
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
public class RedirectToCanonicalUrlAttribute : AuthorizationFilterAttribute
{
#region Fields

private const char QueryCharacter = '?';
private const char SlashCharacter = '/';

private readonly bool appendTrailingSlash;
private readonly bool lowercaseUrls;

#endregion

#region Constructors

/// <summary>
/// Initializes a new instance of the <see cref="RedirectToCanonicalUrlAttribute" /> class.
/// </summary>
/// <param name="appendTrailingSlash">If set to <c>true</c> append trailing slashes, otherwise strip trailing
/// slashes.</param>
/// <param name="lowercaseUrls">If set to <c>true</c> lower-case all URL's.</param>
public RedirectToCanonicalUrlAttribute(
bool appendTrailingSlash,
bool lowercaseUrls)
{
this.appendTrailingSlash = appendTrailingSlash;
this.lowercaseUrls = lowercaseUrls;
}

#endregion

#region Public Properties

/// <summary>
/// Gets a value indicating whether to append trailing slashes.
/// </summary>
/// <value>
/// <c>true</c> if appending trailing slashes; otherwise, strip trailing slashes.
/// </value>
public bool AppendTrailingSlash
{
get { return this.appendTrailingSlash; }
}

/// <summary>
/// Gets a value indicating whether to lower-case all URL's.
/// </summary>
/// <value>
/// <c>true</c> if lower-casing URL's; otherwise, <c>false</c>.
/// </value>
public bool LowercaseUrls
{
get { return this.lowercaseUrls; }
}

#endregion

#region Public Methods

/// <summary>
/// Determines whether the HTTP request contains a non-canonical URL using <see cref="TryGetCanonicalUrl"/>,
/// if it doesn't calls the <see cref="HandleNonCanonicalRequest"/> method.
/// </summary>
/// <param name="context">An object that encapsulates information that is required in order to use the
/// <see cref="RedirectToCanonicalUrlAttribute"/> attribute.</param>
/// <exception cref="ArgumentNullException">The <paramref name="context"/> parameter is <c>null</c>.</exception>
public override void OnAuthorization(AuthorizationContext context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}

string canonicalUrl;
if (!this.TryGetCanonicalUrl(context, out canonicalUrl))
{
this.HandleNonCanonicalRequest(context, canonicalUrl);
}
}

#endregion

#region Protected Methods

/// <summary>
/// Determines whether the specified URl is canonical and if it is not, outputs the canonical URL.
/// </summary>
/// <param name="context">An object that encapsulates information that is required in order to use the
/// <see cref="RedirectToCanonicalUrlAttribute" /> attribute.</param>
/// <param name="canonicalUrl">The canonical URL.</param>
/// <returns><c>true</c> if the URL is canonical, otherwise <c>false</c>.</returns>
protected virtual bool TryGetCanonicalUrl(AuthorizationContext context, out string canonicalUrl)
{
bool isCanonical = true;

canonicalUrl = context.HttpContext.Request.Path.ToString();
int queryIndex = canonicalUrl.IndexOf(QueryCharacter);

if (queryIndex == -1)
{
bool hasTrailingSlash = canonicalUrl[canonicalUrl.Length - 1] == SlashCharacter;

if (this.appendTrailingSlash)
{
// Append a trailing slash to the end of the URL.
if (!hasTrailingSlash && !this.HasNoTrailingSlashAttribute(context))
{
canonicalUrl += SlashCharacter;
isCanonical = false;
}
}
else
{
// Trim a trailing slash from the end of the URL.
if (hasTrailingSlash)
{
canonicalUrl = canonicalUrl.TrimEnd(SlashCharacter);
isCanonical = false;
}
}
}
else
{
bool hasTrailingSlash = canonicalUrl[queryIndex - 1] == SlashCharacter;

if (this.appendTrailingSlash)
{
// Append a trailing slash to the end of the URL but before the query string.
if (!hasTrailingSlash && !this.HasNoTrailingSlashAttribute(context))
{
canonicalUrl = canonicalUrl.Insert(queryIndex, SlashCharacter.ToString());
isCanonical = false;
}
}
else
{
// Trim a trailing slash to the end of the URL but before the query string.
if (hasTrailingSlash)
{
canonicalUrl = canonicalUrl.Remove(queryIndex - 1, 1);
isCanonical = false;
}
}
}

if (this.lowercaseUrls)
{
foreach (char character in canonicalUrl)
{
if (char.IsUpper(character) && !this.HasNoTrailingSlashAttribute(context))
{
canonicalUrl = canonicalUrl.ToLower();
isCanonical = false;
break;
}
}
}

return isCanonical;
}

/// <summary>
/// Handles HTTP requests for URL's that are not canonical. Performs a 301 Permanent Redirect to the canonical URL.
/// </summary>
/// <param name="context">An object that encapsulates information that is required in order to use the
/// <see cref="RedirectToCanonicalUrlAttribute" /> attribute.</param>
/// <param name="canonicalUrl">The canonical URL.</param>
protected virtual void HandleNonCanonicalRequest(AuthorizationContext context, string canonicalUrl)
{
context.Result = new RedirectResult(canonicalUrl, true);
}

/// <summary>
/// Determines whether the specified action or its controller has the <see cref="NoTrailingSlashAttribute"/>
/// attribute specified.
/// </summary>
/// <param name="filterContext">The filter context.</param>
/// <returns><c>true</c> if a <see cref="NoTrailingSlashAttribute"/> attribute is specified, otherwise
/// <c>false</c>.</returns>
protected virtual bool HasNoTrailingSlashAttribute(AuthorizationContext filterContext)
{
foreach (IFilterMetadata filter in filterContext.Filters)
{
if (filter is NoTrailingSlashAttribute)
{
return true;
}
}

return false;
}

#endregion
}
}
4 changes: 2 additions & 2 deletions src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ public virtual void OnAuthorization([NotNull]AuthorizationContext filterContext)
protected virtual void HandleNonHttpsRequest(AuthorizationContext filterContext)
{
// only redirect for GET requests, otherwise the browser might not propagate the verb and request
// body correctly.
// body correctly.
if (!string.Equals(filterContext.HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
{
filterContext.Result = new HttpStatusCodeResult(StatusCodes.Status403Forbidden);
filterContext.Result = new HttpStatusCodeResult(StatusCodes.Status405MethodNotAllowed);
}
else
{
Expand Down
Loading